playwright e2e rails actioncable testing hotwire

Testing ActionCable with Playwright across multiple browsers

Published: August 24, 2026 Last updated: August 24, 2026 ~10 min read

Playwright can open two browser contexts in one test. That is how you test ActionCable: two users, one Rails server, a broadcast that has to land in the other tab without a refresh.

“Multi-browser testing” usually means Chromium, Firefox, WebKit. Same spec, three engines, a screenshot diff. That is the wrong matrix for a streaming UI. The bug is not “Firefox paints this button 2px off.” The bug is “the other tab never heard the event.”

I hit this on Shamira, the festival incident platform I’m building: two operators, one event, ActionCable in the middle. The same setup applies to any Hotwire app where the product is the other person’s screen updating.

The suite is Chromium-only. The work is not the engine matrix. It is a real ActionCable adapter (the test adapter drops the other tab), a wait for the subscription before you mutate, and one isolated world per Playwright worker.

The other tab is the contract

Model and controller tests cannot tell you whether an ActionCable broadcast that left the server actually landed in someone else’s tab. A request/response test sees the writer. It is structurally blind to the reader.

A streaming spec is named as a workflow, not as a component:

test("new record appears exactly once for both connected users", async ({ browser }) => {
  const alice = await signIn(browser, "alice");
  const bob = await signIn(browser, "bob");

  try {
    await openSharedView(alice);
    await openSharedView(bob);

    const text = `realtime ${Date.now()}`;
    await createRecord(alice.page, text);

    await expectVisibleOnce(alice.page, text);
    await expectVisibleOnce(bob.page, text);
  } finally {
    await alice.context.close();
    await bob.context.close();
  }
});

Two sign-ins. Two open views. One create. Two assertions that the row exists once. A double-render from a duplicate stream is as wrong as a missing one.

The same shape covers a read-only observer: the update still has to appear, the write controls still have to be absent. Or an unauthenticated kiosk next to a signed-in admin. Still one engine. Different cookies, or none.

Playwright contexts, not Chrome vs Firefox

Playwright’s browser fixture is one Chromium process. Do not share its default page. Give each user a new context — a clean profile with its own cookies, local storage, and viewport:

const api = await request.newContext({ baseURL });
await api.post("/__test__/session", { data: { user: "alice" } });
const storageState = await api.storageState();
await api.dispose();

const context = await browser.newContext({ storageState });
const page = await context.newPage();

Two contexts is two users. Three contexts is an admin, a second operator, and a public token page. The engine is still Chromium.

projects: [
  { name: "chromium", use: { browserName: "chromium" } },
],

Cross-engine coverage multiplies cost without testing the thing that actually breaks: two ActionCable subscriptions on one server. Add Firefox later if you have a known engine bug. Do not start there.

A test-only sign-in endpoint is worth it. Drive the real login form in one spec so auth stays covered. Every other spec should skip Devise/session ceremony and start at the product workflow. Gate that endpoint on the test environment, or on an explicit flag plus a local request. Do not ship it to production.

The ActionCable test adapter drops broadcasts

This is the dual-browser gotcha that wastes real time.

Rails’ ActionCable test adapter does not deliver broadcasts across two browser connections. Other “test” websocket layers have the same shape. A spec can mutate in browser A, get a 2xx, assert on browser A (the HTTP response painted the row), and then time out waiting for browser B. The server “worked.” The other tab never heard it.

Boot the app the Playwright suite talks to with the same adapter production uses. For Rails that means Solid Cable (or Redis) and a real cable database, not adapter: test. The dual-browser specs are the reason that process is configured differently from rails test.

If you take one thing from this post: do not trust an ActionCable E2E suite that still uses the test adapter.

Wait for the ActionCable subscription, not the clock

ActionCable does not replay. If browser B’s subscription is not confirmed when A broadcasts, the update is gone. Sleeping longer does not bring it back. networkidle never settles against a persistent websocket. waitForTimeout papers over the race until CI is slower than your sleep.

Wait on a subscription-confirmed signal, then mutate. That is the deflake that engine matrices will never give you.

