← All @molecule/* packages · App templates

@molecule/app-e2e-preview

Provider bond · e2e · App (browser) · v1.0.8 · Apache-2.0

Drives the live IDE preview page as a Playwright-shaped page over a same-origin WebSocket through the dev server — no browser binary in the sandbox

npm install @molecule/app-e2e-preview

npm · Source on GitHub · Implements @molecule/app-e2e

How it works

@molecule/app-e2e-preview is a provider bond on the app (browser) side: it implements the e2e core interface (@molecule/app-e2e) with a concrete vendor or library behind it.

Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.

// e2e/bonds.ts (scaffolded)
import { resolveE2EProviderName, setProvider } from '@molecule/app-e2e'
import { provider as playwright } from '@molecule/app-e2e-playwright'
import { provider as preview } from '@molecule/app-e2e-preview'

setProvider(resolveE2EProviderName() === 'preview' ? preview : playwright)

// vite-preview-bridge-plugin.ts (scaffolded) — the plugin rides the existing molecule plugin
import { molE2EPreviewPlugin } from '@molecule/app-e2e-preview/vite'

// any script — measure the live page without a test runner
import { connectPreview } from '@molecule/app-e2e-preview'
const page = await connectPreview()
await page.setViewportSize({ width: 390, height: 844 })
console.log(
  await page
    .locator('article p')
    .first()
    .evaluate((el) => getComputedStyle(el).fontSize),
)
await page.close()

Works with: @molecule/app-bond, @molecule/app-e2e, @molecule/app-e2e-fixtures-default

Reference

Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit src/index.ts JSDoc, not this file.

E2E bond that drives the LIVE PREVIEW — the page the molecule.dev IDE is already showing, in the person's own browser — as a Playwright-shaped page. The browser that renders the preview is the browser that runs the test, so the spec exercises exactly the tab the person is watching. (A sandbox also ships its own Chromium; @molecule/app-e2e-playwright is the bond that runs the same specs there with no tab involved, and the one a sandbox picks by default — choose this bond with MOL_E2E_PROVIDER=preview.)

Three small parts:

  1. A Vite plugin ({@link molE2EPreviewPlugin}) every scaffolded app carries. In vite dev and vite preview it serves a tiny page client, injects it into each document, and attaches a WebSocket hub to the dev server. vite build output is untouched.
  2. The page client, which opens a same-origin WebSocket back to that hub (through whatever proxy serves the preview), runs evaluate requests, navigates on request, asks the framing IDE to resize on setViewportSize, and forwards console output, errors and dialogs.
  3. The driver ({@link provider}), which the test runner uses from the same machine as the dev server: it connects to the hub as role=driver (loopback only, with the hub's token) and turns one connected page into @molecule/app-e2e's Playwright-shaped Page.

Quick Start

// e2e/bonds.ts (scaffolded)
import { resolveE2EProviderName, setProvider } from '@molecule/app-e2e'
import { provider as playwright } from '@molecule/app-e2e-playwright'
import { provider as preview } from '@molecule/app-e2e-preview'

setProvider(resolveE2EProviderName() === 'preview' ? preview : playwright)

// vite-preview-bridge-plugin.ts (scaffolded) — the plugin rides the existing molecule plugin
import { molE2EPreviewPlugin } from '@molecule/app-e2e-preview/vite'

// any script — measure the live page without a test runner
import { connectPreview } from '@molecule/app-e2e-preview'
const page = await connectPreview()
await page.setViewportSize({ width: 390, height: 844 })
console.log(
  await page
    .locator('article p')
    .first()
    .evaluate((el) => getComputedStyle(el).fontSize),
)
await page.close()

Type

provider

Installation

npm install @molecule/app-e2e-preview @molecule/app-bond @molecule/app-e2e @molecule/app-e2e-fixtures-default @playwright/test vite ws
npm install -D @types/ws

API

Interfaces

E2EHub

A running hub.

interface E2EHub {
  /** The driver token (also written to the token file once the server listens). */
  readonly token: string
  /** Pages currently connected. */
  pages(): PagePeer[]
  /** Detach from the server and close every socket. */
  close(): void
}

