diff --git a/tests/e2e/pages/modules/flex-render.ts b/tests/e2e/pages/modules/flex-render.ts index 6014797..5415d95 100644 --- a/tests/e2e/pages/modules/flex-render.ts +++ b/tests/e2e/pages/modules/flex-render.ts @@ -10,6 +10,7 @@ export class FlexRender { readonly inlineEmbed: Locator readonly inlineRuntime: Locator readonly ssrEmbed: Locator + readonly ssrHydrated: Locator readonly embed: (mode: DeliveryMode) => Locator constructor(readonly root: Page | FrameLocator) { @@ -19,6 +20,9 @@ export class FlexRender { .locator('div[data-flex-inline] + script[src*="flex-client"]') .describe('flex inline runtime script') this.ssrEmbed = root.locator('div.ceros-block__flex-ssr').describe('flex server-rendered embed') + this.ssrHydrated = root + .locator('div.ceros-block__flex-ssr[data-flex-router-ready] > [data-flex-ready="true"]') + .describe('flex server-rendered embed, hydrated') this.embed = (mode: DeliveryMode) => ({ iframe: this.iframeEmbed, inline: this.inlineEmbed, ssr: this.ssrEmbed })[mode] } diff --git a/tests/e2e/pages/published-post.page.ts b/tests/e2e/pages/published-post.page.ts index 00d7971..b315e69 100644 --- a/tests/e2e/pages/published-post.page.ts +++ b/tests/e2e/pages/published-post.page.ts @@ -1,11 +1,13 @@ import type { Locator, Page } from '@playwright/test' import { WP_ROUTES } from '@constants/wordpress-constants' import { FlexRender } from '@pages/modules/flex-render' +import type { ImportMap } from '@utils/ceros-embed' /** A published post on the front end — where the render cascade shows its work. */ export class PublishedPostPage { readonly flex: FlexRender readonly iframeEmbed: Locator + readonly importMaps: Locator readonly missingExperience: Locator readonly missingExperienceHeading: Locator @@ -14,6 +16,7 @@ export class PublishedPostPage { this.iframeEmbed = page .locator('div[data-ceros-experience], div[data-aspectRatio] iframe.ceros-experience') .describe('iframe embed marker') + this.importMaps = page.locator('script[type="importmap"]').describe('import map') this.missingExperience = page .locator('div.ceros-missing-experience') .describe('missing experience block') @@ -25,4 +28,21 @@ export class PublishedPostPage { async open(postId: number): Promise { await this.page.goto(WP_ROUTES.permalink(postId)) } + + async importMap(): Promise { + return JSON.parse(String(await this.importMaps.first().textContent())) as ImportMap + } + + /** The module scripts and module preloads that come before the import map. */ + async modulesBeforeImportMap(): Promise { + return this.page.evaluate(() => { + const map = document.querySelector('script[type="importmap"]') + if (!map) return ['no import map'] + return Array.from( + document.querySelectorAll('script[type="module"], link[rel="modulepreload"]'), + ) + .filter((m) => !(map.compareDocumentPosition(m) & Node.DOCUMENT_POSITION_FOLLOWING)) + .map((m) => m.outerHTML) + }) + } } diff --git a/tests/e2e/tests/flex-ssr-import-map.spec.ts b/tests/e2e/tests/flex-ssr-import-map.spec.ts new file mode 100644 index 0000000..a35cc21 --- /dev/null +++ b/tests/e2e/tests/flex-ssr-import-map.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from '@fixtures/fixtures' +import { TIMEOUTS } from '@constants/timeouts' +import { PublishedPostPage } from '@pages/published-post.page' +import { resolvedFlexBlock } from '@utils/block-serializer' +import { expectImportMapCarries, readManifestImportMap } from '@utils/ceros-embed' +import { cerosFlexExperienceUrl, cerosFlexManifestUrl, experienceSlug } from '@utils/env-utils' +import { TAGS } from '@utils/test-tags' + +const manifestUrl = cerosFlexManifestUrl() + +/** WordPress prints one import map for the page; the SSR runtime's imports join it. */ +test.describe('Flex SSR import map', { tag: [TAGS.cerosBlock, TAGS.rendered, TAGS.flex] }, () => { + // A real reader is anonymous; render the published page logged out. + test.use({ storageState: { cookies: [], origins: [] } }) + + test.use({ + postOptions: { + title: 'flex ssr import map', + blocks: [resolvedFlexBlock(experienceSlug(cerosFlexExperienceUrl()), manifestUrl, 'ssr')], + status: 'publish', + }, + }) + + test("the import map leads the modules and carries the experience's imports", async ({ + page, + post, + request, + }) => { + const pageErrors: Error[] = [] + page.on('pageerror', (error) => pageErrors.push(error)) + const manifestMap = await readManifestImportMap(request, manifestUrl) + + const publishedPost = new PublishedPostPage(page) + await publishedPost.open(post.id) + + // The runtime loads from the Ceros CDN, slower on a cold edge after a deploy. + await expect(publishedPost.flex.ssrHydrated).toHaveCount(1, { timeout: TIMEOUTS.MEDIUM }) + await expectImportMapCarries(publishedPost, manifestMap) + expect(pageErrors).toEqual([]) + }) +}) diff --git a/tests/e2e/utils/ceros-embed.ts b/tests/e2e/utils/ceros-embed.ts index 6d4ff8d..c965edc 100644 --- a/tests/e2e/utils/ceros-embed.ts +++ b/tests/e2e/utils/ceros-embed.ts @@ -1,5 +1,6 @@ -import { expect, type Locator } from '@playwright/test' +import { expect, type APIRequestContext, type Locator } from '@playwright/test' import type { RequestUtils } from '@wordpress/e2e-test-utils-playwright' +import type { PublishedPostPage } from '@pages/published-post.page' import { readStoredBlockAttributes } from '@utils/wp-rest-client' /** The two iframe embed sizes the block stores as `selectedOption`. */ @@ -8,6 +9,9 @@ export type EmbedSize = 'full' | 'scroll' /** How a Flex experience is delivered, stored as `deliveryMode`. */ export type DeliveryMode = 'iframe' | 'inline' | 'ssr' +/** A page or manifest import map. */ +export type ImportMap = { imports: Record; integrity?: Record } + /** The wrapper every stored Studio embed code keeps, even after filtering strips its iframe and script. */ const STUDIO_EMBED_WRAPPER = /data-aspectratio=/i @@ -91,3 +95,34 @@ export async function expectStoredFlexAttributes( ) expect(String(stored.inlineEmbedCode ?? '')).toContain(FLEX_INLINE_MARKER) } + +/** + * Read the import map from a Flex manifest. Fails when it has no imports or hashes, + * since checks against it would then prove nothing. + */ +export async function readManifestImportMap( + request: APIRequestContext, + manifestUrl: string, +): Promise { + const response = await request.get(manifestUrl) + await expect(response).toBeOK() + const { importMap } = (await response.json()) as { importMap?: ImportMap } + expect(Object.keys(importMap?.imports ?? {}), 'manifest import map entries').not.toEqual([]) + expect(importMap?.integrity, 'manifest import map integrity').toBeTruthy() + return importMap as ImportMap +} + +/** Assert the page prints one import map, ahead of every module, carrying the manifest's imports and hashes. */ +export async function expectImportMapCarries( + publishedPost: PublishedPostPage, + manifestMap: ImportMap, +): Promise { + await expect(publishedPost.importMaps).toHaveCount(1) + expect(await publishedPost.modulesBeforeImportMap()).toEqual([]) + const map = await publishedPost.importMap() + const specifiers = Object.keys(manifestMap.imports) + expect(Object.keys(map.imports)).toEqual(expect.arrayContaining(specifiers)) + for (const specifier of specifiers) { + expect(map.integrity?.[map.imports[specifier]], specifier).toBeTruthy() + } +}