import * as dom from '#core/dom';
import {setShadowDomSupportedVersionForTesting} from '#core/dom/web-components';
import {toArray} from '#core/types/array';

import {Services} from '#service';
import {AmpDocShadow, AmpDocSingle} from '#service/ampdoc-impl';
import {installAmpdocServices} from '#service/core-services';
import * as ext from '#service/extensions-impl';
import {installPlatformService} from '#service/platform-impl';
import {installTemplatesServiceForDoc} from '#service/template-impl';
import {installTimerService} from '#service/timer-impl';
import {vsyncForTesting} from '#service/vsync-impl';

import {deactivateChunking, runChunksForTesting} from '../../src/chunk';
import {ElementStub} from '../../src/element-stub';
import {adopt, adoptShadowMode} from '../../src/runtime';
import {
  getServiceForDoc,
  getServicePromise,
  getServicePromiseOrNullForDoc,
} from '../../src/service-helpers';
import {createShadowRoot} from '../../src/shadow-embed';
import * as styles from '../../src/style-installer';

describes.fakeWin(
  'runtime',
  {
    location: 'https://cdn.ampproject.org/c/s/www.example.com/path',
  },
  (env) => {
    let win;
    let clock;
    let ampdocService;
    let ampdocServiceMock;
    let extensionElementIndex;

    function runChunks(elementOrAmpDoc) {
      clock.tick(1); // for early frame
      runChunksForTesting(elementOrAmpDoc);
    }

    beforeEach(() => {
      win = env.win;
      clock = env.sandbox.useFakeTimers();
      extensionElementIndex = 0;
      ampdocService = {
        isSingleDoc: () => true,
        getSingleDoc: () => null,
        getAmpDoc: () => null,
        installShadowDoc_: () => null,
      };
      ampdocServiceMock = env.sandbox.mock(ampdocService);
      win.AMP = [];
      win.__AMP_SERVICES = {
        ampdoc: {obj: ampdocService, ctor: Object},
      };
      const ampdoc = new AmpDocSingle(win);
      ampdocService.getSingleDoc = () => ampdoc;
      ampdocService.getAmpDoc = () => ampdoc;
      installPlatformService(win);
      installTimerService(win);
      vsyncForTesting(win);
      installAmpdocServices(ampdoc);
    });

    function regularExtension(fn, opt_rtvVersion) {
      return {
        n: 'amp-test-element' + extensionElementIndex++,
        ev: '0.1',
        l: true,
        f: fn,
        // Default version of uncompiled sources.
        v: opt_rtvVersion || '$internalRuntimeVersion$',
      };
    }

    afterEach(() => {
      ampdocServiceMock.verify();
    });

    it('should convert AMP from array to AMP object in single-doc', () => {
      expect(win.AMP.push).to.equal([].push);
      adopt(win);
      expect(win.AMP.push).to.not.equal([].push);
      expect(win.__AMP_TAG).to.be.true;
    });

    it('should convert AMP from array to AMP object in shadow-doc', () => {
      expect(win.AMP.push).to.equal([].push);
      adoptShadowMode(win);
      expect(win.AMP.push).to.not.equal([].push);
      expect(win.__AMP_TAG).to.be.true;
    });

    it('should install legacy stubs in single-doc', () => {
      const initial = win.__AMP_EXTENDED_ELEMENTS || {};
      expect(initial['amp-ad']).to.be.undefined;
      expect(initial['amp-embed']).to.be.undefined;
      expect(initial['amp-video']).to.be.undefined;
      adopt(win);
      expect(win.__AMP_EXTENDED_ELEMENTS['amp-ad']).to.equal(ElementStub);
      expect(win.__AMP_EXTENDED_ELEMENTS['amp-embed']).to.equal(ElementStub);
      expect(win.__AMP_EXTENDED_ELEMENTS['amp-video']).to.equal(ElementStub);
    });

    it('should install legacy stubs in shadow-doc', () => {
      const initial = win.__AMP_EXTENDED_ELEMENTS || {};
      expect(initial['amp-ad']).to.be.undefined;
      expect(initial['amp-embed']).to.be.undefined;
      expect(initial['amp-video']).to.be.undefined;
      adoptShadowMode(win);
      expect(win.__AMP_EXTENDED_ELEMENTS['amp-ad']).to.equal(ElementStub);
      expect(win.__AMP_EXTENDED_ELEMENTS['amp-embed']).to.equal(ElementStub);
      expect(win.__AMP_EXTENDED_ELEMENTS['amp-video']).to.equal(ElementStub);
    });

    it('should NOT set cursor:pointer on document element on non-IOS', () => {
      const platform = Services.platformFor(win);
      env.sandbox.stub(platform, 'isIos').returns(false);
      adopt(win);
      expect(win.document.documentElement.style.cursor).to.not.be.ok;
    });

    it('should set cursor:pointer on document element on IOS', () => {
      const platform = Services.platformFor(win);
      env.sandbox.stub(platform, 'isIos').returns(true);
      adopt(win);
      expect(win.document.documentElement.style.cursor).to.equal('pointer');
    });

    it('should set cursor:pointer on IOS in shadow-doc', () => {
      const platform = Services.platformFor(win);
      env.sandbox.stub(platform, 'isIos').returns(true);
      adoptShadowMode(win);
      expect(win.document.documentElement.style.cursor).to.equal('pointer');
    });

    function extensionRegistrationTest() {
      let progress = '';
      const queueExtensions = win.AMP;
      win.AMP.push(
        regularExtension((amp) => {
          expect(amp).to.equal(win.AMP);
          progress += '1';
        })
      );
      win.AMP.push(
        regularExtension((amp) => {
          expect(amp).to.equal(win.AMP);
          progress += '2';
        })
      );
      win.AMP.push(
        regularExtension((amp) => {
          expect(amp).to.equal(win.AMP);
          progress += '3';
        })
      );
      expect(queueExtensions).to.have.length(3);
      const promise = adopt(win);
      runChunks(win.document);
      return promise
        .then(() => {
          expect(queueExtensions).to.have.length(0);
          expect(progress).to.equal('123');
          win.AMP.push(
            regularExtension((amp) => {
              expect(amp).to.equal(win.AMP);
              progress += '4';
            })
          );
          runChunks(win.document);
          return promise;
        })
        .then(() => {
          expect(progress).to.equal('1234');
          win.AMP.push(
            regularExtension((amp) => {
              expect(amp).to.equal(win.AMP);
              progress += '5';
            })
          );
          runChunks(win.document);
          return promise;
        })
        .then(() => {
          expect(progress).to.equal('12345');
          expect(queueExtensions).to.have.length(0);
        });
    }

    it(
      'should execute scheduled extensions & execute new extensions',
      extensionRegistrationTest
    );

    it('should not maybePumpEarlyFrame when body not yet present', () => {
      // Make document.body be null on first invocation to simulate
      // JS executing before the rest of the doc has been parsed.
      const {body} = win.document;
      let accessedOnce = false;
      env.sandbox.defineProperty(win.document, 'body', {
        get: () => {
          if (accessedOnce) {
            return body;
          }
          accessedOnce = true;
          return null;
        },
      });
      extensionRegistrationTest();
    });

    it(
      'should not maybePumpEarlyFrame ' +
        'when a renderDelayingExtension is present',
      () => {
        win.document.body.appendChild(document.createElement('amp-experiment'));
        extensionRegistrationTest();
      }
    );

    it('should maybePumpEarlyFrame and delay extension execution', () => {
      let progress = '';
      const queueExtensions = win.AMP;
      const highPriority = regularExtension((amp) => {
        expect(amp).to.equal(win.AMP);
        progress += 'high';
      });
      highPriority.p = 'high';
      win.AMP.push(highPriority);
      win.AMP.push(
        regularExtension((amp) => {
          expect(amp).to.equal(win.AMP);
          progress += '1';
        })
      );
      win.AMP.push(
        regularExtension((amp) => {
          expect(amp).to.equal(win.AMP);
          progress += '2';
        })
      );
      win.AMP.push(() => {
        progress += 'function';
      });
      win.AMP.push(
        regularExtension((amp) => {
          expect(amp).to.equal(win.AMP);
          progress += '3';
        })
      );
      expect(queueExtensions).to.have.length(5);
      const promise = adopt(win);
      runChunksForTesting(win.document);
      return promise
        .then(() => {
          // Skip a microtask.
          return Promise.resolve();
        })
        .then(() => {
          expect(progress).to.equal('highfunction');
          expect(queueExtensions).to.have.length(3);
          clock.tick();
          expect(queueExtensions).to.have.length(3);
          expect(progress).to.equal('highfunction');
          // New extension arrives before inital ran.
          win.AMP.push(
            regularExtension((amp) => {
              expect(amp).to.equal(win.AMP);
              progress += '4';
            })
          );
          expect(queueExtensions).to.have.length(4);
          clock.tick(1);
          expect(queueExtensions).to.have.length(0);
          runChunks(win.document);
          return promise;
        })
        .then(() => {
          expect(progress).to.equal('highfunction1234');
          win.AMP.push(
            regularExtension((amp) => {
              expect(amp).to.equal(win.AMP);
              progress += '5';
            })
          );
          runChunks(win.document);
          return promise;
        })
        .then(() => {
          expect(progress).to.equal('highfunction12345');
          expect(queueExtensions).to.have.length(0);
        });
    });

    it('support struct AMP.push raw functions and high priority', () => {
      // New format: {n:string, f:function()}.
      let progress = '';
      const queueExtensions = win.AMP;

      // Queue mode.
      win.AMP.push((amp) => {
        expect(amp).to.equal(win.AMP);
        progress += '1';
      });
      win.AMP.push({
        n: 'ext2',
        ev: '0.1',
        l: true,
        p: 'high',
        f: (amp) => {
          expect(amp).to.equal(win.AMP);
          progress += 'HIGH';
        },
        v: '$internalRuntimeVersion$',
      });
      expect(queueExtensions).to.have.length(2);
      expect(progress).to.equal('');
      const promise = adopt(win);
      clock.tick(1); // await early frame
      return promise
        .then(() => {
          // Notice the queue is down to 0 but there is a micro task to execute
          // raw and high prio functions. Also notice that no `runChunksForTesting`
          // was called to process the queue.
          expect(queueExtensions).to.have.length(0);
          expect(progress).to.equal('');
          // Even though raw functions and high priority don't go through chunking
          // there is a micro task for its queue.
          return Promise.resolve();
        })
        .then(() => {
          expect(queueExtensions).to.have.length(0);
          expect(progress).to.equal('1HIGH');
          win.AMP.push({
            n: 'ext1',
            ev: '0.1',
            l: true,
            f: (amp) => {
              expect(amp).to.equal(win.AMP);
              progress += 'A';
            },
            v: '$internalRuntimeVersion$',
          });
          runChunks(win.document);
          return promise.then(() => {
            expect(progress).to.equal('1HIGHA');
          });
        });
    });

    it('should wait for body before processing extensions', function* () {
      let bodyResolver;
      const bodyPromise = new Promise((resolve) => {
        bodyResolver = resolve;
      });
      env.sandbox
        .stub(dom, 'waitForBodyOpenPromise')
        .callsFake(() => bodyPromise);

      function skipMicro() {
        return Promise.resolve().then(() => Promise.resolve());
      }
      function waitNext(promise) {
        return Promise.race([promise, skipMicro()]);
      }

      let progress = '';
      const queueExtensions = win.AMP;
      win.AMP.push(
        regularExtension((amp) => {
          expect(amp).to.equal(win.AMP);
          progress += '1';
        })
      );
      win.AMP.push(
        regularExtension((amp) => {
          expect(amp).to.equal(win.AMP);
          progress += '2';
        })
      );
      win.AMP.push(
        regularExtension((amp) => {
          expect(amp).to.equal(win.AMP);
          progress += '3';
        })
      );
      const promise = adopt(win);
      runChunks(win.document);

      yield waitNext(promise);
      // Extensions are still unprocessed
      expect(progress).to.equal('');

      // Add one more
      win.AMP.push(
        regularExtension((amp) => {
          expect(amp).to.equal(win.AMP);
          progress += '4';
        })
      );
      runChunks(win.document);

      yield waitNext(promise);
      expect(progress).to.equal('');

      // Body is available now.
      bodyResolver();
      runChunks(win.document);

      yield waitNext(promise);
      expect(progress).to.equal('1234');
      expect(queueExtensions).to.have.length(0);
    });

    it('should load correct extension version', async function () {
      self.__AMP_MODE = {
        rtvVersion: 'test-version',
      };
      function addExisting(index) {
        const s = document.createElement('script');
        const name = 'amp-test-element' + index;
        s.setAttribute('custom-element', name);
        s.setAttribute('src', `https://cdn.ampproject.org/v0/${name}-0.1.js`);
        win.document.head.appendChild(s);
        return s;
      }
      const s1 = addExisting(1);
      const s2 = addExisting(4);
      const s3 = addExisting(5);

      let bodyResolver;
      const bodyPromise = new Promise((resolve) => {
        bodyResolver = resolve;
      });
      env.sandbox
        .stub(dom, 'waitForBodyOpenPromise')
        .callsFake(() => bodyPromise);

      function skipMicro() {
        return Promise.resolve().then(() => Promise.resolve());
      }
      function waitNext(promise) {
        return Promise.race([promise, skipMicro()]);
      }

      let progress = '';
      const queueExtensions = win.AMP;
      win.AMP.push(
        regularExtension((amp) => {
          expect(amp).to.equal(win.AMP);
          progress += '1';
        })
      );
      win.AMP.push(
        regularExtension((amp) => {
          expect(amp).to.equal(win.AMP);
          progress += 'not expected 1';
        }, 'version123')
      );
      win.AMP.push(
        regularExtension((amp) => {
          expect(amp).to.equal(win.AMP);
          progress += '3';
        })
      );
      const promise = adopt(win);
      runChunks(win.document);

      await waitNext(promise);
      // Extensions are still unprocessed
      expect(progress).to.equal('');

      // Add one more
      win.AMP.push(
        regularExtension((amp) => {
          expect(amp).to.equal(win.AMP);
          progress += '4';
        })
      );
      win.AMP.push(
        regularExtension((amp) => {
          expect(amp).to.equal(win.AMP);
          progress += 'not expected 2';
        }, 'version123')
      );
      // Add legacy element (5) and eagarly ask for its load as ElementStub does.
      Services.extensionsFor(win).preloadExtension('amp-test-element5', '0.1');
      win.AMP.push(
        regularExtension((amp) => {
          expect(amp).to.equal(win.AMP);
          progress += '5';
        }, 'version123')
      );
      runChunks(win.document);

      await waitNext(promise);
      expect(progress).to.equal('');

      // Body is available now.
      bodyResolver();
      runChunks(win.document);

      await waitNext(promise);
      expect(progress).to.equal('134');
      expect(queueExtensions).to.have.length(0);
      expect(s1.getAttribute('i-amphtml-loaded-new-version')).to.equal(
        'amp-test-element1'
      );
      expect(s2.getAttribute('i-amphtml-loaded-new-version')).to.equal(
        'amp-test-element4'
      );
      expect(s3.getAttribute('i-amphtml-loaded-new-version')).to.equal(
        'amp-test-element5'
      );

      const inserted = toArray(
        win.document.head.querySelectorAll('[i-amphtml-inserted]')
      ).map((s) => s.src);
      expect(inserted).to.include(
        'https://cdn.ampproject.org/rtv/test-version/v0/amp-test-element1-0.1.js'
      );
      expect(inserted).to.include(
        'https://cdn.ampproject.org/rtv/test-version/v0/amp-test-element4-0.1.js'
      );
      expect(inserted).to.include(
        'https://cdn.ampproject.org/rtv/test-version/v0/amp-test-element5-0.1.js'
      );
    });

    it('should be robust against errors in early extensions', function* () {
      let progress = '';
      win.AMP.push(
        regularExtension(() => {
          progress += '1';
        })
      );
      win.AMP.push(
        regularExtension(() => {
          throw new Error('extension error');
        })
      );
      win.AMP.push(
        regularExtension(() => {
          progress += '3';
        })
      );
      const promise = adopt(win);
      runChunks(win.document);
      yield promise;
      expect(progress).to.equal('13');
    });

    describe('single-mode', () => {
      let extensions;

      beforeEach(() => {
        const promise = adopt(win);
        ext.installExtensionsService(win);
        extensions = Services.extensionsFor(win);
        return promise;
      });

      it('should export properties to global AMP object', () => {
        expect(win.AMP.BaseElement).to.be.a('function');
        expect(win.AMP.registerElement).to.be.a('function');
        expect(win.AMP.registerTemplate).to.be.a('function');
        expect(win.AMP.setTickFunction).to.be.a('function');
        expect(win.AMP.win).to.equal(win);

        expect(win.AMP.viewer).to.be.a('object');
        expect(win.AMP.viewport).to.be.a('object');
        // Single-doc mode does not create `attachShadowDoc`.
        expect(win.AMP.attachShadowDoc).to.not.exist;
        expect(win.AMP.attachShadowDocAsStream).to.not.exist;
      });

      it('should register template', async () => {
        const ampdoc = ampdocService.getSingleDoc();
        installTemplatesServiceForDoc(ampdoc);
        const templates = Services.templatesForDoc(ampdoc);

        class TemplateType {}

        ampdoc.declareExtension('amp-ext', '0.1');
        win.AMP.push({
          n: 'amp-ext',
          ev: '0.1',
          l: true,
          f: (amp) => {
            amp.registerTemplate('amp-ext', TemplateType);
          },
          v: '$internalRuntimeVersion$',
        });
        runChunks(win.document);

        await extensions.waitForExtension('amp-ext', '0.1');

        // Extension is added immediately. Can't find for micro-tasks here.
        const extHolder = extensions.extensions_['amp-ext:0.1'];
        const ext = extHolder.extension;
        expect(ext.elements['amp-ext']).not.exist;

        const templateElement = win.document.createElement('template');
        templateElement.setAttribute('type', 'amp-ext');
        await templates.whenReady(templateElement);

        const shadowRoot = document.createDocumentFragment();
        const ampdoc2 = new AmpDocShadow(win, 'https://acme.org/', shadowRoot);
        installTemplatesServiceForDoc(ampdoc2);
        const templates2 = Services.templatesForDoc(ampdoc2);
        expect(extHolder.docFactories).to.have.length(1);
        extHolder.docFactories[0](ampdoc2);
        const templateElement2 = win.document.createElement('template');
        templateElement2.setAttribute('type', 'amp-ext');
        await templates2.whenReady(templateElement2);
      });

      it('should register element without CSS', function* () {
        const ampdoc = ampdocService.getSingleDoc();
        const servicePromise = getServicePromise(win, 'amp-ext');
        const installStylesStub = env.sandbox.stub(
          styles,
          'installStylesForDoc'
        );

        ampdoc.declareExtension('amp-ext', '0.1');
        win.AMP.push({
          n: 'amp-ext',
          ev: '0.1',
          l: true,
          f: (amp) => {
            amp.registerElement('amp-ext', win.AMP.BaseElement);
          },
          v: '$internalRuntimeVersion$',
        });
        runChunks(win.document);

        yield extensions.waitForExtension('amp-ext', '0.1');

        // Extension is added immediately. Can't find for micro-tasks here.
        const ext = extensions.extensions_['amp-ext:0.1'].extension;
        expect(ext.elements['amp-ext']).exist;
        expect(ext.elements['amp-ext'].implementationClass).to.equal(
          win.AMP.BaseElement
        );

        // No installStyles calls.
        expect(installStylesStub).to.have.not.been.called;

        // Register is called immediately as well.
        expect(win.__AMP_EXTENDED_ELEMENTS['amp-ext']).to.equal(
          AMP.BaseElement
        );

        // Service and extensions are resolved.
        yield Promise.all([
          extensions.waitForExtension('amp-ext', '0.1'),
          servicePromise,
        ]);
      });

      it('should register element with CSS', function* () {
        const ampdoc = Services.ampdocServiceFor(win).getSingleDoc();
        const servicePromise = getServicePromise(win, 'amp-ext');
        let installStylesCallback;
        const installStylesStub = env.sandbox
          .stub(styles, 'installStylesForDoc')
          .callsFake((doc, cssText, cb) => {
            installStylesCallback = cb;
          });

        ampdoc.declareExtension('amp-ext', '0.1');
        win.AMP.push({
          n: 'amp-ext',
          ev: '0.1',
          l: true,
          f: (amp) => {
            amp.registerElement('amp-ext', win.AMP.BaseElement, 'a{}');
          },
          v: '$internalRuntimeVersion$',
        });
        runChunks(win.document);

        // Extension is added immediately. Can't find for micro-tasks here.
        yield extensions.waitForExtension('amp-ext', '0.1');
        const ext = extensions.extensions_['amp-ext:0.1'].extension;
        expect(ext.elements['amp-ext']).exist;
        expect(ext.elements['amp-ext'].implementationClass).to.equal(
          win.AMP.BaseElement
        );
        expect(ext.elements['amp-ext'].css).to.equal('a{}');

        expect(installStylesStub).to.be.calledOnce;
        expect(installStylesStub).to.be.calledWithExactly(
          ampdoc,
          'a{}',
          installStylesCallback,
          /* isRuntimeCss */ false,
          /* ext */ 'amp-ext'
        );

        // Element resistration is not done until callback.
        expect(win.__AMP_EXTENDED_ELEMENTS['amp-ext']).to.be.undefined;
        installStylesCallback();
        expect(win.__AMP_EXTENDED_ELEMENTS['amp-ext']).to.equal(
          AMP.BaseElement
        );

        // Service and extensions are resolved.
        yield Promise.all([
          extensions.waitForExtension('amp-ext', '0.1'),
          servicePromise,
        ]);
      });

      it('should register doc-service as ctor and install imm', function* () {
        class Service1 {}
        const ampdoc = new AmpDocSingle(win);
        ampdoc.declareExtension('amp-ext', '0.1');
        ampdocServiceMock.expects('getAmpDoc').returns(ampdoc).atLeast(1);
        win.AMP.push({
          n: 'amp-ext',
          ev: '0.1',
          l: true,
          f: (amp) => {
            amp.registerServiceForDoc('service1', Service1);
          },
          v: '$internalRuntimeVersion$',
        });
        runChunks(win.document);

        // No factories
        yield extensions.waitForExtension('amp-ext', '0.1');
        const extHolder = extensions.extensions_['amp-ext:0.1'];
        expect(extHolder.docFactories).to.have.length(1);

        // Already installed.
        expect(getServiceForDoc(ampdoc, 'service1')).to.be.instanceOf(Service1);

        // The main top-level service is also pinged to unblock render.
        yield getServicePromise(win, 'service1');
      });

      it('should register doc-service factory and install', function* () {
        let count = 0;
        function factory() {
          count++;
          return {str: 'A'};
        }
        const ampdoc = new AmpDocSingle(win);
        ampdoc.declareExtension('amp-ext', '0.1');
        ampdocServiceMock.expects('getAmpDoc').returns(ampdoc).atLeast(1);
        win.AMP.push({
          n: 'amp-ext',
          ev: '0.1',
          l: true,
          f: (amp) => {
            amp.registerServiceForDoc('service1', factory);
          },
          v: '$internalRuntimeVersion$',
        });
        runChunks(win.document);

        // No factories
        yield extensions.waitForExtension('amp-ext', '0.1');
        const extHolder = extensions.extensions_['amp-ext:0.1'];
        expect(extHolder.docFactories).to.have.length(1);

        // Already installed.
        expect(count).to.equal(1);
        expect(getServiceForDoc(ampdoc, 'service1')).to.deep.equal({str: 'A'});
      });
    });

    describe('shadow-mode', () => {
      let extensions;

      beforeEach(() => {
        const promise = adoptShadowMode(win);
        ext.installExtensionsService(win);
        extensions = Services.extensionsFor(win);
        return promise;
      });

      it('should export properties to global AMP object', () => {
        expect(win.AMP.BaseElement).to.be.a('function');
        expect(win.AMP.registerElement).to.be.a('function');
        expect(win.AMP.registerTemplate).to.be.a('function');
        expect(win.AMP.setTickFunction).to.be.a('function');
        expect(win.AMP.win).to.equal(win);

        expect(win.AMP.attachShadowDoc).to.be.a('function');
        expect(win.AMP.attachShadowDocAsStream).to.be.a('function');

        expect(win.AMP.viewer).to.not.exist;
        expect(win.AMP.viewport).to.not.exist;
      });

      it('should register element without CSS', function* () {
        const servicePromise = getServicePromise(win, 'amp-ext');
        const installStylesStub = env.sandbox.stub(
          styles,
          'installStylesForDoc'
        );

        win.AMP.push({
          n: 'amp-ext',
          ev: '0.1',
          l: true,
          f: (amp) => {
            amp.registerElement('amp-ext', win.AMP.BaseElement);
          },
          v: '$internalRuntimeVersion$',
        });
        runChunks(win.document);

        // Extension is added immediately. Can't find for micro-tasks here.
        yield extensions.waitForExtension('amp-ext', '0.1');
        const extHolder = extensions.extensions_['amp-ext:0.1'];
        const ext = extHolder.extension;
        expect(ext.elements['amp-ext']).exist;
        expect(ext.elements['amp-ext'].implementationClass).to.equal(
          win.AMP.BaseElement
        );

        // No installStyles calls and no factories.
        expect(installStylesStub).to.not.be.called;
        expect(extHolder.docFactories).to.have.length(1);
        expect(win.__AMP_EXTENDED_ELEMENTS['amp-ext']).to.be.undefined;

        // Execute factory to install style.
        const shadowRoot = document.createDocumentFragment();
        const ampdoc = new AmpDocShadow(win, 'https://acme.org/', shadowRoot);
        extHolder.docFactories[0](ampdoc);
        expect(installStylesStub).to.not.be.called;
        expect(win.__AMP_EXTENDED_ELEMENTS['amp-ext']).to.equal(
          AMP.BaseElement
        );

        // Service and extensions are resolved.
        yield Promise.all([
          extensions.waitForExtension('amp-ext', '0.1'),
          servicePromise,
        ]);
      });

      it('should register element with CSS', function* () {
        const servicePromise = getServicePromise(win, 'amp-ext');
        let installStylesCallback;
        const installStylesStub = env.sandbox
          .stub(styles, 'installStylesForDoc')
          .callsFake((doc, cssText, cb) => {
            installStylesCallback = cb;
          });

        win.AMP.push({
          n: 'amp-ext',
          ev: '0.1',
          l: true,
          f: (amp) => {
            amp.registerElement('amp-ext', win.AMP.BaseElement, 'a{}');
          },
          v: '$internalRuntimeVersion$',
        });
        runChunks(win.document);

        // Extension is added immediately. Can't find for micro-tasks here.
        yield extensions.waitForExtension('amp-ext', '0.1');
        const extHolder = extensions.extensions_['amp-ext:0.1'];
        const ext = extHolder.extension;
        expect(ext.elements['amp-ext']).exist;
        expect(ext.elements['amp-ext'].implementationClass).to.equal(
          win.AMP.BaseElement
        );
        expect(ext.elements['amp-ext'].css).to.equal('a{}');
        // No installations yet, but there's a factory.
        expect(extHolder.docFactories).to.have.length(1);
        expect(win.__AMP_EXTENDED_ELEMENTS['amp-ext']).to.be.undefined;
        expect(installStylesStub).to.have.not.been.called;

        // Execute factory to install style.
        const shadowRoot = document.createDocumentFragment();
        const ampdoc = new AmpDocShadow(win, 'https://acme.org/', shadowRoot);
        extHolder.docFactories[0](ampdoc);
        expect(installStylesStub).to.be.calledOnce;
        expect(installStylesStub).to.be.calledWithExactly(
          ampdoc,
          'a{}',
          installStylesCallback,
          /* isRuntimeCss */ false,
          /* ext */ 'amp-ext'
        );

        // Run install.
        installStylesCallback();
        expect(win.__AMP_EXTENDED_ELEMENTS['amp-ext']).to.equal(
          AMP.BaseElement
        );

        // Service and extensions are resolved.
        yield Promise.all([
          extensions.waitForExtension('amp-ext', '0.1'),
          servicePromise,
        ]);
      });

      it('should register doc-service as ctor and defer install', function* () {
        class Service1 {}
        win.AMP.push({
          n: 'amp-ext',
          ev: '0.1',
          l: true,
          f: (amp) => {
            amp.registerServiceForDoc('service1', Service1);
          },
          v: '$internalRuntimeVersion$',
        });
        runChunks(win.document);

        // Factory recorded.
        yield extensions.waitForExtension('amp-ext', '0.1');
        const extHolder = extensions.extensions_['amp-ext:0.1'];
        expect(extHolder.docFactories).to.have.length(1);

        const shadowRoot = document.createDocumentFragment();
        const ampdoc = new AmpDocShadow(win, 'https://a.org/', shadowRoot);

        // Not installed.
        expect(getServicePromiseOrNullForDoc(ampdoc, 'service1')).to.be.null;

        // Install.
        extHolder.docFactories[0](ampdoc);
        expect(getServiceForDoc(ampdoc, 'service1')).to.be.instanceOf(Service1);
      });
    });
  }
);

