Skip to content

Instantly share code, notes, and snippets.

@leo6104
Created April 20, 2023 03:28
Show Gist options
  • Save leo6104/e1ffb3fe521fee33b28400eec61d6eac to your computer and use it in GitHub Desktop.
Save leo6104/e1ffb3fe521fee33b28400eec61d6eac to your computer and use it in GitHub Desktop.
Hydration unit test code in angular 16 (with custom util)
import {
ApplicationRef,
ComponentRef,
destroyPlatform,
getPlatform,
Provider,
Type,
ɵsetDocument
} from '@angular/core';
import {
bootstrapApplication,
HydrationFeature,
HydrationFeatureKind,
provideClientHydration
} from '@angular/platform-browser';
import { provideServerRendering, renderApplication } from '@angular/platform-server';
import { DOCUMENT } from '@angular/common';
import { TestBed } from '@angular/core/testing';
/**
* The name of the attribute that contains a slot index
* inside the TransferState storage where hydration info
* could be found.
*/
const NGH_ATTR_NAME = 'ngh';
const EMPTY_TEXT_NODE_COMMENT = 'ngetn';
const TEXT_NODE_SEPARATOR_COMMENT = 'ngtns';
const NGH_ATTR_REGEXP = new RegExp(` ${NGH_ATTR_NAME}=".*?"`, 'g');
const EMPTY_TEXT_NODE_REGEXP = new RegExp(`<!--${EMPTY_TEXT_NODE_COMMENT}-->`, 'g');
const TEXT_NODE_SEPARATOR_REGEXP = new RegExp(`<!--${TEXT_NODE_SEPARATOR_COMMENT}-->`, 'g');
const SKIP_HYDRATION_ATTR_NAME = 'ngSkipHydration';
const SKIP_HYDRATION_ATTR_NAME_LOWER_CASE = SKIP_HYDRATION_ATTR_NAME.toLowerCase();
/**
* This renders the application with server side rendering logic.
*
* @param component the test component to be rendered
* @param doc the document
* @param envProviders the environment providers
* @param hydrationFeatures kinds of hydration feature
* @param enableHydration whether to enable hydration
* @returns a promise containing the server rendered app as a string
*/
async function ssr(
component: Type<unknown>,
doc?: string,
envProviders?: Provider[],
hydrationFeatures: HydrationFeature<HydrationFeatureKind>[] = [],
enableHydration = true,
): Promise<string> {
const defaultHtml = '<html><head></head><body><app></app></body></html>';
const providers = [
...(envProviders ?? []),
provideServerRendering(),
(enableHydration ? provideClientHydration(...hydrationFeatures) : []),
];
const bootstrap = () => bootstrapApplication(component, { providers });
return renderApplication(bootstrap, {
document: doc ?? defaultHtml,
});
}
/**
* This bootstraps an application with existing html and enables hydration support
* causing hydration to be invoked.
*
* @param doc the document object
* @param component the root component
* @param envProviders the environment providers
* @param resetComponents the components to reset
* @param hydrationFeatures kinds of hydration feature
* @returns a promise with the application ref
*/
export async function assertionsForHydration(
component: Type<unknown>,
envProviders?: Provider[],
resetComponents: Type<unknown>[] = [],
hydrationFeatures: HydrationFeature<HydrationFeatureKind>[] = [],
): Promise<{
verifyHydrate: () => boolean;
verifySkipHydrate: () => boolean;
}> {
const doc = TestBed.inject(DOCUMENT);
const html = await ssr(component, null, envProviders, hydrationFeatures);
resetTViewsFor(component, ...resetComponents);
// Destroy existing platform, a new one will be created later by the `bootstrapApplication`.
destroyPlatform();
// Get HTML contents of the `<app>`, create a DOM element and append it into the body.
const container = convertHtmlToDom(html, doc);
Array.from(container.children).forEach(node => doc.body.appendChild(node));
function _document(): any {
ɵsetDocument(doc);
// (global as any).document = doc;
return doc;
}
const providers = [
...(envProviders ?? []),
{ provide: DOCUMENT, useFactory: _document, deps: [] },
provideClientHydration(...hydrationFeatures),
];
const appRef = await bootstrapApplication(component, { providers });
const compRef = getComponentRef<Type<unknown>>(appRef);
appRef.tick();
const ssrContents = getAppContents(html);
const clientRootNode = compRef.location.nativeElement;
expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`);
// Destroy existing platform, a new one will be created later by the `bootstrapApplication`.
destroyPlatform();
return {
verifyHydrate: () => {
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
return true;
},
verifySkipHydrate: () => {
verifyNoNodesWereClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
return true;
}
};
}
/**
* Drop utility attributes such as `ng-version`, `ng-server-context` and `ngh`,
* so that it's easier to make assertions in tests.
*/
function stripUtilAttributes(html: string, keepNgh: boolean): string {
html = html.replace(/ ng-version=".*?"/g, '')
.replace(/ ng-server-context=".*?"/g, '')
.replace(/ ng-reflect-(.*?)=".*?"/g, '')
.replace(/ _nghost(.*?)=""/g, '')
.replace(/ _ngcontent(.*?)=""/g, '');
if (!keepNgh) {
html = html.replace(NGH_ATTR_REGEXP, '')
.replace(EMPTY_TEXT_NODE_REGEXP, '')
.replace(TEXT_NODE_SEPARATOR_REGEXP, '');
}
return html;
}
/**
* Reset TView, so that we re-enter the first create pass as
* we would normally do when we hydrate on the client. Otherwise,
* hydration info would not be applied to T data structures.
*/
function resetTViewsFor(...types: Type<unknown>[]) {
for (const type of types) {
getComponentDef(type)!.tView = null;
}
}
/**
* Extracts a portion of HTML located inside of the `<body>` element.
* This content belongs to the application view (and supporting TransferState
* scripts) rendered on the server.
*/
function getAppContents(html: string): string {
const result = stripUtilAttributes(html, true).match(/<body>(.*?)<\/body>/s);
if (!result) {
throw new Error('Invalid HTML structure is provided.');
}
return result[1];
}
/**
* Converts a static HTML to a DOM structure.
*
* @param html the rendered html in test
* @param doc the document object
* @returns a div element containing a copy of the app contents
*/
function convertHtmlToDom(html: string, doc: Document): HTMLElement {
const contents = getAppContents(html);
const container = doc.createElement('div');
container.innerHTML = contents;
return container;
}
function getComponentRef<T>(appRef: ApplicationRef): ComponentRef<T> {
return appRef.components[0];
}
declare const ngDevMode: any | null;
export function resetNgDevMode() {
if (typeof ngDevMode === 'object') {
// Reset all ngDevMode counters.
for (const metric of Object.keys(ngDevMode!)) {
const currentValue = (ngDevMode as unknown as {[key: string]: number | boolean})[metric];
if (typeof currentValue === 'number') {
// Rest only numeric values, which represent counters.
(ngDevMode as unknown as {[key: string]: number | boolean})[metric] = 0;
}
}
}
}
/**
* Walks over DOM nodes starting from a given node and make sure
* those nodes were not annotated as "claimed" by hydration.
* This helper function is needed to verify that the non-destructive
* hydration feature can be turned off.
*/
function verifyNoNodesWereClaimedForHydration(el: HTMLElement) {
if ((el as any).__claimed) {
fail(
'Unexpected state: the following node was hydrated, when the test ' +
'expects the node to be re-created instead: ' + el.outerHTML);
}
let current = el.firstChild;
while (current) {
verifyNoNodesWereClaimedForHydration(current as HTMLElement);
current = current.nextSibling;
}
}
function stripTransferDataScript(input: string): string {
return input.replace(/<script (.*?)<\/script>/s, '');
}
function verifyClientAndSSRContentsMatch(ssrContents: string, clientAppRootElement: HTMLElement) {
const clientContents =
stripTransferDataScript(stripUtilAttributes(clientAppRootElement.outerHTML, false));
ssrContents = stripTransferDataScript(stripUtilAttributes(ssrContents, false));
if (clientContents !== ssrContents) {
console.log(clientContents);
console.log(ssrContents);
}
expect(clientContents).toBe(ssrContents, 'Client and server contents mismatch');
}
/**
* Walks over DOM nodes starting from a given node and checks
* whether all nodes were claimed for hydration, i.e. annotated
* with a special monkey-patched flag (which is added in dev mode
* only). It skips any nodes with the skip hydration attribute.
*/
function verifyAllNodesClaimedForHydration(el: HTMLElement, exceptions: HTMLElement[] = []) {
if ((el.nodeType === Node.ELEMENT_NODE && el.hasAttribute(SKIP_HYDRATION_ATTR_NAME_LOWER_CASE)) ||
exceptions.includes(el)) {
return;
}
if (!(el as any).__claimed) {
fail('Hydration error: the node is *not* hydrated: ' + el.outerHTML);
}
let current = el.firstChild;
while (current) {
verifyAllNodesClaimedForHydration(current as HTMLElement, exceptions);
current = current.nextSibling;
}
}
export function getClosureSafeProperty<T>(objWithPropertyToExtract: T): string {
for (let key in objWithPropertyToExtract) {
if (objWithPropertyToExtract[key] === getClosureSafeProperty as any) {
return key;
}
}
throw Error('Could not find renamed property on target object.');
}
export const NG_COMP_DEF = getClosureSafeProperty({ɵcmp: getClosureSafeProperty});
export function getComponentDef<T>(type: any): ComponentRef<T> & { tView: any } |null {
return type[NG_COMP_DEF] || null;
}
fdescribe('hydration', () => {
beforeEach(() => {
resetNgDevMode();
if (getPlatform()) destroyPlatform();
});
it('should pass `tag-recommend` tag', async () => {
@Component({
standalone: true,
selector: 'app',
template: '<tag-recommend [tags]="tags"></tag-recommend>',
imports: [
CommonModule,
TagRecommendComponent,
RouterTestingModule,
SyntaxSharedModule,
],
providers: [
provideHydrationMocks(),
],
})
class SimpleComponent {
tags = [ { tagId: 'test', aliasUrl: 'testurl', localized: 'hello', sheetsCount: 3 } ];
}
const { verifyHydrate } = await assertionsForHydration(SimpleComponent, [], [ TagRecommendComponent ]);
expect(verifyHydrate).not.toThrow();
});
it('should pass `list-item[withImage]` tag', async () => {
@Component({
standalone: true,
selector: 'app',
template: '<list-item withImage></list-item>',
imports: [
ListItemWithImage,
],
})
class SimpleComponent {
}
const { verifyHydrate } = await assertionsForHydration(SimpleComponent, [], [ ListItemWithImage ]);
expect(verifyHydrate).not.toThrow();
});
it('should pass `mp-section-title-wrapper` tag', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
<mp-section-title-wrapper
[label]="'tag-detail.appears-on'"
fontSize="14px"
color="gray"
padding="14px 0 14px"
/>
`,
imports: [
CommonModule,
SectionTitleWrapper,
],
})
class SimpleComponent {
label: string = 'test';
link: string[];
}
const { verifyHydrate } = await assertionsForHydration(SimpleComponent, [], [ SectionTitleWrapper ]);
expect(verifyHydrate).not.toThrow();
});
it('should pass `mp-helper-text` tag', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
<mp-helper-text size="s" label="hydration test"/>`,
imports: [
CommonModule,
MpHelperText
]
})
class SimpleComponent {
}
const { verifyHydrate } = await assertionsForHydration(SimpleComponent, [], [ MpHelperText ]);
expect(verifyHydrate).not.toThrow();
});
fit('should pass `sticky-purchase-sidebar` tag', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
<sticky-purchase-sidebar
ngSkipHydration
[sheet]="_sheet"
[loading]="false" />
<sticky-purchase-sidebar
[sheet]="_sheet"
[loading]="false" />
`,
imports: [
CommonModule,
SyntaxSharedModule,
HttpClientModule,
StickyPurchaseSidebarComponent,
],
providers: [
provideHydrationMocks(),
],
})
class SimpleComponent {
_sheet = _sheetStub;
}
const { verifyHydrate } = await assertionsForHydration(SimpleComponent, [], [ StickyPurchaseSidebarComponent ]);
expect(verifyHydrate).not.toThrow();
});
it('should pass `mp-best-sheet-list` tag', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
<mp-best-sheet-list/>
`,
imports: [
CommonModule,
SyntaxSharedModule,
HttpClientModule,
MobileBestSheetListComponent,
],
providers: [
provideHydrationMocks(),
],
})
class SimpleComponent {
}
const { verifyHydrate } = await assertionsForHydration(SimpleComponent, [], [ MobileBestSheetListComponent ]);
expect(verifyHydrate).not.toThrow();
});
it('should pass `sheet-table` tag', async () => {
@Component({
standalone: true,
selector: 'app',
template: '<sheet-table [sheets]="sheets"></sheet-table>',
imports: [
RouterTestingModule,
SyntaxSharedModule,
SheetTable,
],
providers: [
provideHydrationMocks(),
]
})
class SimpleComponent {
sheets: Sheet[] = [
{
sheetId: 1, metaSong: 'test song', metaMemo: 'test memo', metaMusician: 'test musician',
author: { artistUrl: 'artistUrl', name: 'author' }
}
];
}
const { verifyHydrate } = await assertionsForHydration(SimpleComponent, [], [ SheetTable ]);
expect(verifyHydrate).not.toThrow();
});
it('should pass `mp-sheet-row` tag', async () => {
@Component({
standalone: true,
selector: 'app',
template: '<table><tbody><tr mpSheetRow [sheet]="sheet" [showInstruments]="true"></tr></tbody></table>',
imports: [
SyntaxSharedModule,
MpSheetRowComponent
],
providers: [
provideHydrationMocks(),
]
})
class SimpleComponent {
sheet: Sheet = {
sheetId: 1, metaSong: 'test song', metaMemo: 'test memo', metaMusician: 'test musician',
author: { artistUrl: 'artistUrl', name: 'author' },
instruments: [ 'piano-88keys' ]
};
}
const { verifyHydrate } = await assertionsForHydration(SimpleComponent, [], [ MpSheetRowComponent ]);
expect(verifyHydrate).not.toThrow();
});
it('should pass `package-table` tag', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
<package-table [packages]="packages"/>
`,
imports: [
CommonModule,
PackageTable,
],
providers: [
provideHydrationMocks(),
]
})
class SimpleComponent {
packages = [ {
packageId: 1,
title: 'test title',
sheets: [
{ author: { name: 'test author' }, instruments: [ 'piano-88keys' ] }
],
} ];
}
const { verifyHydrate } = await assertionsForHydration(SimpleComponent, [], [ PackageTable ]);
expect(verifyHydrate).not.toThrow();
});
it('should pass `mp-package-row` tag', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
<table>
<tbody>
<tr mpPackageRow [package]="package" [showInstruments]="true"></tr>
</tbody>
</table>
`,
imports: [
CommonModule,
MpPackageRowComponent
],
providers: [
provideHydrationMocks(),
]
})
class SimpleComponent {
package = {
packageId: 1,
title: 'test title',
sheets: [
{ author: { name: 'test author' }, instruments: [ 'piano-88keys' ] }
],
price: 100
};
}
const { verifyHydrate } = await assertionsForHydration(SimpleComponent, [], [ MpPackageRowComponent ]);
expect(verifyHydrate).not.toThrow();
});
it('should pass `user-info-bar` tag', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
<user-info-bar [product]="_sheet"/>`,
imports: [
CommonModule,
UserInfoBar,
],
providers: [
provideHydrationMocks(),
],
})
class SimpleComponent {
_sheet = _sheetStub;
}
const { verifyHydrate } = await assertionsForHydration(SimpleComponent, [], [ UserInfoBar ]);
expect(verifyHydrate).not.toThrow();
});
fit('should pass `tag-like-button` tag', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
<tag-like-button
[target]="_tag.category" [targetId]="_tag.tagId"
variant="void"
size="s"
iconSize="20px"
/>`,
imports: [
CommonModule,
TagLikeButtonComponent,
],
providers: [
provideHydrationMocks(),
],
})
class SimpleComponent {
_tag = _sheetStub.tags[0];
}
const { verifyHydrate } = await assertionsForHydration(SimpleComponent, [], [ TagLikeButtonComponent ]);
expect(verifyHydrate).not.toThrow();
});
it('should pass `artist-like-button` tag', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
<artist-like-button
[targetId]="_artist.iamUuid"
[artistUrl]="_artist.artistUrl"
variant="void"
size="s"
iconSize="20px"
/>`,
imports: [
CommonModule,
ArtistLikeButtonComponent,
],
providers: [
provideHydrationMocks(),
],
})
class SimpleComponent {
_artist = _sheetStub.author;
}
const { verifyHydrate } = await assertionsForHydration(SimpleComponent, [], [ ArtistLikeButtonComponent ]);
expect(verifyHydrate).not.toThrow();
});
it('should pass `sheet-like-button` tag', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
<sheet-like-button
[targetId]="_sheet.sheetId"
variant="void"
size="s"
iconSize="20px"
/>`,
imports: [
CommonModule,
SheetLikeButton,
],
providers: [
provideHydrationMocks(),
],
})
class SimpleComponent {
_sheet = _sheetStub;
}
const { verifyHydrate } = await assertionsForHydration(SimpleComponent, [], [ SheetLikeButton ]);
expect(verifyHydrate).not.toThrow();
});
it('should pass `package-like-button` tag', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
<package-like-button
[targetId]="_package.packageId"
variant="void"
size="s"
iconSize="20px"
/>`,
imports: [
CommonModule,
PackageLikeButton,
],
providers: [
provideHydrationMocks(),
],
})
class SimpleComponent {
_package = _packageStub;
}
const { verifyHydrate } = await assertionsForHydration(SimpleComponent, [], [ PackageLikeButton ]);
expect(verifyHydrate).not.toThrow();
});
it('should pass `lecture-like-button` tag', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
<lecture-like-button
[targetId]="_lecture.lectureId"
variant="void"
size="s"
iconSize="20px"
/>`,
imports: [
CommonModule,
LectureLikeButton,
],
providers: [
provideHydrationMocks(),
],
})
class SimpleComponent {
_lecture = _lectureStub;
}
const { verifyHydrate } = await assertionsForHydration(SimpleComponent, [], [ LectureLikeButton ]);
expect(verifyHydrate).not.toThrow();
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment