Created
April 20, 2023 03:28
-
-
Save leo6104/e1ffb3fe521fee33b28400eec61d6eac to your computer and use it in GitHub Desktop.
Hydration unit test code in angular 16 (with custom util)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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