describes.realWin(
  'runtime multidoc',
  {
    amp: {ampdoc: 'multi'},
  },
  (env) => {
    let win;
    let extensions;
    let extensionsMock;
    let ampdocService;

    beforeEach(() => {
      win = env.win;
      extensions = env.extensions;
      extensionsMock = env.sandbox.mock(extensions);
      ampdocService = env.ampdocService;
    });

    afterEach(() => {
      extensionsMock.verify();
    });

    describe('attachShadowDoc', () => {
      const docUrl = 'https://example.org/doc1';

      let clock;
      let importDoc;
      let hostElement;
      let ampdoc;

      beforeEach(() => {
        deactivateChunking();
        clock = env.sandbox.useFakeTimers();
        hostElement = win.document.createElement('div');
        importDoc = win.document.implementation.createHTMLDocument('');
        importDoc.body.appendChild(win.document.createElement('child'));
        createShadowRoot(hostElement);
        ampdoc = null;

        env.sandbox
          .stub(ampdocService, 'installShadowDoc')
          .callsFake((url, shadowRoot, options) => {
            expect(url).to.equal(docUrl);
            expect(shadowRoot).to.equal(getShadowRoot(hostElement));
            return (ampdoc = new AmpDocShadow(win, url, shadowRoot, options));
          });
        env.sandbox.stub(ampdocService, 'getAmpDoc').callsFake((node) => {
          expect(node).to.equal(getShadowRoot(hostElement));
          return ampdoc;
        });
      });

      it('should install services and styles', () => {
        const ret = win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
        expect(ret).to.exist;

        const shadowRoot = getShadowRoot(hostElement);

        // URL is set.
        expect(shadowRoot.AMP.url).to.equal(docUrl);

        // Stylesheet has been installed.
        expect(shadowRoot.querySelector('style[amp-runtime]')).to.exist;

        // Doc services have been installed.
        expect(ampdoc.__AMP_SERVICES.action).to.exist;
        expect(ampdoc.__AMP_SERVICES.action.obj).to.exist;
        expect(ampdoc.__AMP_SERVICES.viewer).to.exist;
        expect(ampdoc.__AMP_SERVICES.viewer.obj).to.exist;

        // Single-doc bidings have been installed.
        expect(ret.ampdoc).to.equal(ampdoc);
        expect(ret.viewer).to.not.exist;
      });

      it('should install doc services', async () => {
        class Service1 {}
        win.AMP.push({
          n: 'amp-ext',
          ev: '0.1',
          l: true,
          f: (amp) => {
            amp.registerServiceForDoc('service1', Service1);
          },
          v: '$internalRuntimeVersion$',
        });

        const script = win.document.createElement('script');
        script.setAttribute('custom-element', 'amp-ext');
        script.setAttribute(
          'src',
          'https://cdn.ampproject.org/v0/amp-ext-0.1.js'
        );
        importDoc.head.appendChild(script);

        win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);

        // Extensions-known has been set.
        await ampdoc.whenExtensionsKnown();

        // Factories have been applied.
        await extensions.waitForExtension('amp-ext', '0.1');
        expect(getServiceForDoc(ampdoc, 'service1')).to.be.instanceOf(Service1);
      });

      it('should pass init parameters to viewer', () => {
        win.AMP.attachShadowDoc(hostElement, importDoc, docUrl, {
          'test1': '12',
        });
        expect(ampdoc.getParam('test1')).to.equal('12');
        const viewer = getServiceForDoc(ampdoc, 'viewer');
        expect(viewer.getParam('test1')).to.equal('12');
      });

      it('should update host visibility', () => {
        win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);

        // Document is invisible at first.
        expect(hostElement.style.visibility).to.equal('hidden');

        // After timeout the doc rendered is started.
        clock.tick(3000);
        expect(hostElement.style.visibility).to.equal('visible');
        expect(ampdoc.signals().get('render-start')).to.be.ok;

        return ampdoc.whenReady().then(() => {
          expect(ampdoc.isReady()).to.be.true;
        });
      });

      it('should import body', () => {
        win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
        const shadowRoot = getShadowRoot(hostElement);
        const body =
          shadowRoot.querySelector('body') ||
          shadowRoot.querySelector('amp-body');
        expect(body).to.exist;
        expect(body).to.have.class('amp-shadow');
        expect(body.style.position).to.equal('relative');
        expect(body.querySelector('child')).to.exist;
        expect(ampdoc.getBody()).to.exist;
      });

      it('should read title element', () => {
        const titleEl = win.document.createElement('title');
        titleEl.textContent = 'test title';
        importDoc.head.appendChild(titleEl);
        const ret = win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
        expect(ret.title).to.equal('test title');
        expect(getShadowRoot(hostElement).AMP.title).to.equal('test title');
      });

      it('should read canonical element', () => {
        const canonicalEl = win.document.createElement('link');
        canonicalEl.setAttribute('rel', 'canonical');
        canonicalEl.setAttribute('href', 'http://example.org/canonical');
        importDoc.head.appendChild(canonicalEl);
        const ret = win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
        expect(ret.canonicalUrl).to.equal('http://example.org/canonical');
      });

      it('should import fonts', () => {
        const fontEl1 = win.document.createElement('link');
        fontEl1.setAttribute('rel', 'stylesheet');
        fontEl1.setAttribute('href', 'http://example.org/font1');
        importDoc.head.appendChild(fontEl1);
        const fontEl2 = win.document.createElement('link');
        fontEl2.setAttribute('rel', 'stylesheet');
        fontEl2.setAttribute('href', 'http://example.org/font2');
        importDoc.head.appendChild(fontEl2);
        win.document.head.appendChild(fontEl2.cloneNode(true));
        win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
        expect(
          win.document.querySelector('link[href="http://example.org/font1"]')
        ).to.exist;
        // Duplicates are ignored.
        expect(
          win.document.querySelectorAll('link[href="http://example.org/font2"]')
        ).to.have.length(1);

        const fontEl = win.document.querySelector(
          'link[href="http://example.org/font1"]'
        );
        expect(fontEl.getAttribute('type')).to.equal('text/css');
        expect(fontEl.getAttribute('rel')).to.equal('stylesheet');
        fontEl.parentElement.removeChild(fontEl);
      });

      it('should ignore boilerplate style', () => {
        const styleEl = win.document.createElement('style');
        styleEl.setAttribute('amp-boilerplate', '');
        importDoc.head.appendChild(styleEl);
        win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
        const shadowRoot = getShadowRoot(hostElement);
        expect(shadowRoot.querySelector('style[amp-boilerplate]')).to.not.exist;
      });

      it('should import custom style', () => {
        const styleEl = win.document.createElement('style');
        styleEl.setAttribute('amp-custom', '');
        styleEl.textContent = '.custom{}';
        importDoc.head.appendChild(styleEl);
        win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
        const shadowRoot = getShadowRoot(hostElement);
        expect(shadowRoot.querySelector('style[amp-custom]')).to.exist;
        expect(
          shadowRoot.querySelector('style[amp-custom]').textContent
        ).to.contain('.custom');
      });

      it('should import keyframes style', () => {
        const styleEl = win.document.createElement('style');
        styleEl.setAttribute('amp-keyframes', '');
        styleEl.textContent = '.keyframes{}';
        importDoc.head.appendChild(styleEl);
        win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
        const shadowRoot = getShadowRoot(hostElement);
        expect(shadowRoot.querySelector('style[amp-custom]')).to.not.exist;
        expect(shadowRoot.querySelector('style[amp-keyframes]')).to.exist;
        expect(
          shadowRoot.querySelector('style[amp-keyframes]').textContent
        ).to.contain('.keyframes');
      });

      it('should ignore runtime', () => {
        extensionsMock.expects('preloadExtension').never();

        const scriptEl = win.document.createElement('script');
        scriptEl.setAttribute('src', 'https://cdn.ampproject.org/v0.js');
        importDoc.head.appendChild(scriptEl);
        win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
      });

      it('should ignore mjs runtime', () => {
        extensionsMock.expects('preloadExtension').never();

        const scriptEl = win.document.createElement('script');
        scriptEl.setAttribute('src', 'https://cdn.ampproject.org/v0.mjs');
        importDoc.head.appendChild(scriptEl);
        win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
      });

      it('should ignore unknown script', () => {
        extensionsMock.expects('preloadExtension').never();

        const scriptEl = win.document.createElement('script');
        scriptEl.setAttribute('data-id', 'unknown1');
        scriptEl.setAttribute('src', 'https://cdn.ampproject.org/other.js');
        importDoc.head.appendChild(scriptEl);
        allowConsoleError(() => {
          win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
        });
        expect(
          getShadowRoot(hostElement).querySelector('script[data-id="unknown1"]')
        ).to.not.exist;
        expect(win.document.querySelector('script[data-id="unknown1"]')).to.not
          .exist;
      });

      it('should import extension element', async () => {
        extensionsMock
          .expects('preloadExtension')
          .withExactArgs('amp-ext1', '0.1')
          .returns(
            Promise.resolve({
              elements: {
                'amp-ext1': function () {},
              },
            })
          )
          .once();

        const scriptEl = win.document.createElement('script');
        scriptEl.setAttribute('custom-element', 'amp-ext1');
        scriptEl.setAttribute(
          'src',
          'https://cdn.ampproject.org/v0/amp-ext-0.1.js'
        );
        importDoc.head.appendChild(scriptEl);
        win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
        expect(win.document.querySelector('script[custom-element="amp-ext1"]'))
          .to.not.exist;

        // Extensions-known has been set.
        await ampdoc.whenExtensionsKnown();
      });

      it('should import module/nomodule extension element', () => {
        extensionsMock
          .expects('preloadExtension')
          .withExactArgs('amp-ext1', '0.1')
          .returns(
            Promise.resolve({
              elements: {
                'amp-ext1': function () {},
              },
            })
          )
          .once();

        const mod = win.document.createElement('script');
        mod.setAttribute('custom-element', 'amp-ext1');
        mod.setAttribute(
          'src',
          'https://cdn.ampproject.org/v0/amp-ext-0.1.mjs'
        );
        mod.setAttribute('type', 'module');
        const nomod = win.document.createElement('script');
        nomod.setAttribute('custom-element', 'amp-ext1');
        nomod.setAttribute(
          'src',
          'https://cdn.ampproject.org/v0/amp-ext-0.1.js'
        );
        nomod.setAttribute('nomodule', '');

        importDoc.head.appendChild(mod);
        importDoc.head.appendChild(nomod);
        win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
        expect(win.document.querySelector('script[custom-element="amp-ext1"]'))
          .to.not.exist;
      });

      it('should import extension element with version ≠ 0.1', () => {
        extensionsMock
          .expects('preloadExtension')
          .withExactArgs('amp-ext1', '1.0')
          .returns(
            Promise.resolve({
              elements: {
                'amp-ext1': function () {},
              },
            })
          )
          .once();

        const scriptEl = win.document.createElement('script');
        scriptEl.setAttribute('custom-element', 'amp-ext1');
        scriptEl.setAttribute(
          'src',
          'https://cdn.ampproject.org/v0/amp-ext1-1.0.js'
        );
        importDoc.head.appendChild(scriptEl);
        win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
        expect(win.document.querySelector('script[custom-element="amp-ext1"]'))
          .to.not.exist;
      });

      it('should import extension template', () => {
        extensionsMock
          .expects('preloadExtension')
          .withExactArgs('amp-ext1', '0.1')
          .returns(Promise.resolve({elements: {}}))
          .once();

        const scriptEl = win.document.createElement('script');
        scriptEl.setAttribute('custom-template', 'amp-ext1');
        scriptEl.setAttribute(
          'src',
          'https://cdn.ampproject.org/v0/amp-ext1-0.1.js'
        );
        importDoc.head.appendChild(scriptEl);
        win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
        expect(win.document.querySelector('script[custom-template="amp-ext1"]'))
          .to.not.exist;
      });

      it('should import inline script', () => {
        const scriptEl = win.document.createElement('script');
        scriptEl.setAttribute('type', 'application/json');
        scriptEl.setAttribute('data-id', 'test1');
        scriptEl.textContent = '{}';
        importDoc.head.appendChild(scriptEl);
        win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
        expect(
          getShadowRoot(hostElement).querySelector('script[data-id="test1"]')
        ).to.exist;
        expect(
          getShadowRoot(hostElement).querySelector('script[data-id="test1"]')
            .textContent
        ).to.equal('{}');
      });

      it('should ignore inline script if javascript', () => {
        const scriptEl1 = win.document.createElement('script');
        scriptEl1.setAttribute('type', 'application/javascript');
        scriptEl1.setAttribute('data-id', 'test1');
        importDoc.head.appendChild(scriptEl1);
        const scriptEl2 = win.document.createElement('script');
        scriptEl2.setAttribute('data-id', 'test1');
        importDoc.head.appendChild(scriptEl2);
        allowConsoleError(() => {
          win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
        });
        expect(
          getShadowRoot(hostElement).querySelector('script[data-id="test1"]')
        ).to.not.exist;
      });

      it('should import meta content', () => {
        const metaEl = win.document.createElement('meta');
        metaEl.setAttribute('name', 'abc');
        metaEl.setAttribute('content', '123');
        importDoc.head.appendChild(metaEl);
        const amp = win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
        expect(amp.ampdoc.getMetaByName('abc')).to.equal('123');
      });

      it('should start as visible by default', () => {
        win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
        expect(ampdoc.getVisibilityState()).to.equal('visible');
      });

      it('should start as prerender when requested', () => {
        win.AMP.attachShadowDoc(hostElement, importDoc, docUrl, {
          'visibilityState': 'prerender',
        });
        expect(ampdoc.getVisibilityState()).to.equal('prerender');
      });

      it('should expose visibility method', () => {
        const amp = win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
        expect(amp.setVisibilityState).to.be.a('function');
        expect(ampdoc.getVisibilityState()).to.equal('visible');

        amp.setVisibilityState('inactive');
        expect(ampdoc.getVisibilityState()).to.equal('inactive');
      });

      it('should expose close method and dispose services', () => {
        const amp = win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
        const viewer = getServiceForDoc(ampdoc, 'viewer');
        expect(amp.close).to.be.a('function');
        expect(ampdoc.getVisibilityState()).to.equal('visible');

        viewer.dispose = env.sandbox.spy();
        amp.close().then(() => {
          expect(ampdoc.getVisibilityState()).to.equal('inactive');
          expect(viewer.dispose).to.be.calledOnce;
        });
      });

      it('should expose head tag ', () => {
        const amp = win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
        expect(amp.head).to.exist;
        expect(amp.head.children).to.exist;
        expect(amp.head.children.length).to.greaterThan(0);
      });
    });

    describe
      .configure()
      .skipFirefox()
      .run('attachShadowDocAsStream', () => {
        const docUrl = 'https://example.org/doc1';

        let hostElement;
        let ampdoc;
        let shadowDoc;
        let writer;

        beforeEach(() => {
          deactivateChunking();
          setShadowDomSupportedVersionForTesting(undefined);
          hostElement = win.document.createElement('div');
          createShadowRoot(hostElement);
          ampdoc = null;

          env.sandbox
            .stub(ampdocService, 'installShadowDoc')
            .callsFake((url, shadowRoot, options) => {
              expect(url).to.equal(docUrl);
              expect(shadowRoot).to.equal(getShadowRoot(hostElement));
              return (ampdoc = new AmpDocShadow(win, url, shadowRoot, options));
            });
          env.sandbox.stub(ampdocService, 'getAmpDoc').callsFake((node) => {
            expect(node).to.equal(getShadowRoot(hostElement));
            return ampdoc;
          });
        });

        it('should install services and styles', () => {
          shadowDoc = win.AMP.attachShadowDocAsStream(hostElement, docUrl);
          writer = shadowDoc.writer;

          const shadowRoot = getShadowRoot(hostElement);

          // URL is set.
          expect(shadowRoot.AMP.url).to.equal(docUrl);

          // Stylesheet has been installed.
          expect(shadowRoot.querySelector('style[amp-runtime]')).to.exist;

          // Doc services have been installed.
          expect(ampdoc.__AMP_SERVICES.action).to.exist;
          expect(ampdoc.__AMP_SERVICES.action.obj).to.exist;
          expect(ampdoc.__AMP_SERVICES.viewer).to.exist;
          expect(ampdoc.__AMP_SERVICES.viewer.obj).to.exist;

          // Single-doc bidings have been installed.
          expect(shadowDoc.ampdoc).to.equal(ampdoc);
          expect(shadowDoc.viewer).to.not.exist;
        });

        it('should install doc services', async () => {
          shadowDoc = win.AMP.attachShadowDocAsStream(hostElement, docUrl);
          writer = shadowDoc.writer;

          class Service1 {}
          win.AMP.push({
            n: 'amp-ext',
            ev: '0.1',
            l: true,
            f: (amp) => {
              amp.registerServiceForDoc('service1', Service1);
            },
            v: '$internalRuntimeVersion$',
          });

          writer.write(
            '<script custom-element="amp-ext" src="https://cdn.ampproject.org/v0/amp-ext-0.1.js"></script>'
          );
          writer.write('<body>');

          await ampdoc.waitForBodyOpen();

          // Extensions-known has been set.
          await ampdoc.whenExtensionsKnown();

          // Factories have been applied.
          await extensions.waitForExtension('amp-ext', '0.1');
          expect(getServiceForDoc(ampdoc, 'service1')).to.be.instanceOf(
            Service1
          );
        });

        it('should pass init parameters to viewer', () => {
          win.AMP.attachShadowDocAsStream(hostElement, docUrl, {
            'test1': '12',
          });
          const viewer = getServiceForDoc(ampdoc, 'viewer');
          expect(viewer.getParam('test1')).to.equal('12');
        });

        it('should update host visibility', () => {
          shadowDoc = win.AMP.attachShadowDocAsStream(hostElement, docUrl);
          writer = shadowDoc.writer;

          writer.write('<body><div>');

          // Document is invisible at first.
          expect(hostElement.style.visibility).to.equal('hidden');

          return ampdoc
            .waitForBodyOpen()
            .then(() => {
              // After timeout the doc rendered is started.
              expect(hostElement.style.visibility).to.equal('hidden');
              return ampdoc.signals().whenSignal('render-start');
            })
            .then(() => {
              expect(hostElement.style.visibility).to.equal('visible');
            });
        });

        it('should import body', () => {
          shadowDoc = win.AMP.attachShadowDocAsStream(hostElement, docUrl);
          writer = shadowDoc.writer;
          writer.write('<body><child>');
          return ampdoc.waitForBodyOpen().then(() => {
            const shadowRoot = getShadowRoot(hostElement);
            const body =
              shadowRoot.querySelector('body') ||
              shadowRoot.querySelector('amp-body');
            expect(body).to.exist;
            expect(body).to.have.class('amp-shadow');
            expect(body.style.position).to.equal('relative');
            env.flushVsync();
            expect(body.querySelector('child')).to.exist;
            expect(ampdoc.getBody()).to.exist;
          });
        });

        it('should mark doc as ready', () => {
          shadowDoc = win.AMP.attachShadowDocAsStream(hostElement, docUrl);
          writer = shadowDoc.writer;
          writer.write('<body><child>');
          return ampdoc.waitForBodyOpen().then(() => {
            expect(ampdoc.isReady()).to.be.false;
            writer.write('</child></body>');
            writer.write('</html>');
            writer.close();
            return ampdoc.whenReady().then(() => {
              expect(ampdoc.isReady()).to.be.true;
            });
          });
        });

        it('should read title element', () => {
          shadowDoc = win.AMP.attachShadowDocAsStream(hostElement, docUrl);
          writer = shadowDoc.writer;
          writer.write('<title>test title</title><body>');
          return ampdoc.waitForBodyOpen().then(() => {
            expect(shadowDoc.title).to.equal('test title');
            expect(getShadowRoot(hostElement).AMP.title).to.equal('test title');
          });
        });

        it('should read canonical element', () => {
          shadowDoc = win.AMP.attachShadowDocAsStream(hostElement, docUrl);
          writer = shadowDoc.writer;
          writer.write(
            '<link rel="canonical" href="http://example.org/canonical"><body>'
          );
          return ampdoc.waitForBodyOpen().then(() => {
            expect(shadowDoc.canonicalUrl).to.equal(
              'http://example.org/canonical'
            );
          });
        });

        it('should import fonts', () => {
          shadowDoc = win.AMP.attachShadowDocAsStream(hostElement, docUrl);
          writer = shadowDoc.writer;

          writer.write(
            '<link rel="stylesheet" href="http://example.org/font1"><body>'
          );
          writer.write(
            '<link rel="stylesheet" href="http://example.org/font2"><body>'
          );
          const fontEl2 = win.document.createElement('link');
          fontEl2.setAttribute('rel', 'stylesheet');
          fontEl2.setAttribute('href', 'http://example.org/font2');
          win.document.head.appendChild(fontEl2);

          return ampdoc.waitForBodyOpen().then(() => {
            expect(
              win.document.querySelector(
                'link[href="http://example.org/font1"]'
              )
            ).to.exist;
            // Duplicates are ignored.
            expect(
              win.document.querySelectorAll(
                'link[href="http://example.org/font2"]'
              )
            ).to.have.length(1);

            const fontEl = win.document.querySelector(
              'link[href="http://example.org/font1"]'
            );
            expect(fontEl.getAttribute('type')).to.equal('text/css');
            expect(fontEl.getAttribute('rel')).to.equal('stylesheet');
            fontEl.parentElement.removeChild(fontEl);
          });
        });

        it('should ignore boilerplate style', () => {
          shadowDoc = win.AMP.attachShadowDocAsStream(hostElement, docUrl);
          writer = shadowDoc.writer;
          writer.write('<style amp-boilerplate></style><body>');
          return ampdoc.waitForBodyOpen().then(() => {
            const shadowRoot = getShadowRoot(hostElement);
            expect(shadowRoot.querySelector('style[amp-boilerplate]')).to.not
              .exist;
          });
        });

        it('should import custom style', () => {
          shadowDoc = win.AMP.attachShadowDocAsStream(hostElement, docUrl);
          writer = shadowDoc.writer;
          writer.write('<style amp-custom>.custom{}</style><body>');
          return ampdoc.waitForBodyOpen().then(() => {
            const shadowRoot = getShadowRoot(hostElement);
            expect(shadowRoot.querySelector('style[amp-custom]')).to.exist;
            expect(
              shadowRoot.querySelector('style[amp-custom]').textContent
            ).to.contain('.custom');
          });
        });

        it('should ignore runtime extension', async () => {
          shadowDoc = win.AMP.attachShadowDocAsStream(hostElement, docUrl);
          writer = shadowDoc.writer;
          extensionsMock.expects('preloadExtension').never();
          writer.write(
            '<script src="https://cdn.ampproject.org/v0.js"></script>'
          );
          writer.write('<body>');
          await ampdoc.waitForBodyOpen();

          // Extensions-known has been set.
          await ampdoc.whenExtensionsKnown();
        });

        it('should ignore unknown script', () => {
          shadowDoc = win.AMP.attachShadowDocAsStream(hostElement, docUrl);
          writer = shadowDoc.writer;
          extensionsMock.expects('preloadExtension').never();
          writer.write(
            '<script data-id="unknown1"' +
              ' src="https://cdn.ampproject.org/other.js"></script>'
          );
          writer.write('<body>');
          return ampdoc.waitForBodyOpen().then(() => {
            expect(
              getShadowRoot(hostElement).querySelector(
                'script[data-id="unknown1"]'
              )
            ).to.not.exist;
            expect(win.document.querySelector('script[data-id="unknown1"]')).to
              .not.exist;
          });
        });

        it('should import extension element', async () => {
          shadowDoc = win.AMP.attachShadowDocAsStream(hostElement, docUrl);
          writer = shadowDoc.writer;
          extensionsMock
            .expects('preloadExtension')
            .withExactArgs('amp-ext1', '0.1')
            .returns(
              Promise.resolve({
                elements: {
                  'amp-ext1': function () {},
                },
              })
            )
            .once();
          writer.write(
            '<script custom-element="amp-ext1" src="https://cdn.ampproject.org/v0/amp-ext1-0.1.js"></script>'
          );
          writer.write('<body>');
          await ampdoc.waitForBodyOpen();

          // Extensions-known has been set.
          await ampdoc.whenExtensionsKnown();

          expect(
            win.document.querySelector('script[custom-element="amp-ext1"]')
          ).to.not.exist;
        });

        it('should import extension template', () => {
          shadowDoc = win.AMP.attachShadowDocAsStream(hostElement, docUrl);
          writer = shadowDoc.writer;
          extensionsMock
            .expects('preloadExtension')
            .withExactArgs('amp-ext1', '0.1')
            .returns(Promise.resolve({elements: {}}))
            .once();
          writer.write(
            '<script custom-template="amp-ext1" src="https://cdn.ampproject.org/v0/amp-ext1-0.1.js"></script>'
          );
          writer.write('<body>');
          return ampdoc.waitForBodyOpen().then(() => {
            expect(
              win.document.querySelector('script[custom-template="amp-ext1"]')
            ).to.not.exist;
          });
        });

        it('should import inline script', () => {
          shadowDoc = win.AMP.attachShadowDocAsStream(hostElement, docUrl);
          writer = shadowDoc.writer;
          writer.write(
            '<script type="application/json" data-id="test1">{}</script>'
          );
          writer.write('<body>');
          return ampdoc.waitForBodyOpen().then(() => {
            expect(
              getShadowRoot(hostElement).querySelector(
                'script[data-id="test1"]'
              )
            ).to.exist;
            expect(
              getShadowRoot(hostElement).querySelector(
                'script[data-id="test1"]'
              ).textContent
            ).to.equal('{}');
          });
        });

        it('should ignore inline script if javascript', () => {
          expectAsyncConsoleError(
            '[multidoc-manager] - unallowed inline javascript:  ' +
              '[object HTMLScriptElement]',
            2
          );

          shadowDoc = win.AMP.attachShadowDocAsStream(hostElement, docUrl);
          writer = shadowDoc.writer;
          writer.write(
            '<script type="application/javascript" data-id="test1"></script>'
          );
          writer.write('<script data-id="test1"></script>');
          writer.write('<body>');
          return ampdoc.waitForBodyOpen(() => {
            expect(
              getShadowRoot(hostElement).querySelector(
                'script[data-id="test1"]'
              )
            ).to.not.exist;
          });
        });

        it('should start as visible by default', () => {
          shadowDoc = win.AMP.attachShadowDocAsStream(hostElement, docUrl);
          writer = shadowDoc.writer;
          writer.write('<body>');
          return ampdoc.waitForBodyOpen().then(() => {
            expect(ampdoc.getVisibilityState()).to.equal('visible');
          });
        });

        it('should start as prerender when requested', () => {
          shadowDoc = win.AMP.attachShadowDocAsStream(hostElement, docUrl, {
            'visibilityState': 'prerender',
          });
          writer = shadowDoc.writer;
          writer.write('<body>');
          return ampdoc.waitForBodyOpen().then(() => {
            expect(ampdoc.getVisibilityState()).to.equal('prerender');
          });
        });

        it('should expose visibility method', () => {
          shadowDoc = win.AMP.attachShadowDocAsStream(hostElement, docUrl);
          writer = shadowDoc.writer;
          writer.write('<body>');
          return ampdoc.waitForBodyOpen().then(() => {
            expect(shadowDoc.setVisibilityState).to.be.a('function');
            expect(ampdoc.getVisibilityState()).to.equal('visible');

            shadowDoc.setVisibilityState('inactive');
            expect(ampdoc.getVisibilityState()).to.equal('inactive');
          });
        });

        it('should expose close method and dispose services', () => {
          shadowDoc = win.AMP.attachShadowDocAsStream(hostElement, docUrl);
          writer = shadowDoc.writer;
          writer.write('<body>');
          return ampdoc.waitForBodyOpen().then(() => {
            const viewer = getServiceForDoc(ampdoc, 'viewer');
            expect(shadowDoc.close).to.be.a('function');
            expect(ampdoc.getVisibilityState()).to.equal('visible');

            viewer.dispose = env.sandbox.spy();
            shadowDoc.close().then(() => {
              expect(ampdoc.getVisibilityState()).to.equal('inactive');
              expect(viewer.dispose).to.be.calledOnce;
            });
          });
        });
      });

    describes.repeated(
      'messaging',
      {
        'document.contains is the browser implementation': false,
        'document.contains is a stubbed implementation': true,
      },
      (name, isStubbedDocumentContains) => {
        let doc1, doc2, doc3;
        let ampdocServiceMock;

        beforeEach(() => {
          ampdocServiceMock = env.sandbox.mock(env.ampdocService);

          if (isStubbedDocumentContains) {
            // Some browsers implement document.contains wrong, and it returns
            // `false` even when this is incorrect. Repeat these tests with the
            // faulty implementation.
            env.sandbox.stub(win.document, 'contains').returns(false);
          }

          doc1 = attach('https://example.org/doc1');
          doc2 = attach('https://example.org/doc2');
          doc3 = attach('https://example.org/doc3');
        });

        afterEach(() => {
          ampdocServiceMock.verify();
        });

        function attach(docUrl) {
          const hostElement = win.document.createElement('div');
          win.document.body.appendChild(hostElement);
          const importDoc = win.document.implementation.createHTMLDocument('');
          const shadowRoot = createShadowRoot(hostElement);
          const ampdoc = new AmpDocShadow(win, docUrl, shadowRoot);

          ampdocServiceMock
            .expects('installShadowDoc')
            .withArgs(
              docUrl,
              env.sandbox.match((arg) => arg == getShadowRoot(hostElement))
            )
            .returns(ampdoc)
            .atLeast(0);
          ampdocServiceMock
            .expects('getAmpDoc')
            .withExactArgs(
              env.sandbox.match((arg) => arg == getShadowRoot(hostElement))
            )
            .returns(ampdoc)
            .atLeast(0);

          const amp = win.AMP.attachShadowDoc(hostElement, importDoc, docUrl);
          const viewer = getServiceForDoc(ampdoc, 'viewer');
          const broadcastReceived = env.sandbox.spy();
          viewer.onBroadcast(broadcastReceived);
          const onMessage = env.sandbox.stub();
          amp.onMessage(function (eventType, data) {
            if (eventType == 'ignore') {
              return Promise.resolve();
            }
            return onMessage(eventType, data);
          });
          return {
            hostElement,
            amp,
            ampdoc,
            viewer,
            broadcastReceived,
            onMessage,
          };
        }

        it('should broadcast to all but sender', () => {
          doc1.viewer.broadcast({test: 1});
          return doc1.viewer.sendMessageAwaitResponse('ignore', {}).then(() => {
            // Sender is not called.
            expect(doc1.broadcastReceived).to.not.be.called;

            // All others are called.
            expect(doc2.broadcastReceived).to.be.calledOnce;
            expect(doc2.broadcastReceived.args[0][0]).deep.equal({test: 1});
            expect(doc3.broadcastReceived).to.be.calledOnce;
            expect(doc3.broadcastReceived.args[0][0]).deep.equal({test: 1});

            // None of the onMessage are called.
            expect(doc1.onMessage).to.not.be.called;
            expect(doc2.onMessage).to.not.be.called;
            expect(doc3.onMessage).to.not.be.called;
          });
        });

        it('should stop broadcasting after close', () => {
          doc3.amp.close();
          doc1.viewer.broadcast({test: 1});
          return doc1.viewer.sendMessageAwaitResponse('ignore', {}).then(() => {
            // Sender is not called, closed is not called.
            expect(doc1.broadcastReceived).to.not.be.called;
            expect(doc3.broadcastReceived).to.not.be.called;

            // All others are called.
            expect(doc2.broadcastReceived).to.be.calledOnce;
            expect(doc2.broadcastReceived.args[0][0]).deep.equal({test: 1});
          });
        });

        it('should stop broadcasting after force-close', () => {
          doc3.hostElement.parentNode.removeChild(doc3.hostElement);
          doc1.viewer.broadcast({test: 1});
          return doc1.viewer.sendMessageAwaitResponse('ignore', {}).then(() => {
            // Sender is not called, closed is not called.
            expect(doc1.broadcastReceived).to.not.be.called;
            expect(doc3.broadcastReceived).to.not.be.called;

            // All others are called.
            expect(doc2.broadcastReceived).to.be.calledOnce;
            expect(doc2.broadcastReceived.args[0][0]).deep.equal({test: 1});
          });
        });

        it('should send message', () => {
          doc1.onMessage.returns(Promise.resolve());
          return doc1.viewer
            .sendMessageAwaitResponse('test3', {test: 3})
            .then(() => {
              expect(doc1.onMessage).to.be.calledOnce;
              expect(doc1.onMessage.args[0][0]).to.equal('test3');
              expect(doc1.onMessage.args[0][1]).to.deep.equal({test: 3});
            });
        });

        it('should receive message', () => {
          doc1.amp.postMessage('broadcast', {test: 4}, true);
          expect(doc1.broadcastReceived).to.be.calledOnce;
          expect(doc1.broadcastReceived.args[0][0]).to.deep.equal({test: 4});
        });
      }
    );
  }
);

function getShadowRoot(hostElement) {
  return hostElement.shadowRoot || hostElement.__AMP_SHADOW_ROOT;
}
