aurelia/aurelia

View on GitHub
packages/__tests__/src/3-runtime-html/template-compiler.local-templates.spec.ts

Summary

Maintainability
F
2 wks
Test Coverage
import {
  DefaultLogger, IContainer, ILogEvent, ISink, kebabCase, LoggerConfiguration, LogLevel
} from '@aurelia/kernel';
import {
  BindingMode,
  Aurelia, bindable,
  BindableDefinition,
  CustomAttributeDefinition,
  customElement,
  CustomElement,
  CustomElementDefinition,
  PartialCustomElementDefinition,
} from '@aurelia/runtime-html';
import { HydrateElementInstruction } from '@aurelia/template-compiler';
import {
  assert,
  generateCartesianProduct,
  TestContext,
} from '@aurelia/testing';

export function createAttribute(name: string, value: string): Attr {
  const attr = document.createAttribute(name);
  attr.value = value;
  return attr;
}

const elementInfoLookup = new WeakMap<CustomElementDefinition, Record<string, ElementInfo>>();

/**
 * Pre-processed information about a custom element resource, optimized
 * for consumption by the template compiler.
 */
class ElementInfo {
  /**
   * A lookup of the bindables of this element, indexed by the (pre-processed)
   * attribute names as they would be found in parsed markup.
   */
  public bindables: Record<string, BindableInfo | undefined> = Object.create(null);

  public constructor(
    public name: string,
    public alias: string | undefined,
    public containerless: boolean,
  ) { }

  public static from(def: CustomElementDefinition | null, alias: string): ElementInfo | null {
    if (def === null) {
      return null;
    }
    let rec = elementInfoLookup.get(def);
    if (rec === void 0) {
      elementInfoLookup.set(def, rec = Object.create(null) as Record<string, ElementInfo>);
    }
    let info = rec[alias];
    if (info === void 0) {
      info = rec[alias] = new ElementInfo(def.name, alias === def.name ? void 0 : alias, def.containerless);
      const bindables = def.bindables;
      const defaultBindingMode = BindingMode.toView;

      let bindable: BindableDefinition;
      let prop: string;
      let attr: string;
      let mode: string | number;

      for (prop in bindables) {
        bindable = bindables[prop];
        // explicitly provided property name has priority over the implicit property name
        if (bindable.name !== void 0) {
          prop = bindable.name;
        }
        // explicitly provided attribute name has priority over the derived implicit attribute name
        if (bindable.attribute !== void 0) {
          attr = bindable.attribute;
        } else {
          // derive the attribute name from the resolved property name
          attr = kebabCase(prop);
        }
        if (bindable.mode !== void 0 && bindable.mode !== BindingMode.default) {
          mode = bindable.mode;
        } else {
          mode = defaultBindingMode;
        }
        info.bindables[attr] = new BindableInfo(prop, mode);
      }
    }
    return info;
  }
}

const attrInfoLookup = new WeakMap<CustomAttributeDefinition, Record<string, AttrInfo>>();

/**
 * Pre-processed information about a custom attribute resource, optimized
 * for consumption by the template compiler.
 */
class AttrInfo {
  /**
   * A lookup of the bindables of this attribute, indexed by the (pre-processed)
   * bindable names as they would be found in the attribute value.
   *
   * Only applicable to multi attribute bindings (semicolon-separated).
   */
  public bindables: Record<string, BindableInfo | undefined> = Object.create(null);
  /**
   * The single or first bindable of this attribute, or a default 'value'
   * bindable if no bindables were defined on the attribute.
   *
   * Only applicable to single attribute bindings (where the attribute value
   * contains no semicolons)
   */
  public bindable: BindableInfo | null = null;

  public constructor(
    public name: string,
    public alias: string | undefined,
    public isTemplateController: boolean,
    public noMultiBindings: boolean,
  ) { }