For turbo_stream_from, turbo-rails already sets connected on <turbo-cable-stream-source> when the server confirms the subscription. No test-only flag required:

await expect(panel.locator("turbo-cable-stream-source[connected]")).toHaveCount(1);
await createRecord(alice.page, text);

Scope the locator to the panel. Dashboards host several stream sources. Use toHaveCount rather than toBeVisible: the element has no box.

Custom channels need the same signal. Set a data attribute (or equivalent) in the ActionCable connected callback, wait for it, throw on timeout. Reloads are worse: the old connected element is still in the DOM while the new one is spinning up. Arm those waits before the action, with a generation marker, so the waiter can only succeed on a newer element.

Ban the obvious flake sources in the suite: waitForTimeout, networkidle, hover-driven menus. Hover chains drop the moment the pointer jitters. Prefer :focus-within (or an explicit open) and click. reducedMotion: "reduce" helps only CSS that honors the media query. It is defense in depth, not a substitute for a real readiness signal.

Isolate Playwright workers

Four Playwright workers sharing one Rails test server will trample each other if they share one account, one tenant, or one empty list. Dual-browser specs make that collision obvious: Alice on worker 1 creates a row that Bob on worker 2 was supposed to see appear from a blank log.

Give each worker its own world, keyed off testInfo.parallelIndex: its own user pair, its own tenant/org, its own empty document. If the product is multi-tenant on subdomains, send worker 1 to w1.… and worker 2 to w2.…. Parallelism is then “don’t write to each other’s data.”

Reset that world before the run. Do not prove a fix against a database dirtied by the previous command.

Unique record text (Date.now(), worker index) is the cheap extra. Tests must not depend on execution order.

Assert what the other person sees

Prefer user-visible selectors: roles, labels, visible text. If a selector is painful, the accessible name is probably wrong. Thin helpers for user actions (signIn, createRecord) beat page objects that hide the workflow.

Watch for Playwright APIs that pass through a broken UI. scrollIntoViewIfNeeded will scroll an overflow: hidden container no user can pan. If scrolling is the product, use a real wheel and assert scrollTop moved.

Treat flakes as failures. Playwright’s failOnFlakyTests makes a retry-pass still red, which is what you want: a lost ActionCable broadcast that “sometimes arrives” is a product bug. Local retries at 0; CI can retry once so you get a trace and a video, but the job stays red.

Video is expensive. Each recording context runs an encoder for the whole session, and dual-browser tests double that. Record on retry or behind an explicit flag, not on every green run.

What to put in the second browser

Not every spec needs two contexts. One browser is enough for typing, menus, empty states, and “this page loads.” Use a second context when the assertion is about someone else:

  • the record appears without a refresh
  • it appears exactly once
  • a completion/archive removes it from their filtered view
  • a permission difference holds (they can see it, they cannot edit it)
  • an unauthenticated or tokenized surface writes, and the signed-in surface updates

Folder by that shape if the suite is large enough to get lost in: single-browser/ vs dual-browser/ inside a domain, not a tag you have to remember. The expensive tests are the ones whose failure means two people looking at the same thing and seeing different worlds.

FAQ

Do I need Firefox and WebKit?

Not for ActionCable. Two Playwright contexts on Chromium already exercise two subscriptions, two cookie jars, and one real Cable adapter. Add another engine when you have a known rendering bug. A matrix of engines will not catch a dropped broadcast.

Why did the other tab never update?

Almost always one of three:

  1. The E2E server is still on ActionCable’s test adapter, which does not deliver across browser connections.
  2. Browser B mutated (or A broadcast) before B’s subscription was confirmed. ActionCable does not replay. Wait for turbo-cable-stream-source[connected] or your channel’s connected callback, then mutate.
  3. Parallel Playwright workers shared one tenant or one empty list, so the assertion ran against the wrong world.

If your app has a second tab that is supposed to update, a matrix of engines will not find the bug. A second context will.

Updates & Revision History
  • 24 Aug 2026: Initial publication

Found this helpful? Have feedback?

I'd love to hear if this solved your problem or if you ran into issues. Your feedback helps me improve these guides.