chromium/chrome/test/data/webui/webview/webview_content_script_test.ts

// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {assertEquals, assertNotEquals, assertTrue} from 'chrome://webui-test/chai_assert.js';

suite('WebviewContentScriptTest', function() {
  const REQUEST_TO_COMM_CHANNEL_1 = 'connect';
  const REQUEST_TO_COMM_CHANNEL_2 = 'connect_request';
  const RESPONSE_FROM_COMM_CHANNEL_1 = 'connected';
  const RESPONSE_FROM_COMM_CHANNEL_2 = 'connected_response';

  function createWebview(): chrome.webviewTag.WebView {
    const webview =
        document.createElement('webview') as chrome.webviewTag.WebView;
    document.body.appendChild(webview);
    return webview;
  }

  function checkBackgroundColor(webview: chrome.webviewTag.WebView):
      Promise<void> {
    return new Promise<void>(resolve => {
      webview.executeScript(
          {code: 'document.body.style.backgroundColor;'}, (results: any[]) => {
            assertEquals(1, results.length);
            assertEquals('red', results[0]);
            resolve();
          });
    });
  }

  function getWebviewUrl(): string {
    return (window as unknown as Window & {webviewUrl: string}).webviewUrl;
  }

  test('ExecuteScriptCode', async () => {
    const webview = createWebview();

    const whenLoaded = new Promise<void>(resolve => {
      webview.addEventListener('loadstop', () => {
        webview.executeScript(
            {code: 'document.body.style.backgroundColor = \'red\';'},
            (_results: any[]) => {
              resolve();
            });
      });
    });
    webview.src = getWebviewUrl();
    await whenLoaded;
    await checkBackgroundColor(webview);
  });

  test('ExecuteScriptCodeFromFile', async () => {
    const webview = createWebview();
    const whenLoaded = new Promise<void>(resolve => {
      webview.addEventListener('loadstop', () => {
        webview.executeScript({file: 'test/webview_execute_script.js'}, () => {
          resolve();
        });
      });
    });
    webview.src = getWebviewUrl();
    await whenLoaded;
    await checkBackgroundColor(webview);
  });

  // This test verifies that a content script will be injected to the webview
  // when the webview is navigated to a page that matches the URL pattern
  // defined in the content script.
  test('AddContentScript', async () => {
    const webview =
        document.createElement('webview') as chrome.webviewTag.WebView;

    console.info('Step 1: call <webview>.addContentScripts.');
    webview.addContentScripts([{
      name: 'myrule',
      matches: ['http://*/empty*'],
      js: {
        files: ['test/inject_comm_channel.js', 'test/inject_comm_channel_2.js'],
      },
      run_at: 'document_start' as chrome.extensionTypes.RunAt,
    }]);

    webview.addEventListener('loadstop', function() {
      console.info('Step 2: postMessage to build connection.');
      const msg = [REQUEST_TO_COMM_CHANNEL_1];
      assertTrue(!!webview.contentWindow);
      webview.contentWindow!.postMessage(JSON.stringify(msg), '*');
    });

    const whenMessageReceived = new Promise<void>(resolve => {
      window.addEventListener('message', function(e) {
        if (e.source !== webview.contentWindow) {
          return;
        }
        const data = JSON.parse(e.data);
        assertEquals(
            RESPONSE_FROM_COMM_CHANNEL_1, data[0],
            'Unexpected message: \'' + data[0] + '\'');
        console.info(
            'Step 3: A communication channel has been established with ' +
            'webview.');
        resolve();
      });
    });

    webview.src = getWebviewUrl();
    document.body.appendChild(webview);
    await whenMessageReceived;
  });

  // Adds two content scripts with the same URL pattern to <webview> at the same
  // time. This test verifies that both scripts are injected when the <webview>
  // navigates to a URL that matches the URL pattern.
  test('AddMultiContentScripts', async () => {
    const webview =
        document.createElement('webview') as chrome.webviewTag.WebView;

    console.info('Step 1: call <webview>.addContentScripts(myrule1 & myrule2)');
    webview.addContentScripts([
      {
        name: 'myrule1',
        matches: ['http://*/empty*'],
        js: {files: ['test/inject_comm_channel.js']},
        run_at: 'document_start' as chrome.extensionTypes.RunAt,
      },
      {
        name: 'myrule2',
        matches: ['http://*/empty*'],
        js: {files: ['test/inject_comm_channel_2.js']},
        run_at: 'document_start' as chrome.extensionTypes.RunAt,
      },
    ]);

    webview.addEventListener('loadstop', function() {
      console.info('Step 2: postMessage to build connection.');
      const msg1 = [REQUEST_TO_COMM_CHANNEL_1];
      webview.contentWindow!.postMessage(JSON.stringify(msg1), '*');
      console.info(
          'Step 3: postMessage to build connection to the other script.');
      const msg2 = [REQUEST_TO_COMM_CHANNEL_2];
      webview.contentWindow!.postMessage(JSON.stringify(msg2), '*');
    });

    let response1 = false;
    let response2 = false;
    const whenResponse = new Promise<void>(resolve => {
      window.addEventListener('message', function(e) {
        if (e.source !== webview.contentWindow) {
          return;
        }
        const data = JSON.parse(e.data);
        if (data[0] === RESPONSE_FROM_COMM_CHANNEL_1) {
          console.info(
              'Step 4: A communication channel has been established with ' +
              'webview.');
          response1 = true;
          if (response1 && response2) {
            resolve();
          }
          return;
        }
        assertEquals(
            RESPONSE_FROM_COMM_CHANNEL_2, data[0],
            'Unexpected message: \'' + data[0] + '\'');
        console.info(
            'Step 5: A communication channel has been established with ' +
            'webview.');
        response2 = true;
        if (response1 && response2) {
          resolve();
        }
        return;
      });
    });

    webview.src = getWebviewUrl();
    document.body.appendChild(webview);
    await whenResponse;
  });

  // Adds a content script to <webview> and navigates. After seeing the script
  // is injected, we add another content script with the same name to the
  // <webview>. This test verifies that the second script will replace the first
  // one and be injected after navigating the <webview>. Meanwhile, the
  // <webview> shouldn't get any message from the first script anymore.
  test(
      'AddContentScriptWithSameNameShouldOverwriteTheExistingOne', async () => {
        const webview =
            document.createElement('webview') as chrome.webviewTag.WebView;

        console.info('Step 1: call <webview>.addContentScripts(myrule1)');
        webview.addContentScripts([{
          name: 'myrule1',
          matches: ['http://*/empty*'],
          js: {files: ['test/inject_comm_channel.js']},
          run_at: 'document_start' as chrome.extensionTypes.RunAt,
        }]);
        let connectScript1 = true;
        let connectScript2 = false;

        webview.addEventListener('loadstop', function() {
          if (connectScript1) {
            const msg1 = [REQUEST_TO_COMM_CHANNEL_1];
            webview.contentWindow!.postMessage(JSON.stringify(msg1), '*');
            connectScript1 = false;
          }
          if (connectScript2) {
            const msg2 = [REQUEST_TO_COMM_CHANNEL_2];
            webview.contentWindow!.postMessage(JSON.stringify(msg2), '*');
            connectScript2 = false;
          }
        });

        let shouldGetResponseFromScript1 = true;
        const whenChannel2ResponseReceived = new Promise<void>(resolve => {
          window.addEventListener('message', function(e) {
            if (e.source !== webview.contentWindow) {
              return;
            }
            const data = JSON.parse(e.data);
            if (data[0] === RESPONSE_FROM_COMM_CHANNEL_1) {
              assertTrue(shouldGetResponseFromScript1);
              console.info(
                  'Step 2: A communication channel has been established with ' +
                  'webview.');
              console.info(
                  'Step 3: <webview>.addContentScripts() with a updated' +
                  ' \'myrule1\'');
              webview.addContentScripts([{
                name: 'myrule1',
                matches: ['http://*/empty*'],
                js: {files: ['test/inject_comm_channel_2.js']},
                run_at: 'document_start' as chrome.extensionTypes.RunAt,
              }]);
              connectScript2 = true;
              shouldGetResponseFromScript1 = false;
              webview.src = getWebviewUrl();
              return;
            }
            assertEquals(
                RESPONSE_FROM_COMM_CHANNEL_2, data[0],
                'Unexpected message : \'' + data[0] + '\'');
            console.info(
                'Step 4: Another communication channel has been established ' +
                'with webview.');
            resolve();
          });
        });

        webview.src = getWebviewUrl();
        document.body.appendChild(webview);
        await whenChannel2ResponseReceived;
      });

  // There are two <webview>s are added to the DOM, and we add a content script
  // to one of them. This test verifies that the script won't be injected in
  // the other <webview>.
  test(
      'AddContentScriptToOneWebViewShouldNotInjectToTheOtherWebView',
      async () => {
        const webview1 =
            document.createElement('webview') as chrome.webviewTag.WebView;
        const webview2 =
            document.createElement('webview') as chrome.webviewTag.WebView;

        console.info('Step 1: call <webview1>.addContentScripts.');
        webview1.addContentScripts([{
          name: 'myrule',
          matches: ['http://*/empty*'],
          js: {files: ['test/inject_comm_channel.js']},
          run_at: 'document_start' as chrome.extensionTypes.RunAt,
        }]);

        const whenMessagePosted = new Promise<void>(resolve => {
          webview2.addEventListener('loadstop', function() {
            console.info(
                'Step 2: webview2 requests to build communication channel.');
            const msg = [REQUEST_TO_COMM_CHANNEL_1];
            webview2.contentWindow!.postMessage(JSON.stringify(msg), '*');
            setTimeout(function() {
              resolve();
            }, 0);
          });
        });

        window.addEventListener('message', function(e) {
          assertNotEquals(
              webview2.contentWindow, e.source,
              'Unexpected message : \'' + JSON.parse(e.data)[0] + '\'');
        });
        webview1.src = getWebviewUrl();
        webview2.src = getWebviewUrl();
        document.body.appendChild(webview1);
        document.body.appendChild(webview2);
        await whenMessagePosted;
      });

  // Adds a content script to <webview> and navigates to a URL that matches the
  // URL pattern defined in the script. After the first navigation, we remove
  // this script from the <webview> and navigates to the same URL. This test
  // verifies that the script is injected during the first navigation, but isn't
  // injected after removing it.
  test('AddAndRemoveContentScripts', async () => {
    const webview =
        document.createElement('webview') as chrome.webviewTag.WebView;

    console.info('Step 1: call <webview>.addContentScripts.');
    webview.addContentScripts([{
      name: 'myrule',
      matches: ['http://*/empty*'],
      js: {files: ['test/inject_comm_channel.js']},
      run_at: 'document_start' as chrome.extensionTypes.RunAt,
    }]);

    let shouldGetResponseFromScript1 = true;

    let count = 0;
    const whenPostSecondMessage = new Promise<void>(resolve => {
      webview.addEventListener('loadstop', function() {
        if (count === 0) {
          console.info('Step 2: post message to build connect.');
          const msg = [REQUEST_TO_COMM_CHANNEL_1];
          webview.contentWindow!.postMessage(JSON.stringify(msg), '*');
          ++count;
        } else if (count === 1) {
          console.info('Step 5: post message to build connect again.');
          const msg = [REQUEST_TO_COMM_CHANNEL_1];
          webview.contentWindow!.postMessage(JSON.stringify(msg), '*');
          setTimeout(function() {
            resolve();
          }, 0);
        }
      });
    });

    window.addEventListener('message', function(e) {
      if (e.source !== webview.contentWindow) {
        return;
      }
      const data = JSON.parse(e.data);
      assertTrue(shouldGetResponseFromScript1);
      assertEquals(
          RESPONSE_FROM_COMM_CHANNEL_1, data[0],
          'Unexpected message: \'' + data[0] + '\'');
      console.info(
          'Step 3: A communication channel has been established ' +
          'with webview.');
      shouldGetResponseFromScript1 = false;
      console.info('Step 4: call <webview>.removeContentScripts and navigate.');
      webview.removeContentScripts();
      webview.src = getWebviewUrl();
    });

    webview.src = getWebviewUrl();
    document.body.appendChild(webview);
    await whenPostSecondMessage;
  });

  // This test verifies that the addContentScripts API works with the new window
  // API.
  test('AddContentScriptsWithNewWindowAPI', async () => {
    const webview =
        document.createElement('webview') as chrome.webviewTag.WebView;

    let newwebview: chrome.webviewTag.WebView;
    webview.addEventListener('newwindow', function(e) {
      e.preventDefault();
      newwebview =
          document.createElement('webview') as chrome.webviewTag.WebView;

      console.info('Step 2: call newwebview.addContentScripts.');
      newwebview.addContentScripts([{
        name: 'myrule',
        matches: ['http://*/guest_from_opener*'],
        js: {files: ['test/inject_comm_channel.js']},
        run_at: 'document_start' as chrome.extensionTypes.RunAt,
      }]);

      newwebview.addEventListener('loadstop', function() {
        const msg = [REQUEST_TO_COMM_CHANNEL_1];
        console.info(
            'Step 4: new webview postmessage to build communication ' +
            'channel.');
        newwebview.contentWindow!.postMessage(JSON.stringify(msg), '*');
      });

      document.body.appendChild(newwebview);
      // attach the new window to the new <webview>.
      console.info('Step 3: attaches the new webview.');
      const newwindow = (e as Event & {window: Window}).window;
      (newwindow as Window & {
        attach: (frame: HTMLIFrameElement) => void,
      }).attach(newwebview);
    });

    const whenResponseFromChannel1 = new Promise<void>(resolve => {
      window.addEventListener('message', function(e) {
        if (!newwebview || e.source !== newwebview.contentWindow) {
          return;
        }
        const data = JSON.parse(e.data);
        assertEquals(newwebview.contentWindow, e.source);
        assertEquals(
            RESPONSE_FROM_COMM_CHANNEL_1, data[0],
            'Unexpected message: \'' + data[0] + '\'');
        console.info(
            'Step 5: a communication channel has been established ' +
            'with the new webview.');
        resolve();
      });
    });

    console.info('Step 1: navigates the webview to window open guest URL.');
    webview.setAttribute('src', getWebviewUrl());
    document.body.appendChild(webview);
    await whenResponseFromChannel1;
  });

  // Adds a content script to <webview>. This test verifies that the script is
  // injected after terminate and reload <webview>.
  test('ContentScriptIsInjectedAfterTerminateAndReloadWebView', async () => {
    const webview =
        document.createElement('webview') as chrome.webviewTag.WebView;

    console.info('Step 1: call <webview>.addContentScripts.');
    webview.addContentScripts([{
      name: 'myrule',
      matches: ['http://*/empty*'],
      js: {files: ['test/webview_execute_script.js']},
      run_at: 'document_end' as chrome.extensionTypes.RunAt,
    }]);

    let count = 0;
    const onGetBackgroundExecuted = new Promise<void>(resolve => {
      webview.addEventListener('loadstop', function() {
        if (count === 0) {
          console.info('Step 2: call webview.terminate().');
          webview.terminate();
          ++count;
          return;
        } else if (count === 1) {
          console.info('Step 4: call <webview>.executeScript to check result.');
          checkBackgroundColor(webview).then(resolve);
        }
      });
    });

    webview.addEventListener('exit', function() {
      console.info('Step 3: call webview.reload().');
      webview.reload();
    });

    webview.src = getWebviewUrl();
    document.body.appendChild(webview);
    await onGetBackgroundExecuted;
  });

  // This test verifies the content script won't be removed when the guest is
  // destroyed, i.e., removed <webview> from the DOM.
  test('ContentScriptExistsAsLongAsWebViewTagExists', async () => {
    const webview =
        document.createElement('webview') as chrome.webviewTag.WebView;

    console.info('Step 1: call <webview>.addContentScripts.');
    webview.addContentScripts([{
      name: 'myrule',
      matches: ['http://*/empty*'],
      js: {files: ['test/webview_execute_script.js']},
      run_at: 'document_end' as chrome.extensionTypes.RunAt,
    }]);

    let count = 0;
    const whenBackgroundColorChecked = new Promise<void>(resolve => {
      webview.addEventListener('loadstop', function() {
        if (count === 0) {
          console.info('Step 2: check the result of content script injected.');
          webview.executeScript(
              {code: 'document.body.style.backgroundColor;'},
              function(results) {
                assertEquals(1, results.length);
                assertEquals('red', results[0]);

                console.info('Step 3: remove webview from the DOM.');
                document.body.removeChild(webview);
                console.info('Step 4: add webview back to the DOM.');
                document.body.appendChild(webview);
                ++count;
              });
        } else if (count === 1) {
          console.info(
              'Step 5: check the result of content script injected again.');
          checkBackgroundColor(webview).then(resolve);
        }
      });
    });

    webview.src = getWebviewUrl();
    document.body.appendChild(webview);
    await whenBackgroundColorChecked;
  });

  test('AddContentScriptWithCode', async () => {
    const webview =
        document.createElement('webview') as chrome.webviewTag.WebView;

    console.info('Step 1: call <webview>.addContentScripts.');
    webview.addContentScripts([{
      name: 'myrule',
      matches: ['http://*/empty*'],
      js: {code: 'document.body.style.backgroundColor = \'red\';'},
      run_at: 'document_end' as chrome.extensionTypes.RunAt,
    }]);

    const whenLoadStop = new Promise<void>(resolve => {
      webview.addEventListener('loadstop', function() {
        console.info('Step 2: call webview.executeScript() to check result.');
        resolve();
      });
    });

    webview.src = getWebviewUrl();
    document.body.appendChild(webview);
    await whenLoadStop;
    await checkBackgroundColor(webview);
  });
});