  public static from(def: CustomAttributeDefinition | null, alias: string): AttrInfo | null {
    if (def === null) {
      return null;
    }
    let rec = attrInfoLookup.get(def);
    if (rec === void 0) {
      attrInfoLookup.set(def, rec = Object.create(null) as Record<string, AttrInfo>);
    }
    let info = rec[alias];
    if (info === void 0) {
      info = rec[alias] = new AttrInfo(def.name, alias === def.name ? void 0 : alias, def.isTemplateController, def.noMultiBindings);
      const bindables = def.bindables;
      const defaultBindingMode = def.defaultBindingMode !== void 0 && def.defaultBindingMode !== BindingMode.default
        ? def.defaultBindingMode
        : BindingMode.toView;

      let bindable: BindableDefinition;
      let prop: string;
      let mode: string | number;
      let hasPrimary: boolean = false;
      let isPrimary: boolean = false;
      let bindableInfo: BindableInfo;

      for (prop in bindables) {
        bindable = bindables[prop];
        // explicitly provided property name has priority over the implicit property name
        if (bindable.name !== void 0) {
          prop = bindable.name;
        }
        if (bindable.mode !== void 0 && bindable.mode !== BindingMode.default) {
          mode = bindable.mode;
        } else {
          mode = defaultBindingMode;
        }
        isPrimary = bindable.primary === true;
        bindableInfo = info.bindables[prop] = new BindableInfo(prop, mode);
        if (isPrimary) {
          if (hasPrimary) {
            throw new Error('primary already exists');
          }
          hasPrimary = true;
          info.bindable = bindableInfo;
        }
        // set to first bindable by convention
        if (info.bindable === null) {
          info.bindable = bindableInfo;
        }
      }
      // if no bindables are present, default to "value"
      if (info.bindable === null) {
        info.bindable = new BindableInfo('value', defaultBindingMode);
      }
    }
    return info;
  }
}

/**
 * A pre-processed piece of information about a defined bindable property on a custom
 * element or attribute, optimized for consumption by the template compiler.
 */
class BindableInfo {
  public constructor(
    /**
     * The pre-processed *property* (not attribute) name of the bindable, which is
     * (in order of priority):
     *
     * 1. The `property` from the description (if defined)
     * 2. The name of the property of the bindable itself
     */
    public propName: string,
    /**
     * The pre-processed (default) bindingMode of the bindable, which is (in order of priority):
     *
     * 1. The `mode` from the bindable (if defined and not bindingMode.default)
     * 2. The `defaultBindingMode` (if it's an attribute, defined, and not bindingMode.default)
     * 3. `bindingMode.toView`
     */
    public mode: string | number,
  ) { }
}

class EventLog implements ISink {
  public readonly log: ILogEvent[] = [];
  public handleEvent(event: ILogEvent): void {
    this.log.push(event);
  }
}
function $$createFixture() {
  const ctx = TestContext.create();
  const container = ctx.container;
  container.register(LoggerConfiguration.create({ sinks: [EventLog] }));
  const sut = {
    get resolveResources() {
      return ctx.templateCompiler.resolveResources;
    },
    set resolveResources(v) {
      ctx.templateCompiler.resolveResources = v;
    },
    compile(def: PartialCustomElementDefinition, container: IContainer) {
      return CustomElementDefinition.getOrCreate(ctx.templateCompiler.compile(CustomElementDefinition.create(def), container));
    }
  };
  return { ctx, container, sut };
}

class LocalTemplateTestData {

  public constructor(
    public readonly template: string,
    private readonly expectedResources: Map<string, ElementInfo>,
    private readonly templateFreq: Map<string, number>,
    public readonly expectedContent: string,
  ) {
    this.verifyDefinition = this.verifyDefinition.bind(this);
  }
  public verifyDefinition(definition: CustomElementDefinition, container: IContainer): void {
    assert.equal((definition.template as HTMLTemplateElement).querySelector('template[as-custom-element]'), null);

    for (const [name, info] of this.expectedResources) {
      assert.deepStrictEqual(ElementInfo.from(CustomElement.find(container, name), void 0), info, 'element info');
    }
    const ceInstructions: HydrateElementInstruction[] = definition.instructions.flatMap((i) => i).filter((i) => i instanceof HydrateElementInstruction) as HydrateElementInstruction[];
    for (const [template, freq] of this.templateFreq) {
      assert.strictEqual(ceInstructions.filter((i) => i.res === template).length, freq, 'HydrateElementInstruction.freq');
    }
  }
}