HubEvent

Hub → driver event envelope.

interface HubEvent {
  event: string
  pageId?: string
  [key: string]: unknown
}

MolE2EPreviewPluginOptions

Options for {@link molE2EPreviewPlugin}.

interface MolE2EPreviewPluginOptions {
  /** Turn the plugin off (also `MOL_E2E_PREVIEW_PLUGIN=0`). Default on. */
  enabled?: boolean
}

PagePeer

A page currently connected to the hub.

interface PagePeer {
  id: string
  href: string
  title: string
  hidden: boolean
  framed: boolean
  /** The app's base path as the page reports it ('/blog/'; '/' when none). */
  base: string
  connectedAt: number
  lastSeen: number
}

PreviewConnectOptions

Options for provider.connect() beyond the core's.

interface PreviewConnectOptions extends E2EConnectOptions {
  /** Full hub URL (`ws://127.0.0.1:5173/__mol/e2e`); overrides port discovery. */
  url?: string
  /** Dev-server port to try first (the sandbox serves the preview on 5173). */
  port?: number
  /** Driver token; defaults to `MOL_E2E_TOKEN` or the hub's token file. */
  token?: string
  /** Drive one specific page (an id from `listPages()`); defaults to the most recently seen visible page. */
  pageId?: string
  /** How long to wait for a preview page to be connected before failing, ms. */
  connectTimeout?: number
}

UpgradeCapableServer

The slice of http.Server / http2.Http2SecureServer the hub uses — Vite hands over either, and both emit upgrade with the same arguments.

interface UpgradeCapableServer {
  on(event: 'upgrade', listener: UpgradeListener): unknown
  off(event: 'upgrade', listener: UpgradeListener): unknown
  once(event: 'listening' | 'close', listener: () => void): unknown
  address(): AddressInfo | string | null
  readonly listening: boolean
}

Functions

attachE2EHub(httpServer, options?)

