import { FabricBytes } from "@commonfabric/data-model/fabric-primitives";
import "../src/common-iframe-sandbox.ts";
import {
assert,
assertDeepEquals,
assertEquals,
cleanupFixtures,
ContextShim,
deepEquals,
render,
waitForContextValue,
} from "./utils.ts";
// Each guest document is one module script: this prolog, the test's own body,
// and `GUEST_EPILOG`. The prolog names the guest API's operations as the body
// uses them, so a body reads as the guest code it is.
const GUEST_PROLOG = ``;
Deno.test("pulls and sets", async () => {
cleanupFixtures();
try {
const context = new ContextShim({ a: 1 });
const body = `${GUEST_PROLOG}
onUpdate = (key, value) => {
if (key === "a" && value === 1) {
set(key, value + 1);
}
};
pull('a');
${GUEST_EPILOG}`;
const iframe = await render(body, context);
await waitForContextValue(context, iframe, "a", (value) => value === 2);
} finally {
cleanupFixtures();
}
});
Deno.test("subscribes", async () => {
cleanupFixtures();
try {
const context = new ContextShim({ a: 1 }, [
"barrier",
"barrier-seen",
"ready",
"unsubscribed",
"updates",
]);
// "barrier" stays subscribed after "a" is unsubscribed, so a write to it
// can be used to mark a point in the update stream. It reports arrivals
// under its own key to leave `updates` holding only what the test asserts
// on.
const body = `${GUEST_PROLOG}
const updates = [];
onUpdate = (key, value) => {
if (key === "barrier") {
set("barrier-seen", value);
return;
}
updates.push([key, value]);
set("updates", updates);
if (key === "a" && value === 3) {
unsink("a");
set("unsubscribed", true);
}
};
sink("a");
sink("barrier");
set("ready", true);
${GUEST_EPILOG}`;
const iframe = await render(body, context);
await waitForContextValue(
context,
iframe,
"ready",
(value) => value === true,
);
context.set(iframe, "b", 1);
context.set(iframe, "a", 2);
context.set(iframe, "a", 3);
context.set(iframe, "b", 2);
await waitForContextValue(
context,
iframe,
"updates",
(value) =>
deepEquals(value, [
["a", undefined],
["a", 1],
["a", 2],
["a", 3],
]),
);
await waitForContextValue(
context,
iframe,
"unsubscribed",
(value) => value === true,
);
// Writes to "a" after the unsubscribe must not reach the guest. Write to
// the still-subscribed "barrier" afterwards and wait for the guest to
// report it: messages are delivered in order, so once the barrier has been
// seen, an "a" update would already have arrived had one been sent.
context.set(iframe, "a", 4);
context.set(iframe, "a", 5);
context.set(iframe, "barrier", 1);
await waitForContextValue(
context,
iframe,
"barrier-seen",
(value) => value === 1,
);
assertDeepEquals(context.get(iframe, "updates"), [
["a", undefined],
["a", 1],
["a", 2],
["a", 3],
]);
} finally {
cleanupFixtures();
}
});
Deno.test("handles multiple iframes", async () => {
cleanupFixtures();
try {
const context1 = new ContextShim({ a: 1 }, ["b"]);
const context2 = new ContextShim({ b: 100 }, ["a"]);
const body1 = `${GUEST_PROLOG}
set("b", 1);
${GUEST_EPILOG}`;
const body2 = `${GUEST_PROLOG}
onUpdate = (key, value) => {
if (key === "b" && value === 100) {
set("a", 200);
}
};
pull("b");
${GUEST_EPILOG}`;
const iframe1 = await render(body1, context1);
const iframe2 = await render(body2, context2);
// Each frame writes one key: iframe1 writes "b" into context1, and iframe2
// answers its read of "b" by writing "a" into context2. Waiting for both
// writes puts each frame past the point where a write to the wrong context
// would have happened, so the untouched keys can then be checked for the
// value they started with.
await waitForContextValue(context1, iframe1, "b", (value) => value === 1);
await waitForContextValue(context2, iframe2, "a", (value) => value === 200);
assertEquals(context1.get(iframe1, "a"), 1);
assertEquals(context2.get(iframe2, "b"), 100);
} finally {
cleanupFixtures();
}
});
Deno.test("handles loading new documents", async () => {
cleanupFixtures();
try {
const context = new ContextShim({ a: 1 }, ["b", "c"]);
const body1 = `${GUEST_PROLOG}
set("b", 1);
${GUEST_EPILOG}`;
const body2 = `${GUEST_PROLOG}
set("c", 1);
${GUEST_EPILOG}`;
const iframe = await render(body1, context);
await waitForContextValue(context, iframe, "b", (value) => value === 1);
iframe.src = body2;
await waitForContextValue(context, iframe, "c", (value) => value === 1);
} finally {
cleanupFixtures();
}
});
Deno.test("loads the last document asked for when a second is asked for before the first has loaded", async () => {
cleanupFixtures();
try {
const context = new ContextShim({}, ["ran"]);
const body = (label: string) =>
`${GUEST_PROLOG}
set("ran", ${JSON.stringify(label)});
${GUEST_EPILOG}`;
const iframe = await render(body("first"), context);
await waitForContextValue(
context,
iframe,
"ran",
(value) => value === "first",
);
// Two documents asked for in one turn, so the second request reaches the
// outer frame while the first is still loading. Whichever document loads
// gets the port and writes its label, so the wait accepts either label
// and the assertion names the right one, which fails a wrong document at
// once rather than by the harness timeout.
iframe.src = body("second");
iframe.src = body("third");
await waitForContextValue(
context,
iframe,
"ran",
(value) => value === "second" || value === "third",
);
assertEquals(context.get(iframe, "ran"), "third");
} finally {
cleanupFixtures();
}
});
Deno.test("cancels subscriptions between documents", async () => {
cleanupFixtures();
try {
const context = new ContextShim({ a: 1 }, [
"b",
"got-a-update",
"got-b-update",
"ready1",
"ready2",
]);
const body1 = `${GUEST_PROLOG}
sink("a");
set("ready1", true);
${GUEST_EPILOG}`;
const body2 = `${GUEST_PROLOG}
onUpdate = (key, value) => {
if (key === "b") {
set("got-b-update", true);
}
if (key === "a") {
set("got-a-update", true);
}
};
sink("b");
set("ready2", true);
${GUEST_EPILOG}`;
const iframe = await render(body1, context);
await waitForContextValue(
context,
iframe,
"ready1",
(value) => value === true,
);
iframe.src = body2;
await waitForContextValue(
context,
iframe,
"ready2",
(value) => value === true,
);
// "a" is written first, so by the time the guest reports the "b" update it
// subscribed to, an "a" update from the previous document's subscription
// would already have arrived had it survived the load.
context.set(iframe, "a", 1000);
context.set(iframe, "b", 1000);
await waitForContextValue(
context,
iframe,
"got-b-update",
(value) => value === true,
);
assertEquals(context.get(iframe, "got-a-update"), undefined);
} finally {
cleanupFixtures();
}
});
Deno.test("what a guest posts outside its port raises an alarm, not a write", async () => {
cleanupFixtures();
try {
const context = new ContextShim({}, ["after"]);
const errors: string[] = [];
const onError = (event: Event) =>
errors.push((event as CustomEvent).detail.description);
document.addEventListener("common-iframe-error", onError);
// The first post is a protocol message sent the way a guest reaches its
// parent rather than over its port. Nothing routes it there any more, so
// it cannot become a write; it is an alarm whose contents are not an
// error, and it is dropped. The second is the alarm a guest with no
// working port has, which is the whole reason that route still exists.
// The third is an ordinary write, and it lands last.
const body = `${GUEST_PROLOG}
parent.postMessage({ type: "write", data: ["relayed", 1] }, "*");
parent.postMessage({ type: "error", data: {
description: "raised without a port",
source: "", lineno: 0, colno: 0, stacktrace: "",
} }, "*");
set("after", 1);
${GUEST_EPILOG}`;
const iframe = await render(body, context);
try {
await waitForContextValue(
context,
iframe,
"after",
(value) => value === 1,
);
assertEquals(context.get(iframe, "relayed"), undefined);
assertDeepEquals(errors, ["raised without a port"]);
} finally {
document.removeEventListener("common-iframe-error", onError);
}
} finally {
cleanupFixtures();
}
});
Deno.test("an alarm raised before a write is dispatched before the write lands", async () => {
cleanupFixtures();
// The alarm route and the port are separate channels. An alarm crosses two
// window hops -- guest to outer frame, outer frame to host -- while port
// traffic reaches the host directly, and no task ordering holds one channel
// behind the other. The guarantee under test is the rendezvous that closes
// that race: a guest taking its port posts a flush marker up the parent
// chain, behind everything it posted there before, and holds its port
// traffic until the host answers -- so by the time any port operation
// lands, every earlier parent post has been handled. This test drives the
// schedule the rendezvous exists for: every relayed guest message is
// withheld, standing for a relay running behind the port, and released in
// arrival order once the third arrives. The third is the marker, which is
// the last thing the relay carries before port traffic can land.
const held: MessageEvent[] = [];
let releasing = false;
const release = () => {
releasing = true;
for (const event of held.splice(0)) {
globalThis.dispatchEvent(
new MessageEvent("message", {
data: event.data,
source: event.source,
origin: event.origin,
}),
);
}
};
const withhold = (event: MessageEvent) => {
if (releasing) return;
const data = event.data as { type?: unknown } | null;
if (!data || data.type !== "guest-error") return;
event.stopImmediatePropagation();
held.push(event);
if (held.length >= 3) release();
};
// Registered before the element is, so it hears each message first and can
// withhold it from the element.
globalThis.addEventListener("message", withhold);
try {
const context = new ContextShim({}, ["after"]);
const errors: string[] = [];
const onError = (event: Event) =>
errors.push((event as CustomEvent).detail.description);
document.addEventListener("common-iframe-error", onError);
try {
// The same three posts as the test above: a dropped protocol message, an
// alarm, a write. Here the write is only allowed to land after the
// withheld relay is released, and the alarm must already have been
// dispatched when it does.
const body = `${GUEST_PROLOG}
parent.postMessage({ type: "write", data: ["relayed", 1] }, "*");
parent.postMessage({ type: "error", data: {
description: "raised without a port",
source: "", lineno: 0, colno: 0, stacktrace: "",
} }, "*");
set("after", 1);
${GUEST_EPILOG}`;
const iframe = await render(body, context);
await waitForContextValue(
context,
iframe,
"after",
(value) => value === 1,
);
assertEquals(context.get(iframe, "relayed"), undefined);
assertDeepEquals(errors, ["raised without a port"]);
} finally {
document.removeEventListener("common-iframe-error", onError);
}
} finally {
globalThis.removeEventListener("message", withhold);
cleanupFixtures();
}
});
Deno.test("a reattached element loads its document into the frame it gets", async () => {
cleanupFixtures();
try {
const context = new ContextShim({}, ["ran"]);
const body = `${GUEST_PROLOG}
set("ran", 1);
${GUEST_EPILOG}`;
const iframe = await render(body, context);
await waitForContextValue(context, iframe, "ran", (value) => value === 1);
// Detaching destroys the frame the element rendered, and reattaching gets
// it a new one -- which reports itself ready, as the first one did. The
// teardown lands on a later task than the removal, so the two have to be
// told apart by a turn of the event loop rather than by adjacency.
const parent = iframe.parentElement!;
context.set(iframe, "ran", undefined);
parent.remove();
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
document.body.appendChild(parent);
// The guest in the new frame runs the same document, so its write is what
// says the element found its way back rather than going quietly mute.
await waitForContextValue(context, iframe, "ran", (value) => value === 1);
} finally {
cleanupFixtures();
}
});
Deno.test("same-task reparenting keeps the bridge host alive", async () => {
cleanupFixtures();
try {
const context = new ContextShim({}, ["ready", "seen", "value"]);
const body = `${GUEST_PROLOG}
onUpdate = (key, value) => {
if (key === "value") set("seen", value);
};
sink("value");
set("ready", true);
${GUEST_EPILOG}`;
const iframe = await render(body, context);
await waitForContextValue(
context,
iframe,
"ready",
(value) => value === true,
);
assertEquals(context.callbacks.length, 1);
const newParent = document.createElement("div");
iframe.parentElement!.append(newParent);
newParent.append(iframe);
await Promise.resolve();
assertEquals(context.callbacks.length, 1);
} finally {
cleanupFixtures();
}
});
Deno.test("a second ready from the frame already in hand is refused", async () => {
cleanupFixtures();
try {
const context = new ContextShim({}, ["ran"]);
const body = `${GUEST_PROLOG}
set("ran", 1);
${GUEST_EPILOG}`;
const iframe = await render(body, context);
await waitForContextValue(context, iframe, "ran", (value) => value === 1);
// Reached into rather than driven, because the outer frame reports itself
// ready once and nothing outside it can send that message: the element
// takes it only from the window it rendered. So the refusal is asserted
// where it is made.
const inner = iframe.accessForTestingOnly;
const reporting = inner.iframeRef.value!.contentWindow!;
assertEquals(inner.readyWindow, reporting);
let refusal = "";
try {
inner.onOuterReady(reporting);
} catch (error) {
refusal = String(error);
}
assert(refusal.includes("Already initialized"));
} finally {
cleanupFixtures();
}
});
Deno.test("a repeated load report does not unseat the guest holding its port", async () => {
cleanupFixtures();
try {
const context = new ContextShim({ watched: 1 }, ["ready", "echo"]);
const body = `${GUEST_PROLOG}
onUpdate = (key, value) => {
if (key === "watched" && value === 2) set("echo", 2);
};
sink("watched");
set("ready", true);
${GUEST_EPILOG}`;
const iframe = await render(body, context);
await waitForContextValue(
context,
iframe,
"ready",
(value) => value === true,
);
assertEquals(context.callbacks.length, 1);
// The outer frame reports a load per completed navigation of its inner
// frame, and it cannot tell whose navigation a load event was: the
// initial `about:blank` one can complete after a document was asked for,
// in which case one asked-for document yields two reports. The element
// offers a fresh port on each report, and a session is retired only once
// a successor shows activity -- so a guest refusing the extra offer keeps
// the session it has, subscriptions included. The repeat is synthesized,
// as the real one turns on navigation timing inside the frame.
const inner = iframe.accessForTestingOnly;
globalThis.dispatchEvent(
new MessageEvent("message", {
data: { type: "load" },
source: inner.iframeRef.value!.contentWindow!,
origin: "null",
}),
);
assertEquals(context.callbacks.length, 1);
// The subscription staying registered is half of it; the guest writing
// through the port it kept is the other half.
context.set(iframe, "watched", 2);
await waitForContextValue(
context,
iframe,
"echo",
(value) => value === 2,
);
} finally {
cleanupFixtures();
}
});
Deno.test("a guest that starts listening after its handoff went by still gets a port", async () => {
cleanupFixtures();
try {
// The host hands a port over once the guest's document has loaded, and
// module scripts can still be running then. This guest makes that
// certain: a classic script, which runs while the document is parsed,
// waits for the handoff to go by, and only then does the guest connect.
const context = new ContextShim({ a: 1 });
const body = `
`;
const iframe = await render(body, context);
await waitForContextValue(context, iframe, "a", (value) => value === 2);
} finally {
cleanupFixtures();
}
});
Deno.test("an element that gets a frame and has no source says nothing is loaded", async () => {
cleanupFixtures();
try {
const context = new ContextShim({}, ["ran"]);
const body = `${GUEST_PROLOG}
set("ran", 1);
${GUEST_EPILOG}`;
const iframe = await render(body, context);
await waitForContextValue(context, iframe, "ran", (value) => value === 1);
// Driven rather than staged, for the reason the refusal test gives: a
// frame reports itself ready once, and the report cannot be sent from
// anywhere else. A window this element has not seen stands for the frame a
// reattach would bring.
const inner = iframe.accessForTestingOnly;
iframe.src = "";
inner.onOuterReady({} as Window);
assertEquals(iframe.loadState, "");
} finally {
cleanupFixtures();
}
});
Deno.test("a subscription is cancelled against the bridge that issued it", async () => {
cleanupFixtures();
try {
const first = new ContextShim({ watched: 1 }, ["ready"]);
const body = `${GUEST_PROLOG}
sink("watched");
set("ready", true);
${GUEST_EPILOG}`;
const iframe = await render(body, first);
await waitForContextValue(
first,
iframe,
"ready",
(value) => value === true,
);
assertEquals(first.callbacks.length, 1);
// A subscription belongs to one bridge session, and `bridge` is a property
// a consumer may reassign. Swapping it here and then asking for a new
// document distinguishes cancelling the old session from touching the new
// bridge.
const second = new ContextShim({}, ["second-ran"]);
iframe.bridge = second.bridge;
iframe.src = `${GUEST_PROLOG}
set("second-ran", true);
${GUEST_EPILOG}`;
await waitForContextValue(
second,
iframe,
"second-ran",
(value) => value === true,
);
assertEquals(first.callbacks.length, 0);
} finally {
cleanupFixtures();
}
});
Deno.test("carries a value structured cloning would flatten", async () => {
cleanupFixtures();
try {
const context = new ContextShim({
payload: new FabricBytes(new Uint8Array([1, 2, 3])),
}, ["bytes-seen", "echo"]);
// The guest reports the bytes it can read out of what arrived, and echoes
// the value back. Reading them takes a live `FabricBytes`, which is what
// separates a value that crossed whole from one structured cloning
// stripped to a bare object; the echo asks the same of the other
// direction. The report names the flattened case rather than throwing on
// it, so that case fails an assertion instead of going silent.
const body = `${GUEST_PROLOG}
onUpdate = (key, value) => {
if (key !== "payload") return;
set("bytes-seen", typeof value?.slice === "function"
? [...value.slice()]
: "not a FabricBytes");
set("echo", value);
};
pull("payload");
${GUEST_EPILOG}`;
const iframe = await render(body, context);
// `echo` is written last and writes arrive in order, so waiting on its
// arrival puts both reports in hand. Waiting on arrival rather than on the
// class leaves a value that crossed flattened to fail an assertion below
// rather than never satisfy the wait.
await waitForContextValue(
context,
iframe,
"echo",
(value) => value !== undefined,
);
assertDeepEquals(context.get(iframe, "bytes-seen"), [1, 2, 3]);
assert(context.get(iframe, "echo") instanceof FabricBytes);
} finally {
cleanupFixtures();
}
});