describe('3-runtime-html/template-compiler.local-templates.spec.ts', function () {

  describe('[UNIT]', function () {
    function* getLocalTemplateTestData() {
      yield new LocalTemplateTestData(
        `<template as-custom-element="foo-bar">static</template>
        <foo-bar></foo-bar>`,
        new Map([['foo-bar', new ElementInfo('foo-bar', void 0, false)]]),
        new Map([['foo-bar', 1]]),
        'static'
      );
      yield new LocalTemplateTestData(
        `<foo-bar></foo-bar>
        <template as-custom-element="foo-bar">static</template>`,
        new Map([['foo-bar', new ElementInfo('foo-bar', void 0, false)]]),
        new Map([['foo-bar', 1]]),
        'static'
      );
      yield new LocalTemplateTestData(
        `<foo-bar></foo-bar>
        <template as-custom-element="foo-bar">static</template>
        <foo-bar></foo-bar>`,
        new Map([['foo-bar', new ElementInfo('foo-bar', void 0, false)]]),
        new Map([['foo-bar', 2]]),
        'static static'
      );
      yield new LocalTemplateTestData(
        `<template as-custom-element="foo-bar">static foo-bar</template>
        <template as-custom-element="fiz-baz">static fiz-baz</template>
        <fiz-baz></fiz-baz>
        <foo-bar></foo-bar>`,
        new Map([['foo-bar', new ElementInfo('foo-bar', void 0, false)], ['fiz-baz', new ElementInfo('fiz-baz', void 0, false)]]),
        new Map([['foo-bar', 1], ['fiz-baz', 1]]),
        'static fiz-baz static foo-bar'
      );
      const bindingModeMap = new Map([
        ['oneTime', BindingMode.oneTime],
        ['toView', BindingMode.toView],
        ['fromView', BindingMode.fromView],
        ['twoWay', BindingMode.twoWay],
        ['default', BindingMode.toView],
      ]);
      for (const [bindingMode, props, attributeName] of generateCartesianProduct([
        [...bindingModeMap.keys(), void 0],
        [['prop'], ['prop', 'camelProp']],
        ['fiz-baz', undefined],
      ])) {
        const ei = new ElementInfo('foo-bar', void 0, false);
        const mode = bindingModeMap.get(bindingMode) ?? BindingMode.toView;
        let bindables = '';
        let templateBody = '';
        let attrExpr = '';
        let renderedContent = '';
        const value = "awesome possum";
        for (let i = 0, ii = props.length; i < ii; i++) {
          const prop = props[i];
          const bi = new BindableInfo(prop, mode);
          const attr = kebabCase(attributeName !== void 0 ? `${attributeName}${i + 1}` : prop);
          ei.bindables[attr] = bi;

          bindables += `<bindable name='${prop}'${bindingMode !== void 0 ? ` mode="${bindingMode}"` : ''}${attributeName !== void 0 ? ` attribute="${attr}"` : ''}></bindable>`;
          templateBody += ` \${${prop}}`;
          const content = `${value}${i + 1}`;
          attrExpr += ` ${attr}="${content}"`;
          renderedContent += ` ${content}`;
        }

        yield new LocalTemplateTestData(
          `<template as-custom-element="foo-bar">
              ${bindables}
              ${templateBody}
            </template>
            <foo-bar ${attrExpr}></foo-bar>`,
          new Map([['foo-bar', ei]]),
          new Map([['foo-bar', 1]]),
          renderedContent.trim()
        );
      }
    }
    for (const { template, verifyDefinition, expectedContent } of getLocalTemplateTestData()) {
      it(template, function () {
        const { container, sut } = $$createFixture();
        sut.resolveResources = false;
        const definition = sut.compile({ name: 'lorem-ipsum', template }, container);
        verifyDefinition(definition, container);
      });
      if (template.includes(`mode="fromView"`)) { continue; }
      it(`${template} - content`, async function () {
        const { ctx, container } = $$createFixture();
        const host = ctx.doc.createElement('div');
        ctx.doc.body.appendChild(host);
        const au = new Aurelia(container)
          .app({ host, component: CustomElement.define({ name: 'lorem-ipsum', template }, class { }) });

        await au.start();

        assert.html.textContent(host, expectedContent);

        await au.stop();
        ctx.doc.body.removeChild(host);
        au.dispose();
      });
    }

    it('throws error if a root template is a local template', function () {
      const template = `<template as-custom-element="foo-bar">I have local root!</template>`;
      const { container, sut } = $$createFixture();
      assert.throws(() => sut.compile({ name: 'lorem-ipsum', template }, container), 'The root cannot be a local template itself.');
    });

    it('throws error if the custom element has only local templates', function () {
      const template = `
      <template as-custom-element="foo-bar">Does this work?</template>
      <template as-custom-element="fiz-baz">Of course not!</template>
      `;
      const { container, sut } = $$createFixture();
      assert.throws(() => sut.compile({ name: 'lorem-ipsum', template }, container), 'The custom element does not have any content other than local template(s).');
    });

    it('throws error if a local template is not under root', function () {
      const template = `<div><template as-custom-element="foo-bar">Can I hide here?</template></div>`;
      const { container, sut } = $$createFixture();
      assert.throws(() => sut.compile({ name: 'lorem-ipsum', template }, container), 'Local templates needs to be defined directly under root.');
    });

    it('throws error if a local template does not have name', function () {
      const template = `<template as-custom-element="">foo-bar</template><div></div>`;
      const { container, sut } = $$createFixture();
      assert.throws(() => sut.compile({ name: 'lorem-ipsum', template }, container), 'The value of "as-custom-element" attribute cannot be empty for local template');
    });

    it('throws error if a duplicate local templates are found', function () {
      const template = `<template as-custom-element="foo-bar">foo-bar1</template><template as-custom-element="foo-bar">foo-bar2</template><div></div>`;
      const { container, sut } = $$createFixture();
      assert.throws(() => sut.compile({ name: 'lorem-ipsum', template }, container), 'Duplicate definition of the local template named foo-bar');
    });

    it('throws error if bindable is not under root', function () {
      const template = `<template as-custom-element="foo-bar">
        <div>
          <bindable name="prop"></bindable>
        </div>
      </template>
      <div></div>`;
      const { container, sut } = $$createFixture();
      assert.throws(() => sut.compile({ name: 'lorem-ipsum', template }, container), 'Bindable properties of local templates needs to be defined directly under root.');
    });

    it('throws error if bindable property is missing', function () {
      const template = `<template as-custom-element="foo-bar">
        <bindable attribute="prop"></bindable>
      </template>
      <div></div>`;
      const { container, sut } = $$createFixture();
      assert.throws(() => sut.compile({ name: 'lorem-ipsum', template }, container), 'The attribute \'property\' is missing in <bindable attribute="prop"></bindable>');
    });

    it('throws error if duplicate bindable properties are found', function () {
      const template = `<template as-custom-element="foo-bar">
        <bindable name="prop" attribute="bar"></bindable>
        <bindable name="prop" attribute="baz"></bindable>
      </template>
      <div></div>`;
      const { container, sut } = $$createFixture();
      assert.throws(
        () => sut.compile({ name: 'lorem-ipsum', template }, container),
        'Bindable property and attribute needs to be unique; found property: prop, attribute: '
      );
    });

    it('throws error if duplicate bindable attributes are found', function () {
      const template = `<template as-custom-element="foo-bar">
        <bindable name="prop1" attribute="bar"></bindable>
        <bindable name="prop2" attribute="bar"></bindable>
      </template>
      <div></div>`;
      const { container, sut } = $$createFixture();
      assert.throws(
        () => sut.compile({ name: 'lorem-ipsum', template }, container),
        'Bindable property and attribute needs to be unique; found property: prop2, attribute: bar'
      );
    });

    for (const attr of ['if.bind="true"', 'if.bind="false"', 'else', 'repeat.for="item of items"', 'with.bind="{a:1}"', 'switch.bind="cond"', 'case="case1"']) {
      it(`throws error if local-template surrogate has template controller - ${attr}`, function () {
        const template = `<template as-custom-element="foo-bar" ${attr}>
        <bindable name="prop1" attribute="bar"></bindable>
      </template>
      <foo-bar></foo-bar>`;
        const { ctx, container } = $$createFixture();

        assert.throws(() =>
          new Aurelia(container)
            .app({ host: ctx.doc.createElement('div'), component: CustomElement.define({ name: 'lorem-ipsum', template }, class { }) }),
          `Template controller ${attr.split('.')[0]} is invalid on surrogate`
        );
      });
    }

    it('warns if bindable element has more attributes other than the allowed', function () {
      const template = `<template as-custom-element="foo-bar">
        <bindable name="prop" unknown-attr who-cares="no one"></bindable>
      </template>
      <div></div>`;
      const { container, sut } = $$createFixture();

      sut.compile({ name: 'lorem-ipsum', template }, container);
      if (__DEV__) {
        const sinks = container.get(DefaultLogger).sinks;
        const eventLog = sinks.find((s) => s instanceof EventLog) as EventLog;
        assert.strictEqual(eventLog.log.length, 1, `eventLog.log.length`);
        const event = eventLog.log[0];
        assert.strictEqual(event.severity, LogLevel.warn);
        assert.includes(
          event.toString(),
          'The attribute(s) unknown-attr, who-cares will be ignored for <bindable name="prop" unknown-attr="" who-cares="no one"></bindable>. Only property, attribute, mode are processed.'
        );
      }
    });
  });

  it('works with if', async function () {
    const template = `<template as-custom-element="foo-bar">
      <bindable name='prop'></bindable>
      \${prop}
     </template>
     <foo-bar prop="awesome possum" if.bind="true"></foo-bar>
     <foo-bar prop="ignored" if.bind="false"></foo-bar>`;
    const expectedContent = "awesome possum";

    const { ctx, container } = $$createFixture();
    const host = ctx.doc.createElement('div');
    ctx.doc.body.appendChild(host);
    const au = new Aurelia(container)
      .app({ host, component: CustomElement.define({ name: 'lorem-ipsum', template }, class { }) });

    await au.start();

    assert.html.textContent(host, expectedContent);

    await au.stop();
    ctx.doc.body.removeChild(host);
    au.dispose();
  });

  it('works with for', async function () {
    const template = `<template as-custom-element="foo-bar">
      <bindable name='prop'></bindable>
      \${prop}
     </template>
     <foo-bar repeat.for="i of 5" prop.bind="i"></foo-bar>`;
    const expectedContent = "0 1 2 3 4";

    const { ctx, container } = $$createFixture();
    const host = ctx.doc.createElement('div');
    ctx.doc.body.appendChild(host);
    const au = new Aurelia(container)
      .app({ host, component: CustomElement.define({ name: 'lorem-ipsum', template }, class { }) });

    await au.start();

    assert.html.textContent(host, expectedContent);

    await au.stop();
    ctx.doc.body.removeChild(host);
    au.dispose();
  });

  it('works with nested templates - 1', async function () {
    @customElement({ name: 'level-one', template: `<template as-custom-element="foo-bar"><bindable name='prop'></bindable>Level One \${prop}</template><foo-bar prop.bind="prop"></foo-bar>` })
    class LevelOne {
      @bindable public prop: string;
    }
    @customElement({
      name: 'level-two', template: `
      <template as-custom-element="foo-bar">
        <bindable name='prop'></bindable>
        Level Two \${prop}
        <level-one prop="inter-dimensional portal"></level-one>
      </template>
      <foo-bar prop.bind="prop"></foo-bar>
      <level-one prop.bind="prop"></level-one>
      `})
    class LevelTwo {
      @bindable public prop: string;
    }
    const template = `<level-two prop="foo2"></level-two><level-one prop="foo1"></level-one>`;
    const expectedContent = "Level Two foo2 Level One inter-dimensional portal Level One foo2 Level One foo1";

    const { ctx, container } = $$createFixture();
    const host = ctx.doc.createElement('div');
    ctx.doc.body.appendChild(host);
    const au = new Aurelia(container)
      .register(LevelOne, LevelTwo)
      .app({ host, component: CustomElement.define({ name: 'lorem-ipsum', template }, class { }) });

    await au.start();

    assert.html.textContent(host, expectedContent);

    await au.stop();
    ctx.doc.body.removeChild(host);
    au.dispose();
  });

  it('works with nested templates - 2', async function () {
    const template = `
      <template as-custom-element="el-one">
        <template as-custom-element="one-two">
          1
        </template>
        2
        <one-two></one-two>
      </template>
      <template as-custom-element="el-two">
        <template as-custom-element="two-two">
          3
        </template>
        4
        <two-two></two-two>
      </template>
      <el-two></el-two>
      <el-one></el-one>
      `;
    const expectedContent = "4 3 2 1";

    const { ctx, container } = $$createFixture();
    const host = ctx.doc.createElement('div');
    ctx.doc.body.appendChild(host);
    const au = new Aurelia(container)
      .app({ host, component: CustomElement.define({ name: 'lorem-ipsum', template }, class { }) });

    await au.start();

    assert.html.textContent(host, expectedContent);

    await au.stop();
    ctx.doc.body.removeChild(host);
    au.dispose();
  });

  it('works with non-global dependencies in owning template', async function () {
    @customElement({ name: 'my-ce', template: 'my-ce-content' })
    class MyCe { }

    const template = `
      <my-ce></my-ce>
      <my-le></my-le>
      <template as-custom-element="my-le">
        my-le-content
        <my-ce></my-ce>
      </template>
      `;
    @customElement({ name: 'my-app', template, dependencies: [MyCe] })
    class App { }

    const { ctx, container } = $$createFixture();
    const host = ctx.doc.createElement('div');
    ctx.doc.body.appendChild(host);
    const au = new Aurelia(container)
      .app({ host, component: App });

    await au.start();

    assert.html.textContent(host, 'my-ce-content my-le-content my-ce-content');

    await au.stop();
    ctx.doc.body.removeChild(host);
    au.dispose();
  });

  it('works with non-global dependencies - template-controllers - if', async function () {
    @customElement({ name: 'my-ce', template: 'my-ce-content' })
    class MyCe { }

    const template = `
      <my-ce></my-ce>
      <my-le if.bind="true"></my-le>
      <template as-custom-element="my-le">
        my-le-content
        <my-ce></my-ce>
      </template>
      `;
    @customElement({ name: 'my-app', template, dependencies: [MyCe] })
    class App { }

    const { ctx, container } = $$createFixture();
    const host = ctx.doc.createElement('div');
    ctx.doc.body.appendChild(host);
    const au = new Aurelia(container)
      .app({ host, component: App });

    await au.start();

    assert.html.textContent(host, 'my-ce-content my-le-content my-ce-content');

    await au.stop();
    ctx.doc.body.removeChild(host);
    au.dispose();
  });

  it('works with non-global dependencies - nested-template-controllers - [repeat.for]>[if]', async function () {
    @customElement({ name: 'my-ce', template: 'my-ce-content' })
    class MyCe { }

    const template = `
      <my-ce></my-ce>
      <my-le repeat.for="prop of 5" if.bind="prop % 2 === 0" prop.bind></my-le>
      <template as-custom-element="my-le">
        <bindable name="prop"></bindable>
        \${prop}
        <my-ce></my-ce>
      </template>
      `;
    @customElement({ name: 'my-app', template, dependencies: [MyCe] })
    class App { }

    const { ctx, container } = $$createFixture();
    const host = ctx.doc.createElement('div');
    ctx.doc.body.appendChild(host);
    const au = new Aurelia(container)
      .app({ host, component: App });

    await au.start();

    assert.html.textContent(host, 'my-ce-content 0 my-ce-content 2 my-ce-content 4 my-ce-content');

    await au.stop();
    ctx.doc.body.removeChild(host);
    au.dispose();
  });

  it('recognizes owning element', async function () {
    let id = 0;
    const template = `
        my-app-content
        <my-le prop.bind></my-le>
        <template as-custom-element="my-le">
          <bindable name="prop"></bindable>
          my-le-content
          <my-app if.bind="prop"></my-app>
        </template>`;
    @customElement({ name: 'my-app', template })
    class App {
      prop = false;
      id = ++id;
    }

    const { ctx, container } = $$createFixture();
    const host = ctx.doc.createElement('div');
    ctx.doc.body.appendChild(host);
    const au = new Aurelia(container)
      .app({ host, component: App });

    await au.start();

    const vm = au.root.controller.viewModel as App;

    assert.html.textContent(host, 'my-app-content my-le-content');
    assert.strictEqual(id, 1);

    vm.prop = true;
    ctx.platform.domWriteQueue.flush();

    assert.html.textContent(host, 'my-app-content my-le-content my-app-content my-le-content');
    assert.strictEqual(id, 2);

    await au.stop();
    ctx.doc.body.removeChild(host);
    au.dispose();
  });

  it('all local elements recognize each other', async function () {
    const template = `
        my-app-content
        <my-le-1></my-le-1>
        <my-le-2></my-le-2>

        <template as-custom-element="my-le-1">
          my-le-1-content
          <my-le-2></my-le-2>
        </template>

        <template as-custom-element="my-le-2">
          my-le-2-content
          <my-le-3></my-le-3>
        </template>

        <template as-custom-element="my-le-3">
          my-le-3-content
        </template>`;

    @customElement({ name: 'my-app', template })
    class App { }

    const { ctx, container } = $$createFixture();
    const host = ctx.doc.createElement('div');
    ctx.doc.body.appendChild(host);
    const au = new Aurelia(container)
      .app({ host, component: App });

    await au.start();

    assert.html.textContent(
      host,
      'my-app-content ' +
      'my-le-1-content my-le-2-content my-le-3-content ' +
      'my-le-2-content my-le-3-content');

    await au.stop();
    ctx.doc.body.removeChild(host);
    au.dispose();
  });
});