From eae7409cd41e077ff25b27b215a9d44c2c147cc2 Mon Sep 17 00:00:00 2001 From: Dominic Bett Date: Wed, 21 Oct 2026 22:37:02 +0300 Subject: [PATCH 1/3] test: check the import map on a server-rendered Flex page An SSR page prints one import map, ahead of every module, carrying the experience's imports and integrity, and the embed hydrates. --- tests/e2e/pages/modules/flex-render.ts | 4 +++ tests/e2e/pages/published-post.page.ts | 22 ++++++++++++++ tests/e2e/tests/flex-and-ssr.spec.ts | 42 +++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/tests/e2e/pages/modules/flex-render.ts b/tests/e2e/pages/modules/flex-render.ts index 6014797..d89b4a3 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..2ba1443 100644 --- a/tests/e2e/pages/published-post.page.ts +++ b/tests/e2e/pages/published-post.page.ts @@ -2,10 +2,13 @@ import type { Locator, Page } from '@playwright/test' import { WP_ROUTES } from '@constants/wordpress-constants' import { FlexRender } from '@pages/modules/flex-render' +export type ImportMap = { imports: Record; integrity?: Record } + /** 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 +17,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 +29,22 @@ export class PublishedPostPage { async open(postId: number): Promise { await this.page.goto(WP_ROUTES.permalink(postId)) } + + async importMap(): Promise { + return JSON.parse((await this.importMaps.first().textContent()) ?? '{}') as ImportMap + } + + /** Whether the import map comes before every module script and module preload. */ + async importMapPrecedesModules(): Promise { + return this.page.evaluate(() => { + const map = document.querySelector('script[type="importmap"]') + const modules = document.querySelectorAll('script[type="module"], link[rel="modulepreload"]') + return ( + !!map && + Array.from(modules).every( + (m) => map.compareDocumentPosition(m) & Node.DOCUMENT_POSITION_FOLLOWING, + ) + ) + }) + } } diff --git a/tests/e2e/tests/flex-and-ssr.spec.ts b/tests/e2e/tests/flex-and-ssr.spec.ts index 471d4de..1fad05a 100644 --- a/tests/e2e/tests/flex-and-ssr.spec.ts +++ b/tests/e2e/tests/flex-and-ssr.spec.ts @@ -3,7 +3,7 @@ import { BlockEditorActions } from '@actions/block-editor-actions' import { CerosBlockActions } from '@actions/ceros-block-actions' import { CerosBlockControlsActions } from '@actions/ceros-block-controls-actions' import { BLOCK_TEXT } from '@constants/ceros-block-constants' -import { PublishedPostPage } from '@pages/published-post.page' +import { PublishedPostPage, type ImportMap } from '@pages/published-post.page' import { emptyCerosBlock, resolvedFlexBlock } from '@utils/block-serializer' import { expectStoredFlexAttributes, type DeliveryMode } from '@utils/ceros-embed' import { @@ -114,6 +114,46 @@ test.describe('Flex published render', { tag: [TAGS.cerosBlock, TAGS.rendered, T } }) +/** WordPress prints one import map for the page; the SSR runtime's imports join it. */ +test.describe( + 'Flex SSR published page', + { tag: [TAGS.cerosBlock, TAGS.rendered, TAGS.flex] }, + () => { + test.use({ storageState: { cookies: [], origins: [] } }) + test.use({ + postOptions: { + title: 'flex ssr import map', + blocks: [resolvedFlexBlock(flexSlug, manifestUrl, 'ssr')], + status: 'publish', + }, + }) + + test("one import map, ahead of every module, carries the experience's imports and the embed hydrates", async ({ + page, + post, + request, + }) => { + const pageErrors: Error[] = [] + page.on('pageerror', (error) => pageErrors.push(error)) + const manifest = (await (await request.get(manifestUrl)).json()) as { importMap: ImportMap } + const specifiers = Object.keys(manifest.importMap.imports) + + const publishedPost = new PublishedPostPage(page) + await publishedPost.open(post.id) + + await expect(publishedPost.flex.ssrHydrated).toHaveCount(1) + await expect(publishedPost.importMaps).toHaveCount(1) + expect(await publishedPost.importMapPrecedesModules()).toBe(true) + const map = await publishedPost.importMap() + expect(Object.keys(map.imports)).toEqual(expect.arrayContaining(specifiers)) + for (const specifier of specifiers) { + expect(map.integrity?.[map.imports[specifier]], specifier).toBeTruthy() + } + expect(pageErrors).toEqual([]) + }) + }, +) + /** The SSR preview reads the manifest and warns when it can't, since the page then renders another mode. */ test.describe('Flex SSR preview — manifest warning', { tag: [TAGS.cerosBlock, TAGS.flex] }, () => { test.describe('manifest loads', () => { From 12a1be5005589e3e66b2039c29acea6e11a3df8a Mon Sep 17 00:00:00 2001 From: Dominic Bett Date: Thu, 22 Oct 2026 19:25:51 +0300 Subject: [PATCH 2/3] test: tighten the SSR import map check Fail when the manifest has no imports or hashes to check, name the module tags that come before the map, and fail clearly on a bad manifest response. --- tests/e2e/pages/modules/flex-render.ts | 2 +- tests/e2e/pages/published-post.page.ts | 17 ++++++++--------- tests/e2e/tests/flex-and-ssr.spec.ts | 11 ++++++++--- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/tests/e2e/pages/modules/flex-render.ts b/tests/e2e/pages/modules/flex-render.ts index d89b4a3..5415d95 100644 --- a/tests/e2e/pages/modules/flex-render.ts +++ b/tests/e2e/pages/modules/flex-render.ts @@ -21,7 +21,7 @@ export class FlexRender { .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"]') + .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 2ba1443..7bacbe6 100644 --- a/tests/e2e/pages/published-post.page.ts +++ b/tests/e2e/pages/published-post.page.ts @@ -31,20 +31,19 @@ export class PublishedPostPage { } async importMap(): Promise { - return JSON.parse((await this.importMaps.first().textContent()) ?? '{}') as ImportMap + return JSON.parse(String(await this.importMaps.first().textContent())) as ImportMap } - /** Whether the import map comes before every module script and module preload. */ - async importMapPrecedesModules(): Promise { + /** 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"]') - const modules = document.querySelectorAll('script[type="module"], link[rel="modulepreload"]') - return ( - !!map && - Array.from(modules).every( - (m) => map.compareDocumentPosition(m) & Node.DOCUMENT_POSITION_FOLLOWING, - ) + 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-and-ssr.spec.ts b/tests/e2e/tests/flex-and-ssr.spec.ts index 1fad05a..dbcbd79 100644 --- a/tests/e2e/tests/flex-and-ssr.spec.ts +++ b/tests/e2e/tests/flex-and-ssr.spec.ts @@ -135,15 +135,20 @@ test.describe( }) => { const pageErrors: Error[] = [] page.on('pageerror', (error) => pageErrors.push(error)) - const manifest = (await (await request.get(manifestUrl)).json()) as { importMap: ImportMap } - const specifiers = Object.keys(manifest.importMap.imports) + const response = await request.get(manifestUrl) + await expect(response).toBeOK() + const manifest = (await response.json()) as { importMap?: ImportMap } + const specifiers = Object.keys(manifest.importMap?.imports ?? {}) + // Without imports and hashes in the manifest, the checks below prove nothing. + expect(specifiers.length, 'manifest import map entries').toBeGreaterThan(0) + expect(manifest.importMap?.integrity, 'manifest import map integrity').toBeTruthy() const publishedPost = new PublishedPostPage(page) await publishedPost.open(post.id) await expect(publishedPost.flex.ssrHydrated).toHaveCount(1) await expect(publishedPost.importMaps).toHaveCount(1) - expect(await publishedPost.importMapPrecedesModules()).toBe(true) + expect(await publishedPost.modulesBeforeImportMap()).toEqual([]) const map = await publishedPost.importMap() expect(Object.keys(map.imports)).toEqual(expect.arrayContaining(specifiers)) for (const specifier of specifiers) { From 935883cdaef1d0c2691425820735dfe863575cbd Mon Sep 17 00:00:00 2001 From: Dominic Bett Date: Thu, 22 Oct 2026 19:42:51 +0300 Subject: [PATCH 3/3] test: move the SSR import map check into its own spec Moves the check out of flex-and-ssr.spec.ts into flex-ssr-import-map.spec.ts, with the manifest read and the map assertions as helpers beside the other embed checks. The hydration wait gets the medium timeout, since the runtime loads from the Ceros CDN. --- tests/e2e/pages/published-post.page.ts | 3 +- tests/e2e/tests/flex-and-ssr.spec.ts | 47 +-------------------- tests/e2e/tests/flex-ssr-import-map.spec.ts | 41 ++++++++++++++++++ tests/e2e/utils/ceros-embed.ts | 37 +++++++++++++++- 4 files changed, 79 insertions(+), 49 deletions(-) create mode 100644 tests/e2e/tests/flex-ssr-import-map.spec.ts diff --git a/tests/e2e/pages/published-post.page.ts b/tests/e2e/pages/published-post.page.ts index 7bacbe6..b315e69 100644 --- a/tests/e2e/pages/published-post.page.ts +++ b/tests/e2e/pages/published-post.page.ts @@ -1,8 +1,7 @@ import type { Locator, Page } from '@playwright/test' import { WP_ROUTES } from '@constants/wordpress-constants' import { FlexRender } from '@pages/modules/flex-render' - -export type ImportMap = { imports: Record; integrity?: Record } +import type { ImportMap } from '@utils/ceros-embed' /** A published post on the front end — where the render cascade shows its work. */ export class PublishedPostPage { diff --git a/tests/e2e/tests/flex-and-ssr.spec.ts b/tests/e2e/tests/flex-and-ssr.spec.ts index dbcbd79..471d4de 100644 --- a/tests/e2e/tests/flex-and-ssr.spec.ts +++ b/tests/e2e/tests/flex-and-ssr.spec.ts @@ -3,7 +3,7 @@ import { BlockEditorActions } from '@actions/block-editor-actions' import { CerosBlockActions } from '@actions/ceros-block-actions' import { CerosBlockControlsActions } from '@actions/ceros-block-controls-actions' import { BLOCK_TEXT } from '@constants/ceros-block-constants' -import { PublishedPostPage, type ImportMap } from '@pages/published-post.page' +import { PublishedPostPage } from '@pages/published-post.page' import { emptyCerosBlock, resolvedFlexBlock } from '@utils/block-serializer' import { expectStoredFlexAttributes, type DeliveryMode } from '@utils/ceros-embed' import { @@ -114,51 +114,6 @@ test.describe('Flex published render', { tag: [TAGS.cerosBlock, TAGS.rendered, T } }) -/** WordPress prints one import map for the page; the SSR runtime's imports join it. */ -test.describe( - 'Flex SSR published page', - { tag: [TAGS.cerosBlock, TAGS.rendered, TAGS.flex] }, - () => { - test.use({ storageState: { cookies: [], origins: [] } }) - test.use({ - postOptions: { - title: 'flex ssr import map', - blocks: [resolvedFlexBlock(flexSlug, manifestUrl, 'ssr')], - status: 'publish', - }, - }) - - test("one import map, ahead of every module, carries the experience's imports and the embed hydrates", async ({ - page, - post, - request, - }) => { - const pageErrors: Error[] = [] - page.on('pageerror', (error) => pageErrors.push(error)) - const response = await request.get(manifestUrl) - await expect(response).toBeOK() - const manifest = (await response.json()) as { importMap?: ImportMap } - const specifiers = Object.keys(manifest.importMap?.imports ?? {}) - // Without imports and hashes in the manifest, the checks below prove nothing. - expect(specifiers.length, 'manifest import map entries').toBeGreaterThan(0) - expect(manifest.importMap?.integrity, 'manifest import map integrity').toBeTruthy() - - const publishedPost = new PublishedPostPage(page) - await publishedPost.open(post.id) - - await expect(publishedPost.flex.ssrHydrated).toHaveCount(1) - await expect(publishedPost.importMaps).toHaveCount(1) - expect(await publishedPost.modulesBeforeImportMap()).toEqual([]) - const map = await publishedPost.importMap() - expect(Object.keys(map.imports)).toEqual(expect.arrayContaining(specifiers)) - for (const specifier of specifiers) { - expect(map.integrity?.[map.imports[specifier]], specifier).toBeTruthy() - } - expect(pageErrors).toEqual([]) - }) - }, -) - /** The SSR preview reads the manifest and warns when it can't, since the page then renders another mode. */ test.describe('Flex SSR preview — manifest warning', { tag: [TAGS.cerosBlock, TAGS.flex] }, () => { test.describe('manifest loads', () => { 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() + } +}