Skip to content

Testing ​

Virtual Frame hinges on real browser primitives — live iframes, MutationObserver, Shadow DOM, postMessage, canvas capture. jsdom and happy-dom don't provide high-fidelity versions of these, so tests that exercise projection need a real browser. This page covers the patterns we use in this project's own test suite, plus guidance for application code that depends on Virtual Frame.

Pick the right environment ​

ToolGood forWhy
Vitest browser mode + PlaywrightUnit / integration tests of code that uses VirtualFrame directlyReal iframe + MutationObserver + Shadow DOM in a scriptable browser
Playwright (end-to-end)Full app tests where you drive the host UI and assert on the projectionCovers routing, SSR resume, real-world timing
jsdom / happy-dom❌ Don't useNo iframe contentDocument, no reliable Shadow DOM, no canvas capture

The core package tests use Vitest browser mode (@vitest/browser + @vitest/browser-playwright) and the repo's cross-package / cross-framework e2e tests use Playwright directly against running example apps. Either is a fine starting point for your own tests.

Unit / integration tests with Vitest browser mode ​

Setup ​

ts
// vitest.config.ts
import { defineConfig } from "vitest/config";
import { playwright } from "@vitest/browser-playwright";

export default defineConfig({
  test: {
    browser: {
      enabled: true,
      provider: playwright(),
      instances: [{ browser: "chromium" }],
      headless: true,
    },
    include: ["test/**/*.test.ts"],
  },
});

Test helpers ​

These are the helpers the core package uses — copy them into your project as a starting point.

ts
// test/helpers.ts
export async function createIframe(fixturePath: string) {
  const url = new URL(`./fixtures/${fixturePath}`, import.meta.url).href;
  const iframe = document.createElement("iframe");
  iframe.style.cssText = "position:fixed;left:-9999px;top:0;width:800px;height:600px;border:none;";
  iframe.src = url;
  document.body.appendChild(iframe);
  await new Promise((r) => iframe.addEventListener("load", r));
  return iframe;
}

export function createHost() {
  const host = document.createElement("div");
  host.style.cssText = "width:800px;height:600px;overflow:auto;";
  document.body.appendChild(host);
  return host;
}

export async function waitForInit(vf: VirtualFrame, timeout = 5000) {
  const start = Date.now();
  while (!vf.isInitialized && Date.now() - start < timeout) {
    await new Promise((r) => setTimeout(r, 50));
  }
  if (!vf.isInitialized) throw new Error("VirtualFrame did not initialise");
}

export function nextFrame() {
  return new Promise((r) => requestAnimationFrame(r));
}

export function cleanupDOM() {
  document.querySelectorAll("iframe").forEach((el) => el.remove());
}

A representative test ​

ts
import { afterEach, expect, it } from "vitest";
import { VirtualFrame } from "virtual-frame";
import { createIframe, createHost, waitForInit, cleanupDOM } from "./helpers";

afterEach(() => cleanupDOM());

it("projects the iframe body into the host shadow root", async () => {
  const iframe = await createIframe("basic.html");
  const host = createHost();

  const vf = new VirtualFrame(iframe, host, { isolate: "open" });
  await waitForInit(vf);

  const root = vf.getShadowRoot()!;
  expect(root.querySelector("h1")?.textContent).toBe("Hello");

  vf.destroy();
});

Patterns worth following ​

  • Wait for isInitialized, not load. The iframe's load event only tells you the source document parsed; projection happens after mutation observation spins up.
  • Always vf.destroy() in cleanup. Otherwise leaked MutationObservers and capture streams pile up across tests and cause flakiness.
  • Assert on the shadow root, not the host's light DOM. With isolate enabled (you should), content lives in the shadow root. Use vf.getShadowRoot() — works for both open and closed modes.
  • Use requestAnimationFrame ticks for animation / streaming. A single nextFrame() after a source mutation is usually enough for the mirror to catch up; if not, poll host.shadowRoot.innerHTML with a timeout rather than adding arbitrary delays.

