angular/packages/platform-browser/test/dom/dom_renderer_spec.ts
Matthieu Riegler 6270bba056 ci: reformat files
This is after we've slightly changed a rule in #66056
2025-12-16 14:44:19 -08:00

557 lines
18 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Component, Renderer2, ViewEncapsulation} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {By} from '../../src/dom/debug/by';
import {
addBaseHrefToCssSourceMap,
NAMESPACE_URIS,
REMOVE_STYLES_ON_COMPONENT_DESTROY,
} from '../../src/dom/dom_renderer';
import {expect} from '@angular/private/testing/matchers';
import {isNode} from '@angular/private/testing';
describe('DefaultDomRendererV2', () => {
if (isNode) {
// Jasmine will throw if there are no tests.
it('should pass', () => {});
return;
}
let renderer: Renderer2;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [
TestCmp,
SomeApp,
IsolatedShadowComponentParentApp,
SomeAppForCleanUp,
CmpEncapsulationEmulated,
CmpEncapsulationNone,
CmpEncapsulationShadow,
CmpEncapsulationIsolatedShadowWithChildren,
],
});
renderer = TestBed.createComponent(TestCmp).componentInstance.renderer;
});
describe('setAttribute', () => {
describe('with namespace', () => {
it('xmlns', () => shouldSetAttributeWithNs('xmlns'));
it('xml', () => shouldSetAttributeWithNs('xml'));
it('svg', () => shouldSetAttributeWithNs('svg'));
it('xhtml', () => shouldSetAttributeWithNs('xhtml'));
it('xlink', () => shouldSetAttributeWithNs('xlink'));
it('unknown', () => {
const div = document.createElement('div');
expect(div.hasAttribute('unknown:name')).toBe(false);
renderer.setAttribute(div, 'name', 'value', 'unknown');
expect(div.getAttribute('unknown:name')).toBe('value');
});
function shouldSetAttributeWithNs(namespace: string): void {
const namespaceUri = NAMESPACE_URIS[namespace];
const div = document.createElement('div');
expect(div.hasAttributeNS(namespaceUri, 'name')).toBe(false);
renderer.setAttribute(div, 'name', 'value', namespace);
expect(div.getAttributeNS(namespaceUri, 'name')).toBe('value');
}
});
});
describe('removeAttribute', () => {
describe('with namespace', () => {
it('xmlns', () => shouldRemoveAttributeWithNs('xmlns'));
it('xml', () => shouldRemoveAttributeWithNs('xml'));
it('svg', () => shouldRemoveAttributeWithNs('svg'));
it('xhtml', () => shouldRemoveAttributeWithNs('xhtml'));
it('xlink', () => shouldRemoveAttributeWithNs('xlink'));
it('unknown', () => {
const div = document.createElement('div');
div.setAttribute('unknown:name', 'value');
expect(div.hasAttribute('unknown:name')).toBe(true);
renderer.removeAttribute(div, 'name', 'unknown');
expect(div.hasAttribute('unknown:name')).toBe(false);
});
function shouldRemoveAttributeWithNs(namespace: string): void {
const namespaceUri = NAMESPACE_URIS[namespace];
const div = document.createElement('div');
div.setAttributeNS(namespaceUri, `${namespace}:name`, 'value');
expect(div.hasAttributeNS(namespaceUri, 'name')).toBe(true);
renderer.removeAttribute(div, 'name', namespace);
expect(div.hasAttributeNS(namespaceUri, 'name')).toBe(false);
}
});
});
describe('removeChild', () => {
it('should not error when removing a child without passing a parent', () => {
const parent = document.createElement('div');
const child = document.createElement('div');
parent.appendChild(child);
renderer.removeChild(null, child);
});
});
it('should style non-descendant components correctly with different types of encapsulation', () => {
const fixture = TestBed.createComponent(SomeApp);
fixture.detectChanges();
const cmp = fixture.debugElement.query(By.css('cmp-shadow')).nativeElement;
const shadowRoot = cmp.shadowRoot;
const shadow = shadowRoot.querySelector('.shadow');
expect(window.getComputedStyle(shadow).color).toEqual('rgb(255, 0, 0)');
const emulated = fixture.debugElement.query(By.css('.emulated')).nativeElement;
expect(window.getComputedStyle(emulated).color).toEqual('rgb(0, 0, 255)');
const none = fixture.debugElement.query(By.css('.none')).nativeElement;
expect(window.getComputedStyle(none).color).toEqual('rgb(0, 255, 0)');
});
it('should encapsulate shadow DOM components, with child components inheriting from shadow styles not global styles', () => {
const fixture = TestBed.createComponent(IsolatedShadowComponentParentApp);
fixture.detectChanges();
const shadowcmp = fixture.debugElement.query(By.css('cmp-shadow-children')).nativeElement;
const shadowRoot = shadowcmp.shadowRoot;
const shadow = shadowRoot.querySelector('.shadow');
expect(window.getComputedStyle(shadow).color).toEqual('rgb(255, 0, 0)');
const emulated = fixture.debugElement.query(By.css('.emulated')).nativeElement;
expect(window.getComputedStyle(emulated).color).toEqual('rgb(255, 0, 0)');
const none = fixture.debugElement.query(By.css('.none')).nativeElement;
expect(window.getComputedStyle(none).color).toEqual('rgb(255, 0, 0)');
});
it('child components of shadow components should inherit browser defaults rather than their component styles', () => {
const fixture = TestBed.createComponent(IsolatedShadowComponentParentApp);
fixture.detectChanges();
const shadowcmp = fixture.debugElement.query(By.css('cmp-shadow-children')).nativeElement;
const shadowRoot = shadowcmp.shadowRoot;
const shadow = shadowRoot.querySelector('.shadow');
expect(window.getComputedStyle(shadow).backgroundColor).toEqual('rgba(0, 0, 0, 0)');
const emulated = fixture.debugElement.query(By.css('.emulated')).nativeElement;
expect(window.getComputedStyle(emulated).backgroundColor).toEqual('rgba(0, 0, 0, 0)');
const none = fixture.debugElement.query(By.css('.none')).nativeElement;
expect(window.getComputedStyle(none).backgroundColor).toEqual('rgba(0, 0, 0, 0)');
});
it('shadow components should not be polluted by child components styles when using ExperimentalIsolatedShadowDom', () => {
const fixture = TestBed.createComponent(IsolatedShadowComponentParentApp);
fixture.detectChanges();
const cmp = fixture.debugElement.query(By.css('cmp-shadow-children')).nativeElement;
const shadowRoot = cmp.shadowRoot;
const shadow = shadowRoot.querySelector('.shadow');
expect(window.getComputedStyle(shadow).backgroundColor).not.toEqual('rgb(0, 0, 255)');
expect(window.getComputedStyle(shadow).backgroundColor).not.toEqual('rgb(0, 255, 0)');
});
it('should be able to append children to a <template> element', () => {
const template = document.createElement('template');
const child = document.createElement('div');
renderer.appendChild(template, child);
expect(child.parentNode).toBe(template.content);
});
it('should be able to insert children before others in a <template> element', () => {
const template = document.createElement('template');
const child = document.createElement('div');
const otherChild = document.createElement('div');
template.content.appendChild(child);
renderer.insertBefore(template, otherChild, child);
expect(otherChild.parentNode).toBe(template.content);
});
describe('should not cleanup styles of destroyed components when `REMOVE_STYLES_ON_COMPONENT_DESTROY` is `false`', () => {
beforeEach(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
declarations: [SomeAppForCleanUp, CmpEncapsulationEmulated, CmpEncapsulationNone],
providers: [
{
provide: REMOVE_STYLES_ON_COMPONENT_DESTROY,
useValue: false,
},
],
});
});
it('works for components without encapsulation emulated', async () => {
const fixture = TestBed.createComponent(SomeAppForCleanUp);
const compInstance = fixture.componentInstance;
compInstance.showEmulatedComponents = true;
fixture.detectChanges();
// verify style is in DOM
expect(await styleCount(fixture, '.emulated')).toBe(1);
// Remove a single instance of the component.
compInstance.componentOneInstanceHidden = true;
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();
// Verify style is still in DOM
expect(await styleCount(fixture, '.emulated')).toBe(1);
// Hide all instances of the component
compInstance.componentTwoInstanceHidden = true;
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();
// Verify style is still in DOM
expect(await styleCount(fixture, '.emulated')).toBe(1);
});
it('works for components without encapsulation none', async () => {
const fixture = TestBed.createComponent(SomeAppForCleanUp);
const compInstance = fixture.componentInstance;
compInstance.showEmulatedComponents = false;
fixture.detectChanges();
// verify style is in DOM
expect(await styleCount(fixture, '.none')).toBe(1);
// Remove a single instance of the component.
compInstance.componentOneInstanceHidden = true;
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();
// Verify style is still in DOM
expect(await styleCount(fixture, '.none')).toBe(1);
// Hide all instances of the component
compInstance.componentTwoInstanceHidden = true;
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();
// Verify style is still in DOM
expect(await styleCount(fixture, '.none')).toBe(1);
});
});
describe('should cleanup styles of destroyed components by default', () => {
it('works for components without encapsulation emulated', async () => {
const fixture = TestBed.createComponent(SomeAppForCleanUp);
const compInstance = fixture.componentInstance;
compInstance.showEmulatedComponents = true;
fixture.detectChanges();
// verify style is in DOM
expect(await styleCount(fixture, '.emulated')).toBe(1);
// Remove a single instance of the component.
compInstance.componentOneInstanceHidden = true;
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();
// Verify style is still in DOM
expect(await styleCount(fixture, '.emulated')).toBe(1);
// Hide all instances of the component
compInstance.componentTwoInstanceHidden = true;
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();
// Verify style is not in DOM
expect(await styleCount(fixture, '.emulated')).toBe(0);
});
it('works for components without encapsulation none', async () => {
const fixture = TestBed.createComponent(SomeAppForCleanUp);
const compInstance = fixture.componentInstance;
compInstance.showEmulatedComponents = false;
fixture.detectChanges();
// verify style is in DOM
expect(await styleCount(fixture, '.none')).toBe(1);
// Remove a single instance of the component.
compInstance.componentOneInstanceHidden = true;
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();
// Verify style is still in DOM
expect(await styleCount(fixture, '.none')).toBe(1);
// Hide all instances of the component
compInstance.componentTwoInstanceHidden = true;
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();
// Verify style is not in DOM
expect(await styleCount(fixture, '.emulated')).toBe(0);
});
});
describe('should support namespaces', () => {
it('should create SVG elements', () => {
expect(
document.createElementNS(NAMESPACE_URIS['svg'], 'math') instanceof SVGElement,
).toBeTrue();
});
it('should create MathML elements', () => {
// MathMLElement is fairly recent and doesn't exist on our Saucelabs test environments
if (typeof MathMLElement !== 'undefined') {
expect(
document.createElementNS(NAMESPACE_URIS['math'], 'math') instanceof MathMLElement,
).toBeTrue();
}
});
});
it('should update an external sourceMappingURL by prepending the baseHref as a prefix', () => {
document.head.innerHTML = `<base href="/base/" />`;
TestBed.resetTestingModule();
TestBed.configureTestingModule({
declarations: [CmpEncapsulationNoneWithSourceMap],
});
const fixture = TestBed.createComponent(CmpEncapsulationNoneWithSourceMap);
fixture.detectChanges();
expect(document.head.querySelector('style')?.textContent).toContain(
'/*# sourceMappingURL=/base/cmp-none.css.map */',
);
document.head.innerHTML = '';
});
});
describe('addBaseHrefToCssSourceMap', () => {
it('should return the original styles if baseHref is empty', () => {
const styles = ['body { color: red; }'];
const result = addBaseHrefToCssSourceMap('', styles);
expect(result).toEqual(styles);
});
it('should skip styles that do not contain a sourceMappingURL', () => {
const styles = ['body { color: red; }', 'h1 { font-size: 2rem; }'];
const result = addBaseHrefToCssSourceMap('/base/', styles);
expect(result).toEqual(styles);
});
it('should not modify inline (encoded) sourceMappingURL maps', () => {
const styles = ['/*# sourceMappingURL=data:application/json;base64,xyz */'];
const result = addBaseHrefToCssSourceMap('/base/', styles);
expect(result).toEqual(styles);
});
it('should prepend baseHref to external sourceMappingURL', () => {
const styles = ['/*# sourceMappingURL=style.css */'];
const result = addBaseHrefToCssSourceMap('/base/', styles);
expect(result).toEqual(['/*# sourceMappingURL=/base/style.css */']);
});
it('should handle baseHref with a trailing slash correctly', () => {
const styles = ['/*# sourceMappingURL=style.css */'];
const result = addBaseHrefToCssSourceMap('/base/', styles);
expect(result).toEqual(['/*# sourceMappingURL=/base/style.css */']);
});
it('should handle baseHref without a trailing slash correctly', () => {
const styles = ['/*# sourceMappingURL=style.css */'];
const result = addBaseHrefToCssSourceMap('/base', styles);
expect(result).toEqual(['/*# sourceMappingURL=/style.css */']);
});
it('should not duplicate slashes in the final URL', () => {
const styles = ['/*# sourceMappingURL=./style.css */'];
const result = addBaseHrefToCssSourceMap('/base/', styles);
expect(result).toEqual(['/*# sourceMappingURL=/base/style.css */']);
});
it('should not add base href to sourceMappingURL that is absolute', () => {
const styles = ['/*# sourceMappingURL=http://example.com/style.css */'];
const result = addBaseHrefToCssSourceMap('/base/', styles);
expect(result).toEqual(['/*# sourceMappingURL=http://example.com/style.css */']);
});
it('should process multiple styles and handle each case correctly', () => {
const styles = [
'/*# sourceMappingURL=style1.css */',
'/*# sourceMappingURL=data:application/json;base64,xyz */',
'h1 { font-size: 2rem; }',
'/*# sourceMappingURL=style2.css */',
];
const result = addBaseHrefToCssSourceMap('/base/', styles);
expect(result).toEqual([
'/*# sourceMappingURL=/base/style1.css */',
'/*# sourceMappingURL=data:application/json;base64,xyz */',
'h1 { font-size: 2rem; }',
'/*# sourceMappingURL=/base/style2.css */',
]);
});
});
async function styleCount(
fixture: ComponentFixture<unknown>,
cssContentMatcher: string,
): Promise<number> {
// flush
await new Promise<void>((resolve) => {
setTimeout(() => resolve(), 0);
});
const html = fixture.debugElement.parent?.parent;
const debugElements = html?.queryAll(By.css('style'));
if (!debugElements) {
return 0;
}
return debugElements.filter(({nativeElement}) =>
nativeElement.textContent.includes(cssContentMatcher),
).length;
}
@Component({
selector: 'cmp-emulated',
template: ` <div class="emulated"></div>`,
styles: [
`
.emulated {
background-color: blue;
color: blue;
}
`,
],
encapsulation: ViewEncapsulation.Emulated,
standalone: false,
})
class CmpEncapsulationEmulated {}
@Component({
selector: 'cmp-none',
template: ` <div class="none"></div>`,
styles: [
`
.none {
background-color: lime;
color: lime;
}
`,
],
encapsulation: ViewEncapsulation.None,
standalone: false,
})
class CmpEncapsulationNone {}
@Component({
selector: 'cmp-none',
template: ` <div class="none"></div>`,
styles: [
`
.none {
background-color: lime;
color: lime;
}
/*# sourceMappingURL=cmp-none.css.map */
`,
],
encapsulation: ViewEncapsulation.None,
standalone: false,
})
class CmpEncapsulationNoneWithSourceMap {}
@Component({
selector: 'cmp-shadow',
template: ` <div class="shadow"></div>`,
styles: [
`
.shadow {
color: red;
}
`,
],
encapsulation: ViewEncapsulation.ShadowDom,
standalone: false,
})
class CmpEncapsulationShadow {}
@Component({
selector: 'cmp-shadow-children',
template: ` <div class="shadow">
<cmp-emulated></cmp-emulated>
<cmp-none></cmp-none>
</div>`,
styles: [
`
.shadow {
color: red;
}
`,
],
encapsulation: ViewEncapsulation.ExperimentalIsolatedShadowDom,
standalone: false,
})
class CmpEncapsulationIsolatedShadowWithChildren {}
@Component({
selector: 'some-app',
template: `
<cmp-shadow></cmp-shadow>
<cmp-emulated></cmp-emulated>
<cmp-none></cmp-none>
`,
standalone: false,
})
export class SomeApp {}
@Component({
selector: 'shadow-parent-app-with-children',
template: ` <cmp-shadow-children></cmp-shadow-children> `,
standalone: false,
})
export class IsolatedShadowComponentParentApp {}
@Component({
selector: 'test-cmp',
template: '',
standalone: false,
})
class TestCmp {
constructor(public renderer: Renderer2) {}
}
@Component({
selector: 'some-app',
template: `
<cmp-emulated *ngIf="!componentOneInstanceHidden && showEmulatedComponents"></cmp-emulated>
<cmp-emulated *ngIf="!componentTwoInstanceHidden && showEmulatedComponents"></cmp-emulated>
<cmp-none *ngIf="!componentOneInstanceHidden && !showEmulatedComponents"></cmp-none>
<cmp-none *ngIf="!componentTwoInstanceHidden && !showEmulatedComponents"></cmp-none>
`,
standalone: false,
})
export class SomeAppForCleanUp {
componentOneInstanceHidden = false;
componentTwoInstanceHidden = false;
showEmulatedComponents = true;
}