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. + * + * 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: + * + */ +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