End-to-end tests with Playwright ​

Use this layer when you want to exercise the full path — host app renders, iframe loads, bridge negotiates, user interacts, projection updates.

ts
import { expect, test } from "@playwright/test";

test("cross-origin dashboard projects and accepts clicks", async ({ page }) => {
  await page.goto("http://localhost:3000");

  // Wait for the virtual-frame element to finish projecting
  const vf = page.locator("virtual-frame");
  await expect(vf).toBeVisible();

  // The projected button lives inside the shadow root — use locator.locator()
  // with a CSS selector; Playwright pierces shadow DOM for open mode by default.
  const button = vf.locator("button.buy");
  await expect(button).toHaveText("Buy");

  await button.click();
  await expect(vf.locator(".cart-badge")).toHaveText("1");
});

Key points for Playwright:

  • Open mode + pierce-by-default works out of the box. vf.locator("button") reaches into host.shadowRoot automatically. For closed mode you need to evaluate against vf.getShadowRoot() from the page context.
  • Run each project on a real dev server. The test harness in this repo starts host and remote in parallel via vite / next dev / etc. and then runs specs against the running servers. Copy that pattern — don't try to inline the remote.
  • Separate dev and prod specs if your SSR / proxy wiring differs between them. The repo's e2e suite does this with --grep "(dev)" / --grep "(prod)" annotations.

Testing cross-origin projection ​

The bridge must run inside the remote origin, and postMessage requires a real cross-origin relationship. Two practical options:

  1. Serve remote and host from different ports. http://localhost:3000 and http://localhost:4000 are cross-origin as far as the browser is concerned. Start both servers from Playwright's webServer config.
  2. Use Playwright's routeFromHAR or route() to intercept outbound requests and return fixture HTML — useful when you want deterministic remote responses without running a second dev server.

Do not mock the bridge

The bridge protocol is small but subtle — it negotiates channel IDs, assigns node IDs lazily, and streams mutation batches. A mock that matches today's protocol will drift. Test against the real bridge running in a real remote document.

Testing SSR ​

For fetchVirtualFrame and renderVirtualFrame, you can test the server-side transform in plain Vitest (node environment) because those helpers only touch strings and fetch:

ts
import { expect, it, vi } from "vitest";
import { renderVirtualFrame } from "virtual-frame/ssr";

it("inlines styles into the declarative shadow template", async () => {
  const html = `<html><head><style>h1{color:red}</style></head><body><h1>Hi</h1></body></html>`;
  const frame = await renderVirtualFrame(html, { url: "https://r.example.com/" });

  expect(frame.html).toContain('<template shadowrootmode="open">');
  expect(frame.styles).toContain("color:red");
});

For the client-side resume path (where the <virtual-frame> element picks up the declarative Shadow DOM and creates the srcdoc iframe), use Playwright — that code path depends on real DOM parsing and about:srcdoc behavior.

Testing framework components ​

The framework packages wrap VirtualFrame with idiomatic bindings, so you can usually use your framework's normal testing setup — @testing-library/react, Vue Test Utils, Svelte testing-library, etc. — as long as you run them in a real browser. In Vitest that means browser mode, same config as above. In Playwright Component Testing, the default browser backend is fine.

Two gotchas:

  • Don't assert synchronously after mount. Most frameworks mount the <virtual-frame> element and then projection happens across a few microtasks. Use findBy… / waitFor / vi.waitFor(() => …) rather than getBy….
  • The store prop connects a MessageChannel. If you want to assert on shared state mid-test, write to the store, await nextFrame(), then read. The store batches writes to the end of the microtask queue.

Reference ​

  • This project's own test suites, for working examples:
    • packages/core/test/ — unit + integration with Vitest browser mode
    • e2e/specs/ — framework-level Playwright specs, one per framework integration
  • Vitest browser mode docs
  • Playwright docs