Adds /__wrnexus/islands.js (the bootstrap) and the /__wrnexus/island/ prefix (mount runtime, island bundles, shared chunks) to all three serving paths. Dev reuses the browserArtifactPaths registry pattern from pipeline.ts. Prod mirrors the clientModulesDir handler, including its filename allowlist, so island names cannot escape the output directory. The bootstrap is inert without a data-wrn-island marker, so island-free pages still download nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
131 lines
3.6 KiB
TypeScript
131 lines
3.6 KiB
TypeScript
import { afterEach, expect, test } from "bun:test";
|
|
import { Window } from "happy-dom";
|
|
import { act, createElement } from "react";
|
|
import { islandRootCount, mountIslands, unmountIslands } from "../src/island-runtime.ts";
|
|
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
// happy-dom's element types do not structurally match lib.dom's ParentNode;
|
|
// this cast is a test-environment concern, not a runtime one.
|
|
function host(window: Window): ParentNode {
|
|
return window.document.body as unknown as ParentNode;
|
|
}
|
|
|
|
function domWith(html: string) {
|
|
const window = new Window();
|
|
window.document.body.innerHTML = html;
|
|
(globalThis as any).window = window;
|
|
(globalThis as any).document = window.document;
|
|
return window;
|
|
}
|
|
|
|
const loader = async () => ({
|
|
default: (props: { title?: string }) => createElement("span", null, props.title ?? "none"),
|
|
});
|
|
|
|
afterEach(() => {
|
|
const doc = (globalThis as any).document;
|
|
if (!doc) return;
|
|
act(() => {
|
|
unmountIslands(doc);
|
|
});
|
|
});
|
|
|
|
function marker(props = "{}", strategy = "only") {
|
|
return (
|
|
`<div data-wrn-island="Chart" data-wrn-island-strategy="${strategy}"` +
|
|
` data-wrn-island-props='${props}'></div>`
|
|
);
|
|
}
|
|
|
|
test("mounts an island and passes deserialized props", async () => {
|
|
const window = domWith(marker('{"title":"Revenue"}'));
|
|
|
|
await act(async () => {
|
|
await mountIslands(host(window), { loader });
|
|
});
|
|
|
|
expect(window.document.body.textContent).toContain("Revenue");
|
|
expect(islandRootCount()).toBe(1);
|
|
});
|
|
|
|
test("unmounts roots and leaves no leaked roots behind", async () => {
|
|
const window = domWith(marker('{"title":"A"}'));
|
|
|
|
await act(async () => {
|
|
await mountIslands(host(window), { loader });
|
|
});
|
|
expect(islandRootCount()).toBe(1);
|
|
|
|
act(() => {
|
|
unmountIslands(host(window));
|
|
});
|
|
expect(islandRootCount()).toBe(0);
|
|
});
|
|
|
|
test("repeated mount/unmount cycles do not accumulate roots", async () => {
|
|
const window = domWith(marker());
|
|
|
|
for (let i = 0; i < 5; i += 1) {
|
|
await act(async () => {
|
|
await mountIslands(host(window), { loader });
|
|
});
|
|
act(() => {
|
|
unmountIslands(host(window));
|
|
});
|
|
}
|
|
|
|
expect(islandRootCount()).toBe(0);
|
|
});
|
|
|
|
test("does nothing when no island markers are present", async () => {
|
|
const window = domWith(`<p>plain server html</p>`);
|
|
await act(async () => {
|
|
await mountIslands(host(window), { loader });
|
|
});
|
|
expect(islandRootCount()).toBe(0);
|
|
});
|
|
|
|
test("mounting twice does not create a second root for the same element", async () => {
|
|
const window = domWith(marker());
|
|
|
|
await act(async () => {
|
|
await mountIslands(host(window), { loader });
|
|
await mountIslands(host(window), { loader });
|
|
});
|
|
|
|
expect(islandRootCount()).toBe(1);
|
|
});
|
|
|
|
test("a failing bundle load leaves the placeholder and mounts no root", async () => {
|
|
const window = domWith(marker());
|
|
const original = console.error;
|
|
console.error = () => {};
|
|
|
|
await act(async () => {
|
|
await mountIslands(host(window), {
|
|
loader: async () => {
|
|
throw new Error("network down");
|
|
},
|
|
});
|
|
});
|
|
console.error = original;
|
|
|
|
expect(islandRootCount()).toBe(0);
|
|
expect(window.document.querySelector("[data-wrn-island]")).not.toBeNull();
|
|
});
|
|
|
|
test("malformed props JSON falls back to empty props instead of throwing", async () => {
|
|
const window = domWith(marker("not-json"));
|
|
const original = console.error;
|
|
console.error = () => {};
|
|
|
|
await act(async () => {
|
|
await mountIslands(host(window), { loader });
|
|
});
|
|
console.error = original;
|
|
|
|
expect(window.document.body.textContent).toContain("none");
|
|
expect(islandRootCount()).toBe(1);
|
|
});
|