refactor(core): restructure hydration test files (#58196)

This re-organizes the hydration tests to make it easier to add new tests. Incremental hydration can have tests in a separate file after this groundwork is landed.

PR Close #58196
This commit is contained in:
Jessica Janiuk 2024-10-14 16:07:46 -04:00 committed by Andrew Kushnir
parent d6a8d35aec
commit d8b5f4e0c2
6 changed files with 566 additions and 431 deletions

View file

@ -73,6 +73,9 @@ ts_library(
jasmine_node_test(
name = "test",
bootstrap = ["//tools/testing:node"],
data = [
"//packages/core/primitives/event-dispatch:contract_bundle_min",
],
deps = [
":test_lib",
],

View file

@ -111,7 +111,7 @@ export function hydrate(
return bootstrapApplication(component, {providers});
}
export function render(doc: Document, html: string) {
export function insertDomInDocument(doc: Document, html: string) {
// Get HTML contents of the `<app>`, create a DOM element and append it into the body.
const container = convertHtmlToDom(html, doc);
@ -125,6 +125,24 @@ export function render(doc: Document, html: string) {
Array.from(container.childNodes).forEach((node) => doc.body.appendChild(node));
}
/**
* This prepares the environment before hydration begins.
*
* @param doc the document object
* @param html the server side rendered DOM string to be hydrated
* @returns a promise with the application ref
*/
export function prepareEnvironment(doc: Document, html: string) {
insertDomInDocument(doc, html);
globalThis.document = doc;
const scripts = doc.getElementsByTagName('script');
for (const script of Array.from(scripts)) {
if (script?.textContent?.startsWith('window.__jsaction_bootstrap')) {
eval(script.textContent);
}
}
}
/**
* This bootstraps an application with existing html and enables hydration support
* causing hydration to be invoked.
@ -134,7 +152,7 @@ export function render(doc: Document, html: string) {
* @param envProviders the environment providers
* @returns a promise with the application ref
*/
export async function renderAndHydrate(
export async function prepareEnvironmentAndHydrate(
doc: Document,
html: string,
component: Type<unknown>,
@ -143,7 +161,7 @@ export async function renderAndHydrate(
hydrationFeatures?: HydrationFeature<HydrationFeatureKind>[];
},
): Promise<ApplicationRef> {
render(doc, html);
prepareEnvironment(doc, html);
return hydrate(doc, component, options);
}

View file

@ -29,10 +29,12 @@ import {EventPhase} from '@angular/core/primitives/event-dispatch';
import {
getAppContents,
hydrate,
renderAndHydrate,
render as renderHtml,
prepareEnvironment,
prepareEnvironmentAndHydrate,
resetTViewsFor,
} from './dom_utils';
import {getDocument} from '@angular/core/src/render3/interfaces/document';
import {serializeDocument} from '../src/domino_adapter';
/**
* Represents the <script> tag added by the build process to inject
@ -70,7 +72,6 @@ function withStrictErrorHandler() {
}
describe('event replay', () => {
let doc: Document;
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
@ -81,7 +82,6 @@ describe('event replay', () => {
beforeEach(() => {
if (getPlatform()) destroyPlatform();
doc = TestBed.inject(DOCUMENT);
});
afterAll(() => {
@ -91,7 +91,6 @@ describe('event replay', () => {
});
afterEach(() => {
doc.body.outerHTML = '<body></body>';
window._ejsas = {};
});
@ -125,17 +124,6 @@ describe('event replay', () => {
});
}
function render(doc: Document, html: string) {
renderHtml(doc, html);
globalThis.document = doc;
const scripts = doc.getElementsByTagName('script');
for (const script of Array.from(scripts)) {
if (script?.textContent?.startsWith('window.__jsaction_bootstrap')) {
eval(script.textContent);
}
}
}
it('should work for elements with local refs', async () => {
const onClickSpy = jasmine.createSpy();
@ -151,7 +139,9 @@ describe('event replay', () => {
}
const html = await ssr(AppComponent);
const ssrContents = getAppContents(html);
render(doc, ssrContents);
const doc = getDocument();
prepareEnvironment(doc, ssrContents);
resetTViewsFor(AppComponent);
const btn = doc.getElementById('btn')!;
btn.click();
@ -171,7 +161,7 @@ describe('event replay', () => {
template: `
<div class="card">
<button id="inner-button" (click)="onClick()"></button>
<ng-content></ng-content>
<ng-content></ng-content>
</div>
`,
})
@ -196,13 +186,16 @@ describe('event replay', () => {
}
const html = await ssr(AppComponent);
const ssrContents = getAppContents(html);
render(doc, ssrContents);
const doc = getDocument();
prepareEnvironment(doc, ssrContents);
resetTViewsFor(AppComponent);
const outer = doc.getElementById('outer-button')!;
const inner = doc.getElementById('inner-button')!;
outer.click();
inner.click();
const appRef = await hydrate(doc, AppComponent, {
envProviders: [{provide: PLATFORM_ID, useValue: 'browser'}],
hydrationFeatures: [withEventReplay()],
});
expect(outerOnClickSpy).toHaveBeenCalledBefore(innerOnClickSpy);
@ -225,7 +218,8 @@ describe('event replay', () => {
const docContents = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
const html = await ssr(SimpleComponent, {doc: docContents});
const ssrContents = getAppContents(html);
render(doc, ssrContents);
const doc = getDocument();
prepareEnvironment(doc, ssrContents);
const el = doc.getElementById('1')!;
expect(el.hasAttribute('jsaction')).toBeTrue();
expect((el.firstChild as Element).hasAttribute('jsaction')).toBeTrue();
@ -276,11 +270,14 @@ describe('event replay', () => {
const docContents = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
const html = await ssr(SimpleComponent, {doc: docContents});
const ssrContents = getAppContents(html);
render(doc, ssrContents);
const doc = getDocument();
prepareEnvironment(doc, ssrContents);
resetTViewsFor(SimpleComponent);
const bottomEl = doc.getElementById('bottom')!;
bottomEl.click();
const appRef = await hydrate(doc, SimpleComponent, {
envProviders: [{provide: PLATFORM_ID, useValue: 'browser'}],
hydrationFeatures: [withEventReplay()],
});
expect(onClickSpy).toHaveBeenCalledTimes(2);
@ -308,7 +305,8 @@ describe('event replay', () => {
const docContents = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
const html = await ssr(SimpleComponent, {doc: docContents});
const ssrContents = getAppContents(html);
render(doc, ssrContents);
const doc = getDocument();
prepareEnvironment(doc, ssrContents);
resetTViewsFor(SimpleComponent);
const bottomEl = doc.getElementById('bottom')!;
bottomEl.click();
@ -344,11 +342,13 @@ describe('event replay', () => {
const docContents = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
const html = await ssr(SimpleComponent, {doc: docContents});
const ssrContents = getAppContents(html);
render(doc, ssrContents);
const doc = getDocument();
prepareEnvironment(doc, ssrContents);
resetTViewsFor(SimpleComponent);
const bottomEl = doc.getElementById('bottom')!;
bottomEl.click();
await hydrate(doc, SimpleComponent, {
envProviders: [{provide: PLATFORM_ID, useValue: 'browser'}],
hydrationFeatures: [withEventReplay()],
});
const replayedEvent = currentEvent;
@ -396,7 +396,8 @@ describe('event replay', () => {
expect(hasEventDispatchScript(ssrContents)).toBeFalse();
resetTViewsFor(SimpleComponent);
await renderAndHydrate(doc, ssrContents, SimpleComponent, {
const doc = getDocument();
await prepareEnvironmentAndHydrate(doc, ssrContents, SimpleComponent, {
envProviders: [
{provide: PLATFORM_ID, useValue: 'browser'},
// This ensures that there are no errors while bootstrapping an application

View file

@ -0,0 +1,270 @@
/**
* @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.io/license
*/
import {
ApplicationRef,
ComponentRef,
ErrorHandler,
Injectable,
Provider,
Type,
} from '@angular/core';
import {Console} from '@angular/core/src/console';
import {
HydrationStatus,
readHydrationInfo,
SSR_CONTENT_INTEGRITY_MARKER,
} from '@angular/core/src/hydration/utils';
import {
bootstrapApplication,
HydrationFeature,
provideClientHydration,
} from '@angular/platform-browser';
import {HydrationFeatureKind} from '@angular/platform-browser/src/hydration';
import {provideServerRendering} from '../public_api';
import {EVENT_DISPATCH_SCRIPT_ID, renderApplication} from '../src/utils';
import {getAppContents, stripUtilAttributes} from './dom_utils';
/**
* The name of the attribute that contains a slot index
* inside the TransferState storage where hydration info
* could be found.
*/
export const NGH_ATTR_NAME = 'ngh';
export const EMPTY_TEXT_NODE_COMMENT = 'ngetn';
export const TEXT_NODE_SEPARATOR_COMMENT = 'ngtns';
export const SKIP_HYDRATION_ATTR_NAME = 'ngSkipHydration';
export const SKIP_HYDRATION_ATTR_NAME_LOWER_CASE = SKIP_HYDRATION_ATTR_NAME.toLowerCase();
export const TRANSFER_STATE_TOKEN_ID = '__nghData__';
/**
* Represents the <script> tag added by the build process to inject
* event dispatch (JSAction) logic.
*/
export const EVENT_DISPATCH_SCRIPT = `<script type="text/javascript" id="${EVENT_DISPATCH_SCRIPT_ID}"></script>`;
export const DEFAULT_DOCUMENT = `<html><head></head><body>${EVENT_DISPATCH_SCRIPT}<app></app></body></html>`;
export function getComponentRef<T>(appRef: ApplicationRef): ComponentRef<T> {
return appRef.components[0];
}
export function stripSsrIntegrityMarker(input: string): string {
return input.replace(`<!--${SSR_CONTENT_INTEGRITY_MARKER}-->`, '');
}
export function stripTransferDataScript(input: string): string {
return input.replace(/<script (.*?)<\/script>/s, '');
}
export function stripExcessiveSpaces(html: string): string {
return html.replace(/\s+/g, ' ');
}
export function verifyClientAndSSRContentsMatch(
ssrContents: string,
clientAppRootElement: HTMLElement,
) {
const clientContents = stripSsrIntegrityMarker(
stripTransferDataScript(stripUtilAttributes(clientAppRootElement.outerHTML, false)),
);
ssrContents = stripSsrIntegrityMarker(
stripTransferDataScript(stripUtilAttributes(ssrContents, false)),
);
expect(getAppContents(clientContents)).toBe(ssrContents, 'Client and server contents mismatch');
}
export function verifyNodeHasMismatchInfo(doc: Document, selector = 'app'): void {
expect(readHydrationInfo(doc.querySelector(selector)!)?.status).toBe(HydrationStatus.Mismatched);
}
/** Checks whether a given element is a <script> that contains transfer state data. */
export function isTransferStateScript(el: HTMLElement): boolean {
return (
el.nodeType === Node.ELEMENT_NODE &&
el.tagName.toLowerCase() === 'script' &&
el.getAttribute('id') === 'ng-state'
);
}
export function isSsrContentsIntegrityMarker(el: Node): boolean {
return (
el.nodeType === Node.COMMENT_NODE && el.textContent?.trim() === SSR_CONTENT_INTEGRITY_MARKER
);
}
/**
* 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.
*/
export function verifyAllNodesClaimedForHydration(el: HTMLElement, exceptions: HTMLElement[] = []) {
if (
(el.nodeType === Node.ELEMENT_NODE && el.hasAttribute(SKIP_HYDRATION_ATTR_NAME_LOWER_CASE)) ||
exceptions.includes(el) ||
isTransferStateScript(el) ||
isSsrContentsIntegrityMarker(el)
) {
return;
}
if (readHydrationInfo(el)?.status !== HydrationStatus.Hydrated) {
fail('Hydration error: the node is *not* hydrated: ' + el.outerHTML);
}
verifyAllChildNodesClaimedForHydration(el, exceptions);
}
export function verifyAllChildNodesClaimedForHydration(
el: HTMLElement,
exceptions: HTMLElement[] = [],
) {
let current = el.firstChild;
while (current) {
verifyAllNodesClaimedForHydration(current as HTMLElement, exceptions);
current = current.nextSibling;
}
}
/**
* 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.
*/
export function verifyNoNodesWereClaimedForHydration(el: HTMLElement) {
if (readHydrationInfo(el)?.status === HydrationStatus.Hydrated) {
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;
}
}
export function verifyNodeHasSkipHydrationMarker(element: HTMLElement): void {
expect(readHydrationInfo(element)?.status).toBe(HydrationStatus.Skipped);
}
/**
* Verifies whether a console has a log entry that contains a given message.
*/
export function verifyHasLog(appRef: ApplicationRef, message: string) {
const console = appRef.injector.get(Console) as DebugConsole;
const context =
`Expected '${message}' to be present in the log, but it was not found. ` +
`Logs content: ${JSON.stringify(console.logs)}`;
expect(console.logs.some((log) => log.includes(message)))
.withContext(context)
.toBe(true);
}
/**
* Verifies that there is no message with a particular content in a console.
*/
export function verifyHasNoLog(appRef: ApplicationRef, message: string) {
const console = appRef.injector.get(Console) as DebugConsole;
const context =
`Expected '${message}' to be present in the log, but it was not found. ` +
`Logs content: ${JSON.stringify(console.logs)}`;
expect(console.logs.some((log) => log.includes(message)))
.withContext(context)
.toBe(false);
}
export function timeout(delay: number): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(resolve, delay);
});
}
export function getHydrationInfoFromTransferState(input: string): string | undefined {
return input.match(/<script[^>]+>(.*?)<\/script>/)?.[1];
}
export function withNoopErrorHandler() {
class NoopErrorHandler extends ErrorHandler {
override handleError(error: any): void {
// noop
}
}
return [
{
provide: ErrorHandler,
useClass: NoopErrorHandler,
},
];
}
@Injectable()
export class DebugConsole extends Console {
logs: string[] = [];
override log(message: string) {
this.logs.push(message);
}
override warn(message: string) {
this.logs.push(message);
}
}
export function withDebugConsole() {
return [{provide: Console, useClass: DebugConsole}];
}
/**
* 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
* @returns a promise containing the server rendered app as a string
*/
export async function ssr(
component: Type<unknown>,
options?: {
doc?: string;
envProviders?: Provider[];
hydrationFeatures?: HydrationFeature<HydrationFeatureKind>[];
enableHydration?: boolean;
},
): Promise<string> {
const defaultHtml = DEFAULT_DOCUMENT;
const enableHydration = options?.enableHydration ?? true;
const envProviders = options?.envProviders ?? [];
const hydrationFeatures = options?.hydrationFeatures ?? [];
const providers = [
...envProviders,
provideServerRendering(),
enableHydration ? provideClientHydration(...hydrationFeatures) : [],
];
const bootstrap = () => bootstrapApplication(component, {providers});
return renderApplication(bootstrap, {
document: options?.doc ?? defaultHtml,
});
}
/**
* Verifies that there are no messages in a console.
*/
export function verifyEmptyConsole(appRef: ApplicationRef) {
const console = appRef.injector.get(Console) as DebugConsole;
const logs = console.logs.filter(
(msg) => !msg.startsWith('Angular is running in development mode'),
);
expect(logs).toEqual([]);
}

View file

@ -570,6 +570,10 @@ class HiddenModule {}
destroyPlatform();
});
afterEach(() => {
destroyPlatform();
});
afterAll(() => {
destroyPlatform();
});
@ -772,6 +776,11 @@ class HiddenModule {}
doc = '<html><head></head><body><app></app></body></html>';
});
afterEach(() => {
doc = '<html><head></head><body><app></app></body></html>';
TestBed.resetTestingModule();
});
it('using long form should work', async () => {
const platform = platformServer([{provide: INITIAL_CONFIG, useValue: {document: doc}}]);