Attach the hub to an HTTP server (Vite's server.httpServer). Safe next to Vite's own HMR upgrade listener: each ignores the other's path.

function attachE2EHub(
  httpServer: UpgradeCapableServer,
  options?: { token?: string; path?: string },
): E2EHub

connectPreview(options?)

Open the live preview as a Playwright-shaped page from any script (node scripts/check.mjs).

function connectPreview(options?: PreviewConnectOptions): Promise<Page>

injectE2EClientTag(html, base?)

Put the client and runtime tags at the top of <head> unless they are already there.

function injectE2EClientTag(html: string, base?: string): string

listPreviewPages(options?)

The preview pages currently connected to the hub.

function listPreviewPages(options?: PreviewConnectOptions): Promise<PagePeer[]>

molE2EPreviewPlugin(options?)

The Vite plugin every scaffolded app carries (through its scaffold-owned preview plugin file). Dev and preview servers get the hub and the client; builds are untouched.

function molE2EPreviewPlugin(options?: MolE2EPreviewPluginOptions): Plugin<any>

tokenFilePath(port)

The hub writes its driver token here so a runner on the same machine can find it.

function tokenFilePath(port: number): string

Constants

E2E_CLIENT_PATH

Path the page client script is served from (dev and vite preview).

const E2E_CLIENT_PATH: '/__mol/e2e-client.js'

E2E_PREVIEW_CLIENT_SCRIPT

The client script's source, ready to serve as text/javascript.

const E2E_PREVIEW_CLIENT_SCRIPT: string

E2E_RUNTIME_PATH

Path the in-page runtime (@molecule/app-e2e-fixtures-default's locator engine) is served from, so it is installed with the document instead of being sent through the hub on the first call after every navigation.

const E2E_RUNTIME_PATH: '/__mol/e2e-runtime.js'

E2E_WS_PATH

WebSocket path the hub listens on (same origin as the previewed page).

const E2E_WS_PATH: '/__mol/e2e'

provider

The bond: setProvider(provider) in your e2e/bonds.ts.

const provider: E2EProvider

Core Interface

Implements @molecule/app-e2e interface.

Bond Wiring

Setup function to register this provider with the core interface:

import { setProvider } from '@molecule/app-e2e'
import { provider } from '@molecule/app-e2e-preview'

export function setupE2ePreview(): void {
  setProvider(provider)
}

Injection Notes

Requirements

Peer dependencies:

  • @molecule/app-bond ^1.0.1
  • @molecule/app-e2e ^1.0.0
  • @molecule/app-e2e-fixtures-default ^1.0.3
  • @playwright/test ^1.40.0
  • vite ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0

Runtime Dependencies

  • @molecule/app-bond

  • @molecule/app-e2e

  • @molecule/app-e2e-fixtures-default

  • @playwright/test

  • vite

  • ws

  • Every page the dev server sends is a page the driver can see. The plugin puts its client into every HTML response — Vite's own index and the HTML an app renders from its own dev middleware alike (a static-site generator's post routes, an SSR handler). A page the preview shows is a page the hub lists; if it is not, the app is bypassing the dev server.

  • Someone must be looking, and the bond fails fast when nobody is. The renderer is a browser tab showing the preview. connect() waits 5 s (connectTimeout) for a page to be attached and then fails naming the cause and the alternative: open the preview in the IDE or in any tab, or run with MOL_E2E_PROVIDER=playwright, which needs no tab. The runner opens a connection per test (in a fresh worker process after each failure), so after one full wait finds no page the driver leaves a marker beside the hub's token file and the next connects fail at once (for a minute, or until the hub lists a page) — a spec file with no tab reports it once, in seconds, instead of hanging once per test. Any connected viewer will do — a desktop tab keeps working while a phone sleeps. The IDE holds a screen wake lock during builds and re-delivers commands when a hidden tab wakes.

  • A background tab is as fast as a foreground one. Browsers throttle a hidden page's timers to once a second (once a minute after a while), so nothing in the page waits on a timer: every call is answered at once and the driver, on its own clock, re-asks while an element is still on its way. The plugin also serves the locator runtime as a script with the document, so the first call after a navigation costs one round trip, not a 60 KB install. Budget one to two seconds per navigation through the sandbox proxy and about a fifth of a second per action; a whole spec file normally finishes in well under a minute.

  • Discovery. The driver tries MOL_E2E_PREVIEW_URL, then ports MOL_E2E_PREVIEW_PORT, 5173 (the sandbox preview port), VITE_PORT, 3000, reading each hub's token from MOL_E2E_TOKEN or the file the hub writes in the OS temp dir. Only loopback drivers with the token are accepted, so a preview URL never becomes a way to run code in someone else's tab.

  • URLs. page.goto('/path') navigates the previewed page relative to ITS origin; an absolute http://localhost:<port>/path is rewritten to the same path there, so specs written for a local base URL run unchanged.

  • Base path. An app served under a base (Vite base: '/blog/') has every URL under it; the page knows its base (the plugin tags the client with it) and a goto outside it fails at once naming the base, instead of landing on the dev server's "did you mean /blog/…" page — which has no app and no client, so nothing there can be driven. The page also announces the base to the IDE that frames it (molecule:base), so the IDE's own navigation stays inside the app.

  • Viewport. page.setViewportSize posts molecule:viewport to the framing IDE, which resizes the frame; in a plain tab the size cannot change and the call throws (the configured project viewport is applied with a warning instead of a failure).

  • What is not here is listed in @molecule/app-e2e's docs — screenshots, network interception, element handles, iframes — each method throws with the alternative; @molecule/app-e2e-playwright runs the same spec with real browsers when you need them.

  • Imported (non-Vite) apps get the hub only if their dev server can host it; today the plugin covers every Vite-served app, which is every molecule scaffold.