diff --git a/flowifier/src/main/java/htmlflow/flowifier/AbstractHtmlToJavaHtmlFlowNodeVisitor.java b/flowifier/src/main/java/htmlflow/flowifier/AbstractHtmlToJavaHtmlFlowNodeVisitor.java
index 378c6b63..82b65dfc 100644
--- a/flowifier/src/main/java/htmlflow/flowifier/AbstractHtmlToJavaHtmlFlowNodeVisitor.java
+++ b/flowifier/src/main/java/htmlflow/flowifier/AbstractHtmlToJavaHtmlFlowNodeVisitor.java
@@ -435,11 +435,13 @@ private void processGenericNode(final Node node) throws IOException {
private String escapeInAttribute(final String unescaped) {
// FIXME ask JSoup's maintainer to expose a public method to escape the
// value of an attribute
- return escapeHtmlPreservingApostrophe(unescaped).replace("\"", """);
+ return escapeHtmlPreservingApostrophe(unescaped)
+ .replace("\"", """);
}
private String escapeText(final String unescaped) {
- return escapeHtmlPreservingApostrophe(unescaped).replace(""", "\"");
+ return escapeHtmlPreservingApostrophe(unescaped)
+ .replace(""", "\"");
}
private String escapeHtmlPreservingApostrophe(final String unescaped) {
diff --git a/htmlflow-core/pom.xml b/htmlflow-core/pom.xml
index 74c4cc80..08a6137b 100644
--- a/htmlflow-core/pom.xml
+++ b/htmlflow-core/pom.xml
@@ -91,6 +91,14 @@
${junit.jupiter-version}
test
+
+
+
+ com.microsoft.playwright
+ playwright
+ 1.63.0
+ test
+
@@ -123,4 +131,34 @@
+
+
+
+
+ e2e
+
+
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+ 3.5.2
+
+ -Xmx32m
+
+ ${project.basedir}/../htmlflow-mfe-engine/dist
+
+
+
+
+
+ integration-test
+ verify
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/htmlflow-core/src/main/java/htmlflow/visitor/HtmlVisitor.java b/htmlflow-core/src/main/java/htmlflow/visitor/HtmlVisitor.java
index be4fb25a..c6bedd40 100644
--- a/htmlflow-core/src/main/java/htmlflow/visitor/HtmlVisitor.java
+++ b/htmlflow-core/src/main/java/htmlflow/visitor/HtmlVisitor.java
@@ -24,9 +24,15 @@
package htmlflow.visitor;
+import static htmlflow.visitor.Tags.*;
+
import htmlflow.HtmlMfeConfig;
import htmlflow.exceptions.HtmlFlowAppendException;
import htmlflow.visitor.escape.HtmlEscapers;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
import org.xmlet.htmlapifaster.Area;
import org.xmlet.htmlapifaster.Base;
import org.xmlet.htmlapifaster.Br;
@@ -46,13 +52,6 @@
import org.xmlet.htmlapifaster.Source;
import org.xmlet.htmlapifaster.Text;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.function.Consumer;
-
-import static htmlflow.visitor.Tags.*;
-
/**
* This is the base implementation of the ElementVisitor (from HtmlApiFaster library).
*
diff --git a/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeBasicIT.java b/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeBasicIT.java
new file mode 100644
index 00000000..bddb3b14
--- /dev/null
+++ b/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeBasicIT.java
@@ -0,0 +1,61 @@
+package htmlflow.test.e2e;
+
+import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
+import static org.junit.Assert.assertEquals;
+
+import com.microsoft.playwright.Locator;
+import htmlflow.HtmlFlow;
+import htmlflow.HtmlView;
+import java.util.List;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.xmlet.htmlapifaster.EnumTypeScriptType;
+
+/** End-to-end test of the compiled htmlflow-mfe-engine (dist/) rendering HtmlFlow MFE views. */
+public class MfeBasicIT extends MfeE2EBase {
+
+ private static final HtmlView> HOST_PAGE = HtmlFlow.ViewFactory.builder()
+ .mfeEnabled(true)
+ .preEncoding(true)
+ .build()
+ .view(page -> page
+ .html()
+ .head()
+ .script().attrType(EnumTypeScriptType.MODULE).attrSrc(ENGINE_URL).__()
+ .__()
+ .body()
+ .div()
+ .mfe(cfg -> {
+ cfg.setMfeUrlResource("/fragment");
+ cfg.setMfeName("hello");
+ })
+ .__()
+ .__()
+ .__());
+
+ private static MfeTestServer server;
+
+ @BeforeClass
+ public static void startServer() throws Exception {
+ server = new MfeTestServer()
+ .html("/", HOST_PAGE::render)
+ .html("/fragment", () -> "
Hello World
")
+ .start();
+ }
+
+ @AfterClass
+ public static void stopServer() {
+ if (server != null) server.stop();
+ }
+
+ @Test
+ public void shouldRenderHelloWorldInsideMicroFrontend() {
+ page.navigate(server.url("/"));
+
+ // Playwright CSS locators pierce open shadow roots
+ Locator mfe = page.locator("micro-frontend[mfe-name='hello']");
+ assertThat(mfe.locator("#greeting")).hasText("Hello World");
+ assertEquals(List.of(), pageErrors);
+ }
+}
diff --git a/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeE2EBase.java b/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeE2EBase.java
new file mode 100644
index 00000000..6864a1ff
--- /dev/null
+++ b/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeE2EBase.java
@@ -0,0 +1,88 @@
+package htmlflow.test.e2e;
+
+import com.microsoft.playwright.Browser;
+import com.microsoft.playwright.BrowserContext;
+import com.microsoft.playwright.Page;
+import com.microsoft.playwright.Playwright;
+import com.microsoft.playwright.Route;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+
+/**
+ * Shared Playwright setup for MFE e2e tests.
+ *
+ * - {@link #ENGINE_URL} is fulfilled from the compiled engine in {@code htmlflow-mfe-engine/dist}.
+ * - {@code /e2e/} is fulfilled from the test classpath resource {@code e2e/} (JS or CSS).
+ *
+ * Subclasses own their {@link MfeTestServer} (host page and fragments).
+ */
+public abstract class MfeE2EBase {
+
+ protected static final String ENGINE_URL = "/engine/htmlflow-mfe-engine.js";
+ protected static final String TEST_JS_ROUTE = "/e2e/";
+
+ private static final Path ENGINE_FILE = Path.of(
+ System.getProperty("mfe.engine.dist", "../htmlflow-mfe-engine/dist"),
+ "htmlflow-mfe-engine.js"
+ );
+
+ private static Playwright playwright;
+ protected static Browser browser;
+
+ protected BrowserContext context;
+ protected Page page;
+ protected final List pageErrors = new ArrayList<>();
+
+ @BeforeClass
+ public static void launchBrowser() {
+ playwright = Playwright.create();
+ browser = playwright.chromium().launch();
+ }
+
+ @AfterClass
+ public static void closeBrowser() {
+ if (browser != null) browser.close();
+ if (playwright != null) playwright.close();
+ }
+
+ @Before
+ public void openPage() {
+ context = browser.newContext();
+ context.route("**" + ENGINE_URL, route -> route.fulfill(new Route.FulfillOptions()
+ .setContentType("text/javascript")
+ .setPath(ENGINE_FILE)));
+ context.route("**" + TEST_JS_ROUTE + "*", MfeE2EBase::fulfillTestScript);
+ page = context.newPage();
+ page.onPageError(pageErrors::add);
+ }
+
+ @After
+ public void closePage() {
+ if (context != null) context.close();
+ }
+
+ private static void fulfillTestScript(Route route) {
+ String path = URI.create(route.request().url()).getPath();
+ String resource = "e2e/" + path.substring(path.lastIndexOf('/') + 1);
+ try (InputStream in = MfeE2EBase.class.getClassLoader().getResourceAsStream(resource)) {
+ if (in == null) {
+ route.fulfill(new Route.FulfillOptions().setStatus(404).setBody("Not found: " + resource));
+ return;
+ }
+ route.fulfill(new Route.FulfillOptions()
+ .setContentType(resource.endsWith(".css") ? "text/css" : "text/javascript")
+ .setBodyBytes(in.readAllBytes()));
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+}
diff --git a/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeEventsIT.java b/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeEventsIT.java
new file mode 100644
index 00000000..c877a9d9
--- /dev/null
+++ b/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeEventsIT.java
@@ -0,0 +1,144 @@
+package htmlflow.test.e2e;
+
+import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
+import static org.junit.Assert.assertEquals;
+
+import com.microsoft.playwright.Locator;
+import htmlflow.HtmlFlow;
+import htmlflow.HtmlView;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.xmlet.htmlapifaster.EnumTypeScriptType;
+
+/**
+ * Two MFEs talking over the engine's event bus:
+ *
+ * - microfrontend_1 button click -> payload rendered as a list item in microfrontend_2;
+ * - microfrontend_1 button click -> microfrontend_2 reloads itself and shows the new server content.
+ *
+ */
+public class MfeEventsIT extends MfeE2EBase {
+
+ private static final String EVENT_NAME = "itemAdded";
+
+ private static final HtmlView> HOST_PAGE = HtmlFlow.ViewFactory.builder()
+ .mfeEnabled(true)
+ .preEncoding(true)
+ .build()
+ .view(page -> page
+ .html()
+ .head()
+ .script().attrType(EnumTypeScriptType.MODULE).attrSrc(ENGINE_URL).__()
+ .script().attrType(EnumTypeScriptType.MODULE).attrSrc(TEST_JS_ROUTE + "mfe-events.js").__()
+ .__()
+ .body()
+ .div()
+ .mfe(cfg -> {
+ cfg.setMfeUrlResource("/fragments/microfrontend_1");
+ cfg.setMfeName("microfrontend_1");
+ cfg.setMfeTriggersEventName(EVENT_NAME);
+ })
+ .mfe(cfg -> {
+ cfg.setMfeUrlResource("/fragments/microfrontend_2");
+ cfg.setMfeName("microfrontend_2");
+ cfg.setMfeListeningEventName(EVENT_NAME);
+ })
+ .__()
+ .__()
+ .__());
+
+ private static final String RELOAD_EVENT_NAME = "reloadRequested";
+
+ private static final HtmlView> RELOAD_HOST_PAGE = HtmlFlow.ViewFactory.builder()
+ .mfeEnabled(true)
+ .preEncoding(true)
+ .build()
+ .view(page -> page
+ .html()
+ .head()
+ .script().attrType(EnumTypeScriptType.MODULE).attrSrc(ENGINE_URL).__()
+ .script().attrType(EnumTypeScriptType.MODULE).attrSrc(TEST_JS_ROUTE + "mfe-reload.js").__()
+ .__()
+ .body()
+ .div()
+ .mfe(cfg -> {
+ cfg.setMfeUrlResource("/fragments/reload/microfrontend_1");
+ cfg.setMfeName("microfrontend_1");
+ cfg.setMfeTriggersEventName(RELOAD_EVENT_NAME);
+ })
+ .mfe(cfg -> {
+ cfg.setMfeUrlResource("/fragments/reload/microfrontend_2");
+ cfg.setMfeName("microfrontend_2");
+ cfg.setMfeListeningEventName(RELOAD_EVENT_NAME);
+ })
+ .__()
+ .__()
+ .__());
+
+ // Bumped on every microfrontend_2 fetch, so each (re)load returns different content.
+ // AtomicInteger because the server answers on its own threads.
+ private static final AtomicInteger version = new AtomicInteger();
+
+ private static MfeTestServer server;
+
+ @BeforeClass
+ public static void startServer() throws Exception {
+ server = new MfeTestServer()
+ .html("/", HOST_PAGE::render)
+ .html("/fragments/microfrontend_1", () -> "")
+ .html("/fragments/microfrontend_2", () -> "")
+ .html("/reload", RELOAD_HOST_PAGE::render)
+ .html("/fragments/reload/microfrontend_1", () -> "")
+ .html("/fragments/reload/microfrontend_2", () -> "Version " + version.incrementAndGet() + "
")
+ .start();
+ }
+
+ @Before
+ public void resetVersion() {
+ version.set(0);
+ }
+
+ @AfterClass
+ public static void stopServer() {
+ if (server != null) server.stop();
+ }
+
+ @Test
+ public void shouldSendEventFromMicrofrontend1AndRenderItemInMicrofrontend2() {
+ page.navigate(server.url("/"));
+
+ Locator mfe1 = page.locator("micro-frontend[mfe-name='microfrontend_1']");
+ Locator mfe2 = page.locator("micro-frontend[mfe-name='microfrontend_2']");
+ assertThat(mfe1.locator("#send")).isVisible();
+ assertThat(mfe2.locator("#items")).isAttached();
+
+ mfe1.locator("#send").click();
+
+ Locator items = mfe2.locator("#items li");
+ assertThat(items).hasCount(1);
+ assertThat(items.first()).hasText("test 1");
+ assertThat(items.first()).hasAttribute("data-id", "1");
+ assertThat(mfe1.locator("li")).hasCount(0);
+ assertEquals(List.of(), pageErrors);
+ }
+
+ @Test
+ public void shouldReloadMicrofrontend2WhenEventIsReceived() {
+ page.navigate(server.url("/reload"));
+
+ Locator mfe1 = page.locator("micro-frontend[mfe-name='microfrontend_1']");
+ Locator mfe2 = page.locator("micro-frontend[mfe-name='microfrontend_2']");
+ assertThat(mfe2.locator("#version")).hasText("Version 1");
+
+ mfe1.locator("#reload").click();
+
+ // New content can only come from re-fetching the fragment
+ assertThat(mfe2.locator("#version")).hasText("Version 2");
+ assertThat(mfe2.locator("#version")).hasCount(1);
+ assertEquals(List.of(), pageErrors);
+ }
+}
diff --git a/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeScriptIntegrityIT.java b/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeScriptIntegrityIT.java
new file mode 100644
index 00000000..72016eab
--- /dev/null
+++ b/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeScriptIntegrityIT.java
@@ -0,0 +1,162 @@
+package htmlflow.test.e2e;
+
+import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import com.microsoft.playwright.ConsoleMessage;
+import com.microsoft.playwright.Locator;
+import com.microsoft.playwright.Page;
+import com.microsoft.playwright.options.LoadState;
+import htmlflow.HtmlFlow;
+import htmlflow.HtmlView;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.xmlet.htmlapifaster.EnumTypeScriptType;
+
+/**
+ * MFE script loaded through {@code setMfeScriptUrl} with Subresource Integrity ({@code setMfeScriptIntegrity}).
+ * The script (mfe-events.js) wires microfrontend_1's button to add an item to microfrontend_2,
+ * so the click outcome shows whether the browser executed it or blocked it.
+ */
+public class MfeScriptIntegrityIT extends MfeE2EBase {
+
+ private static final String EVENT_NAME = "itemAdded";
+ private static final String SCRIPT_URL = TEST_JS_ROUTE + "mfe-events.js";
+ private static final String SCRIPT_SELECTOR = "head script[src='" + SCRIPT_URL + "']";
+
+ private static final String VALID_INTEGRITY = sha384(readResource("e2e/mfe-events.js"));
+ private static final String INVALID_INTEGRITY = sha384("tampered".getBytes(StandardCharsets.UTF_8));
+
+ private static final HtmlView> VALID_HOST_PAGE = hostPage(VALID_INTEGRITY);
+ private static final HtmlView> INVALID_HOST_PAGE = hostPage(INVALID_INTEGRITY);
+
+ private static MfeTestServer server;
+
+ private static HtmlView> hostPage(String integrity) {
+ return HtmlFlow.ViewFactory.builder()
+ .mfeEnabled(true)
+ .preEncoding(true)
+ .build()
+ .view(page -> page
+ .html()
+ .head()
+ .script().attrType(EnumTypeScriptType.MODULE).attrSrc(ENGINE_URL).__()
+ .__()
+ .body()
+ .div()
+ .mfe(cfg -> {
+ cfg.setMfeUrlResource("/fragments/microfrontend_1");
+ cfg.setMfeName("microfrontend_1");
+ cfg.setMfeTriggersEventName(EVENT_NAME);
+ cfg.setMfeScriptUrl(SCRIPT_URL);
+ cfg.setMfeScriptIntegrity(integrity);
+ })
+ .mfe(cfg -> {
+ cfg.setMfeUrlResource("/fragments/microfrontend_2");
+ cfg.setMfeName("microfrontend_2");
+ cfg.setMfeListeningEventName(EVENT_NAME);
+ })
+ .__()
+ .__()
+ .__());
+ }
+
+ @BeforeClass
+ public static void startServer() throws Exception {
+ server = new MfeTestServer()
+ .html("/valid", VALID_HOST_PAGE::render)
+ .html("/invalid", INVALID_HOST_PAGE::render)
+ .html("/fragments/microfrontend_1", () -> "")
+ .html("/fragments/microfrontend_2", () -> "")
+ .start();
+ }
+
+ @AfterClass
+ public static void stopServer() {
+ if (server != null) server.stop();
+ }
+
+ @Test
+ public void shouldExecuteMfeScriptWhenIntegrityMatches() {
+ List integrityErrors = new ArrayList<>();
+ page.onConsoleMessage(msg -> {
+ if (isIntegrityError(msg)) integrityErrors.add(msg.text());
+ });
+
+ page.navigate(server.url("/valid"));
+
+ Locator script = page.locator(SCRIPT_SELECTOR);
+ assertThat(script).hasAttribute("integrity", VALID_INTEGRITY);
+ assertThat(script).hasAttribute("crossorigin", "anonymous");
+
+ Locator mfe1 = page.locator("micro-frontend[mfe-name='microfrontend_1']");
+ Locator mfe2 = page.locator("micro-frontend[mfe-name='microfrontend_2']");
+ assertThat(mfe2.locator("#items")).isAttached();
+
+ // Only the integrity-checked script wires this click
+ mfe1.locator("#send").click();
+
+ Locator items = mfe2.locator("#items li");
+ assertThat(items).hasCount(1);
+ assertThat(items.first()).hasText("test 1");
+ assertEquals(List.of(), integrityErrors);
+ assertEquals(List.of(), pageErrors);
+ }
+
+ @Test
+ public void shouldBlockMfeScriptWhenIntegrityDoesNotMatch() {
+ // Chromium downloads the script but refuses to run it, reporting it only on the console
+ ConsoleMessage blocked = page.waitForConsoleMessage(
+ new Page.WaitForConsoleMessageOptions().setPredicate(MfeScriptIntegrityIT::isIntegrityError),
+ () -> page.navigate(server.url("/invalid")));
+
+ assertThat(page.locator(SCRIPT_SELECTOR)).hasAttribute("integrity", INVALID_INTEGRITY);
+ assertThat(page.locator(SCRIPT_SELECTOR)).hasAttribute("crossorigin", "anonymous");
+
+ // Fragments still render: the engine itself is not affected
+ Locator mfe1 = page.locator("micro-frontend[mfe-name='microfrontend_1']");
+ Locator mfe2 = page.locator("micro-frontend[mfe-name='microfrontend_2']");
+ assertThat(mfe1.locator("#send")).isVisible();
+ assertThat(mfe2.locator("#items")).isAttached();
+
+ mfe1.locator("#send").click();
+ page.waitForLoadState(LoadState.NETWORKIDLE);
+
+ assertThat(mfe2.locator("#items li")).hasCount(0);
+ assertTrue(blocked.text(), blocked.text().contains("mfe-events.js"));
+ assertEquals(List.of(), pageErrors);
+ }
+
+ private static boolean isIntegrityError(ConsoleMessage msg) {
+ return "error".equals(msg.type()) && msg.text().contains("integrity");
+ }
+
+ private static byte[] readResource(String resource) {
+ try (InputStream in = MfeScriptIntegrityIT.class.getClassLoader().getResourceAsStream(resource)) {
+ if (in == null) throw new IllegalStateException("Missing test resource: " + resource);
+ return in.readAllBytes();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ private static String sha384(byte[] content) {
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-384").digest(content);
+ return "sha384-" + Base64.getEncoder().encodeToString(digest);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeSharedStylingIT.java b/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeSharedStylingIT.java
new file mode 100644
index 00000000..769f938d
--- /dev/null
+++ b/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeSharedStylingIT.java
@@ -0,0 +1,135 @@
+package htmlflow.test.e2e;
+
+import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
+import static org.junit.Assert.assertEquals;
+
+import com.microsoft.playwright.Locator;
+import com.microsoft.playwright.options.LoadState;
+import htmlflow.HtmlFlow;
+import htmlflow.HtmlView;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.xmlet.htmlapifaster.EnumTypeScriptType;
+
+/**
+ * Two MFEs sharing one scoped stylesheet (mfe-shared-styling-url) plus their own (mfe-styling-url).
+ * The shared sheet is also adopted onto the host document, so the host shell can use the same theme.
+ */
+public class MfeSharedStylingIT extends MfeE2EBase {
+
+ private static final String SHARED_CSS = TEST_JS_ROUTE + "shared.css";
+ private static final String SHARED_BG = "rgb(255, 99, 71)";
+
+ private static final HtmlView> HOST_PAGE = HtmlFlow.ViewFactory.builder()
+ .mfeEnabled(true)
+ .preEncoding(true)
+ .build()
+ .view(page -> page
+ .html()
+ .head()
+ .script().attrType(EnumTypeScriptType.MODULE).attrSrc(ENGINE_URL).__()
+ .__()
+ .body()
+ // Same class as in the MFEs, but outside any .mfe-shared scope
+ .button().attrClass("btn").attrId("host-btn").text("Host button").__()
+ // Host shell opting into the shared theme by using the scope class
+ .div().attrClass("mfe-shared")
+ .button().attrClass("btn").attrId("host-themed-btn").text("Themed host button").__()
+ .__()
+ .div()
+ .mfe(cfg -> {
+ cfg.setMfeUrlResource("/fragments/microfrontend_1");
+ cfg.setMfeName("microfrontend_1");
+ cfg.setMfeSharedStylingUrl(SHARED_CSS);
+ cfg.setMfeStylingUrl(TEST_JS_ROUTE + "microfrontend_1.css");
+ })
+ .mfe(cfg -> {
+ cfg.setMfeUrlResource("/fragments/microfrontend_2");
+ cfg.setMfeName("microfrontend_2");
+ cfg.setMfeSharedStylingUrl(SHARED_CSS);
+ cfg.setMfeStylingUrl(TEST_JS_ROUTE + "microfrontend_2.css");
+ })
+ .__()
+ .__()
+ .__());
+
+ private static MfeTestServer server;
+
+ @BeforeClass
+ public static void startServer() throws Exception {
+ server = new MfeTestServer()
+ .html("/", HOST_PAGE::render)
+ .html("/fragments/microfrontend_1", () -> fragment(1))
+ .html("/fragments/microfrontend_2", () -> fragment(2))
+ .start();
+ }
+
+ @AfterClass
+ public static void stopServer() {
+ if (server != null) server.stop();
+ }
+
+ private static String fragment(int n) {
+ return ""
+ + "
MFE " + n + "
"
+ + "
"
+ + "
";
+ }
+
+ @Test
+ public void shouldApplySharedScopedStylingToBothMicrofrontends() {
+ List sharedCssRequests = new ArrayList<>();
+ page.onRequest(req -> {
+ if (req.url().endsWith(SHARED_CSS)) sharedCssRequests.add(req.url());
+ });
+
+ page.navigate(server.url("/"));
+
+ Locator mfe1 = page.locator("micro-frontend[mfe-name='microfrontend_1']");
+ Locator mfe2 = page.locator("micro-frontend[mfe-name='microfrontend_2']");
+
+ // Shared reusable button class applied in both MFEs
+ for (Locator mfe : List.of(mfe1, mfe2)) {
+ Locator btn = mfe.locator("#btn");
+ assertThat(btn).hasCSS("background-color", SHARED_BG);
+ assertThat(btn).hasCSS("border-radius", "8px");
+ assertThat(btn).hasCSS("padding", "8px 16px");
+ }
+
+ // Own stylesheets apply only to their own MFE
+ assertThat(mfe1.locator(".title")).hasCSS("color", "rgb(0, 128, 0)");
+ assertThat(mfe2.locator(".title")).hasCSS("color", "rgb(0, 0, 255)");
+
+ // Shared sheet is also adopted onto document, but @scope keeps it off the host button
+ page.waitForFunction("() => document.adoptedStyleSheets.length === 1");
+ assertThat(page.locator("#host-btn")).not().hasCSS("background-color", SHARED_BG);
+ assertThat(page.locator("#host-btn")).not().hasCSS("border-radius", Pattern.compile("^8px$"));
+
+ page.waitForLoadState(LoadState.NETWORKIDLE); //wait until all network activity has stopped
+ assertEquals("shared.css should be fetched once and cached: " + sharedCssRequests, 1, sharedCssRequests.size());
+ assertEquals(List.of(), pageErrors);
+ }
+
+ @Test
+ public void shouldApplySharedThemeToHostShell() {
+ page.navigate(server.url("/"));
+
+ // Wait until an MFE has loaded the shared sheet and adopted it onto document
+ assertThat(page.locator("micro-frontend[mfe-name='microfrontend_1'] #btn")).hasCSS("background-color", SHARED_BG);
+ page.waitForFunction("() => document.adoptedStyleSheets.length === 1");
+
+ // Host element inside the .mfe-shared scope gets the shared theme
+ Locator themed = page.locator("#host-themed-btn");
+ assertThat(themed).hasCSS("background-color", SHARED_BG);
+ assertThat(themed).hasCSS("border-radius", "8px");
+ assertThat(themed).hasCSS("padding", "8px 16px");
+
+ // Host element outside the scope is untouched
+ assertThat(page.locator("#host-btn")).not().hasCSS("background-color", SHARED_BG);
+ assertEquals(List.of(), pageErrors);
+ }
+}
diff --git a/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeStreamingIT.java b/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeStreamingIT.java
new file mode 100644
index 00000000..229cbd63
--- /dev/null
+++ b/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeStreamingIT.java
@@ -0,0 +1,190 @@
+package htmlflow.test.e2e;
+
+import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import com.microsoft.playwright.Locator;
+import htmlflow.HtmlFlow;
+import htmlflow.HtmlView;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.xmlet.htmlapifaster.EnumTypeScriptType;
+
+/**
+ * PSSR: the MFE fragment is streamed. A chunk containing {@code } elements is the wrapper, a chunk
+ * with a {@code slot="x"} element goes into {@code slot[name="x"]} and any other chunk is appended as is.
+ */
+public class MfeStreamingIT extends MfeE2EBase {
+
+ private static final Duration CHUNK_DELAY = Duration.ofMillis(100);
+ private static final String MFE = "micro-frontend[mfe-name='streaming']";
+
+ private static final String WRAPPER_CHUNK = ""
+ + ""
+ + ""
+ + ""
+ + "
";
+
+ private static final String SINGLE_SLOT_WRAPPER_CHUNK =
+ "";
+
+ private static MfeTestServer server;
+
+ private final List consoleErrors = new ArrayList<>();
+
+ /** Host page with a single streaming MFE fetching {@code fragmentUrl}. */
+ private static HtmlView> hostPage(String fragmentUrl) {
+ return HtmlFlow.ViewFactory.builder()
+ .mfeEnabled(true)
+ .preEncoding(true)
+ .build()
+ .view(page -> page
+ .html()
+ .head()
+ .script().attrType(EnumTypeScriptType.MODULE).attrSrc(ENGINE_URL).__()
+ .__()
+ .body()
+ .div()
+ .mfe(cfg -> {
+ cfg.setMfeUrlResource(fragmentUrl);
+ cfg.setMfeName("streaming");
+ cfg.setMfeStreamingData(true);
+ })
+ .__()
+ .__()
+ .__());
+ }
+
+ @BeforeClass
+ public static void startServer() throws Exception {
+ HtmlView> inOrder = hostPage("/fragments/items");
+ HtmlView> outOfOrder = hostPage("/fragments/out-of-order");
+ HtmlView> plain = hostPage("/fragments/plain");
+ HtmlView> missingSlot = hostPage("/fragments/missing-slot");
+ server = new MfeTestServer()
+ .html("/", inOrder::render)
+ .stream("/fragments/items", CHUNK_DELAY,
+ WRAPPER_CHUNK, itemChunk(1), itemChunk(2), itemChunk(3))
+ .html("/out-of-order", outOfOrder::render)
+ .stream("/fragments/out-of-order", CHUNK_DELAY,
+ WRAPPER_CHUNK, itemChunk(3), itemChunk(1), itemChunk(2))
+ .html("/plain", plain::render)
+ .stream("/fragments/plain", CHUNK_DELAY,
+ lineChunk(1), lineChunk(2), lineChunk(3))
+ .html("/missing-slot", missingSlot::render)
+ .stream("/fragments/missing-slot", CHUNK_DELAY,
+ SINGLE_SLOT_WRAPPER_CHUNK, itemChunk(1), itemChunk(9))
+ .start();
+ }
+
+ @AfterClass
+ public static void stopServer() {
+ if (server != null) server.stop();
+ }
+
+ @Before
+ public void collectConsoleErrors() {
+ page.onConsoleMessage(msg -> {
+ if ("error".equals(msg.type())) consoleErrors.add(msg.text());
+ });
+ }
+
+ private static String itemChunk(int n) {
+ return "Item " + n + "";
+ }
+
+ private static String lineChunk(int n) {
+ return "Line " + n + "
";
+ }
+
+ private List slotErrors() {
+ return consoleErrors.stream().filter(text -> text.contains(" li");
+ assertThat(item).hasCount(1);
+ assertThat(item).hasText("Item " + n);
+ assertThat(item).hasAttribute("data-id", String.valueOf(n));
+ }
+ }
+
+ @Test
+ public void shouldRenderStreamedItemsIntoTheirSlots() {
+ page.navigate(server.url("/"));
+
+ Locator mfe = page.locator(MFE);
+
+ // Wrapper chunk
+ Locator list = mfe.locator(".item-list > ul#items");
+ assertThat(list).isAttached();
+ assertThat(list.locator("> slot")).hasCount(3);
+
+ // Item chunks, in order
+ Locator items = list.locator("li");
+ assertThat(items).hasCount(3);
+ assertEquals(List.of("Item 1", "Item 2", "Item 3"), items.allTextContents());
+
+ assertItemsInTheirSlots(list, 1, 2, 3);
+ assertEquals(List.of(), pageErrors);
+ assertEquals(List.of(), slotErrors());
+ }
+
+ @Test
+ public void shouldPlaceOutOfOrderChunksIntoTheirSlots() {
+ page.navigate(server.url("/out-of-order"));
+
+ Locator list = page.locator(MFE).locator(".item-list > ul#items");
+
+ // Arrival order is 3, 1, 2 but the slots fix the rendered order
+ Locator items = list.locator("li");
+ assertThat(items).hasCount(3);
+ assertEquals(List.of("Item 1", "Item 2", "Item 3"), items.allTextContents());
+
+ assertItemsInTheirSlots(list, 1, 2, 3);
+ assertEquals(List.of(), pageErrors);
+ assertEquals(List.of(), slotErrors());
+ }
+
+ @Test
+ public void shouldAppendChunksWithoutSlotsInArrivalOrder() {
+ page.navigate(server.url("/plain"));
+
+ Locator mfe = page.locator(MFE);
+
+ Locator lines = mfe.locator("p.line");
+ assertThat(lines).hasCount(3);
+ assertEquals(List.of("Line 1", "Line 2", "Line 3"), lines.allTextContents());
+
+ // No wrapper involved and the loading placeholder was cleared
+ assertThat(mfe.locator("slot")).hasCount(0);
+ assertThat(mfe).not().containsText("Loading");
+ assertEquals(List.of(), pageErrors);
+ assertEquals(List.of(), slotErrors());
+ }
+
+ @Test
+ public void shouldReportChunkTargetingMissingSlot() {
+ page.navigate(server.url("/missing-slot"));
+
+ Locator mfe = page.locator(MFE);
+ Locator list = mfe.locator(".item-list > ul#items");
+
+ assertItemsInTheirSlots(list, 1);
+
+ // item-9 has no matching slot: it is reported and not rendered
+ page.waitForCondition(() -> !slotErrors().isEmpty());
+ assertEquals(1, slotErrors().size());
+ assertTrue(slotErrors().get(0), slotErrors().get(0).contains("No "));
+ assertThat(mfe.locator("li")).hasCount(1);
+ assertThat(mfe).not().containsText("Item 9");
+ assertEquals(List.of(), pageErrors);
+ }
+}
diff --git a/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeTestServer.java b/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeTestServer.java
new file mode 100644
index 00000000..b48dfc4c
--- /dev/null
+++ b/htmlflow-core/src/test/java/htmlflow/test/e2e/MfeTestServer.java
@@ -0,0 +1,84 @@
+package htmlflow.test.e2e;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.function.Supplier;
+
+/** Minimal server for MFE e2e tests: serves the host page and the MFE fragments. */
+public final class MfeTestServer {
+
+ private final HttpServer server;
+ // Lets a slow streaming response run without blocking other requests
+ private final ExecutorService executor = Executors.newCachedThreadPool();
+
+ public MfeTestServer() throws IOException {
+ this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ this.server.setExecutor(executor);
+ }
+
+ public MfeTestServer html(String path, Supplier body) {
+ server.createContext(path, ex -> {
+ // HttpServer contexts match by prefix, so enforce an exact path match
+ if (!ex.getRequestURI().getPath().equals(path)) {
+ send(ex, 404, "text/plain", "Not found".getBytes(StandardCharsets.UTF_8));
+ return;
+ }
+ send(ex, 200, "text/html; charset=utf-8", body.get().getBytes(StandardCharsets.UTF_8));
+ });
+ return this;
+ }
+
+ /**
+ * Streams {@code chunks} as separate HTTP chunks (chunked transfer encoding), flushing each one and
+ * pausing {@code delay} before the next, so the browser receives them as separate reads.
+ */
+ public MfeTestServer stream(String path, Duration delay, String... chunks) {
+ server.createContext(path, ex -> {
+ if (!ex.getRequestURI().getPath().equals(path)) {
+ send(ex, 404, "text/plain", "Not found".getBytes(StandardCharsets.UTF_8));
+ return;
+ }
+ ex.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8");
+ ex.sendResponseHeaders(200, 0);
+ try (OutputStream os = ex.getResponseBody()) {
+ for (int i = 0; i < chunks.length; i++) {
+ if (i > 0) Thread.sleep(delay.toMillis());
+ os.write(chunks[i].getBytes(StandardCharsets.UTF_8));
+ os.flush();
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ });
+ return this;
+ }
+
+ public MfeTestServer start() {
+ server.start();
+ return this;
+ }
+
+ public String url(String path) {
+ return "http://127.0.0.1:" + server.getAddress().getPort() + path;
+ }
+
+ private static void send(HttpExchange ex, int status, String type, byte[] body) throws IOException {
+ ex.getResponseHeaders().set("Content-Type", type);
+ ex.sendResponseHeaders(status, body.length);
+ try (OutputStream os = ex.getResponseBody()) {
+ os.write(body);
+ }
+ }
+
+ public void stop() {
+ server.stop(0);
+ executor.shutdownNow();
+ }
+}
diff --git a/htmlflow-core/src/test/resources/e2e/mfe-events.js b/htmlflow-core/src/test/resources/e2e/mfe-events.js
new file mode 100644
index 00000000..2509a22a
--- /dev/null
+++ b/htmlflow-core/src/test/resources/e2e/mfe-events.js
@@ -0,0 +1,17 @@
+// Wiring for MfeEventsIT. Fragments are sanitized (no