Skip to content

How Dyno runs a test

Node manages processes, bundles, and output. The browser executes tests and Interactor actions. Managed runs use the included Playwright Core. Manual runs only need the URL.

bin/dyno.js owns process signals, exit codes, and terminal streams. cli.js builds a fresh Commander command tree for parsing, validation, and generated help. Its output and exit hooks return control to Dyno; only immutable options or help/version results leave the parser. commands.js dispatches those options; cli-run.js owns a test session’s resources and reverse-order cleanup. cli-output.js formats human output and JSON events. Project console output goes to stderr.

watch-session.js coordinates the filesystem watcher and immutable watch transitions. It serializes builds, retains queued edits, and waits for an active build during shutdown. Cancelled builds don’t emit spurious failures; reload failures explain how to reconnect the browser.

Every command shares cancellation, including browser installation. Shutdown cancels execution before draining cleanup and finalizing session timing. Browser-result deadlines also cancel managed browser opening. There is one command path for each operation.

The exported Interactor and each test’s I use the same app-aware class. Its public Interactor extension points supply app-realm events/files, form replacement, rendered assertions, and dynamic scopes. Scopes create independent contexts with the same assertion settings and signal; they resolve their roots again after navigation. Interactor.js still owns selectors, chaining, retries, and stability checks. Apps keep domain page objects and fixture I/O.

defineInteractor({ actions, assertions }) registers actions and assertions through Interactor’s public API on a private subclass with its own assertion class. Its returned function binds that definition to an existing Interactor and optional selector. Definitions never register methods on I or siblings. Binding and scope() share one context-copy path; neither caches app elements. The small subclass keeps registrations independent.

Original app window
└─ reporter iframe: inspection overlay
Persistent companion window: controller
└─ hidden execution iframe: fresh test runtime
└─ direct access to the app window's current document
Node gateway → Playwright worker → original app window and screenshots

The start URL, /__dyno__/, opens a named companion before loading the app in the original window. Managed launch activates Dyno’s Start tests button with a trusted browser gesture and waits for the companion to open. Manual URLs open it automatically when allowed, with the same button available if popups are blocked. The companion keeps its reference to the app through opener, so app navigation preserves test execution. Both windows must stay open.

The gateway forwards app requests and WebSocket upgrades to the app server. Its startup HTTP readiness check reads the app’s opener/embedder isolation headers; Dyno applies those headers to its own documents before a browser joins. App response headers stay intact. It inserts a small bridge for errors, document readiness, and the reporter overlay. Test code stays in the companion’s execution iframe. See the guide for cookies, redirects, and CSP behavior.

The bundled entry loads test modules sequentially through the registration boundary. Each file gets its own registration boundary, including when module evaluation awaits an import. Tests and hooks must belong to a named describe; file-level declarations fail. describe callbacks register synchronously.

suite.js builds immutable trees and compiles inherited hooks, focus/skip decisions, and stable file/suite/name IDs into an execution plan. Only serializable test descriptors, including declared resources, cross into the host and gateway; callbacks remain in the test runtime. Named suite paths and hook scopes are explicit in the plan.

run.js owns one immutable catalog, priority, workers, assignments and accepted outcomes. Hosts request an execution ID before starting a test; outcomes must match that worker, execution and stable test ID. Results can arrive out of order while reports retain catalog order. Every worker must register the same catalog before dispatch. The CLI uses one coordinator for one or many workers. Each worker has a private browser context, gateway and project resources.

Resource names belong to test descriptors. Dispatch reserves the complete set together, and an accepted result does not release it. Before taking another assignment, the host removes the old runtime, replaces the app document with a small parked document, then waits for project draining. The persistent controller and project-owned storage survive. A failed or timed-out project drain stops new assignments without changing accepted results. The final assignment stays marked as retained, so reports do not claim its document was released. The final app can stay available for inspection; terminal cleanup still drains its project work before saving.

execution.js contains pure transitions over immutable state. shell.js applies DOM and network effects. Window and frame handles, promises, and abort controllers stay outside that state.

The app owns its full, unscaled browser viewport. Manual runs use its actual dimensions; managed runs can resize it. Reporter routes never move the app. Inspection opens an overlay at a test boundary. Continue hides it before the next test starts; pausing doesn’t freeze the app’s timers or network activity.

Before each test, default sizing runs only when configured, actual, or observed geometry differs. Public sizing calls retain their validation and revision changes. Run-specific bundle bytes can be cached; every execution still registers the complete suite in fresh globals.

The reporter owns its route, search, and filter. Its iframe is created on first inspection and retained on close, preserving its route. Closed reporters do not render snapshots; reopening receives current state through the existing ready and presentation messages. Command acknowledgements still arrive while closed. Separate reporter URLs receive server events and send HTTP commands. Both use the same UI.

Each worker publishes changed fields and new output through one ordered publisher. The coordinator merges progress by owned assignment, while accepted outcomes remain the final result. Embedded and standalone reporters consume the same bounded event stream; hidden embedded reporters disconnect and reopen with a fresh snapshot. Reporter commands await acknowledgements in their HTTP response, so closing the overlay cannot lose a pending reply.

The gateway validates each whole change before applying it. Accepted events own final test data; progress cannot finish a run or overwrite an accepted outcome. Fatal results retain unfinished console output without pretending the test completed. Pure reporting helpers create immutable views from those sources.

Standalone observers receive sequenced batches of changed test records. They commit a batch only after its final marker; a reconnect starts a new snapshot. Slow readers retain one in-flight batch and one latest view, with backpressure between records. Each encoded record is limited to 8 MB, with a shared 32 MB reservation for its string and encoded buffer. Object/transport overhead is separate. Command queues and connection counts are bounded too. Exceeding a delivery limit closes that reporter with an explanation; results and saved reports continue independently.

Rendering coalesces through a message turn, so a suspended background animation clock cannot stall the reporter. Route, command and terminal changes render immediately.

A private window.name binds the original app window across navigation and survives project calls to sessionStorage.clear(). Names route the bootstrap; they do not authenticate messages. Origin, exact source window, run, execution, and current document are checked before accepting messages or operation results. Old unload messages cannot invalidate a newer document.

Test frames and the app have separate documents and globals. They share an origin and storage, so this is not a security boundary against hostile code.

Execution and cleanup have separate abort signals. Finishing execution cancels its app APIs and pending capture requests. Only entered scopes clean up; ordinary hook failures are collected while later hooks continue within one cleanup budget. The result keeps the first failure and phase-tagged additional errors. The host gives the runtime one second to deliver that result after both budgets expire, then removes an unresponsive frame.

Reporter Stop asks the runtime to cancel and finish bounded cleanup. App loss interrupts execution and cancels pending operations. Closing or reloading the companion destroys its test JavaScript; it cannot resume that execution. Start a new run with a fresh binding. Shared backend resources still belong in Node setup teardown.

Unload messages are best effort. The gateway connection and bounded run deadlines also detect lost ownership; checking window.closed alone is insufficient.

browser-session.js owns one Playwright browser server and a bounded pool of private contexts. Each context exposes open, activate, resize, capture, retire, stop, identity and capabilities. One IPC process connects through the SDK and keeps a separate operation queue per context, so a waiting operation cannot block another worker. The CLI sizes the pool from the managed worker count; the coordinator assigns tests to those private contexts.

The parent owns the public SDK server handle, including forced shutdown after IPC failure. Contexts close before that server. The SDK owns browser discovery, channels, downloads, temporary profiles and browser process cleanup. Dyno owns operation deadlines and signal handling. There is no process-name discovery, native launcher or WebDriver path.

Retirement closes companion/test pages and navigates the original app tab to the gateway blank document. Keeping that tab preserves session storage along with origin cookies, local storage and IndexedDB. Failed retirement or native restoration closes the pool before reuse. This stops document JavaScript; service workers and backend work still need project-owned draining. Interactive inspection must finish before retirement releases an execution.

Each managed worker has a private launch token and joins the group’s shared run. Manual visitors get independent runs and can’t use another run’s controller. Reruns replace the old run and notify its observers.

The worker retains the original app page. The companion becoming active cannot redirect capture or sizing to the wrong window.

app.setViewport changes the actual app viewport through Playwright. Manual calls succeed only when the requested size already matches. A configured fixed size must also match; Dyno never simulates it with a larger iframe.

doctor validates config, bundling, browser prerequisites, and the app URL without acquiring processes. Test watches use filesystem events for the test directory and imported project helpers. Immutable watch state tracks builds and browser runs independently. Edits during either stay queued; the next run gets the latest source. Config and Node setup changes require restart.

app.pause() uses a browser message, without a driver. It suspends the execution deadline and host watchdog while retaining the test and app. Continue hides the reporter before resuming the remaining budget. Cleanup cannot pause, and headless runs reject the call.

The app stays in the top-level window because nested iframe captures can have the right image dimensions while clipping content below the iframe viewport. Enlarging the iframe changes the layout under test. Capturing the original page preserves its viewport and real browser rendering; capture tests check offscreen pixels as well as dimensions.

  1. The provider activates its owned app tab; the runtime prepares animations and caret state, waits for app fonts and sends execution, document, and request IDs with capture options.
  2. The controller checks ownership, the hidden reporter, viewport revision, dimensions, and pixel ratio.
  3. The gateway captures the original app page, verifies PNG dimensions, and writes a temporary file. Full-page capture keeps the viewport unchanged.
  4. The controller checks the document and geometry again. The gateway then publishes the file and returns attachment metadata.
  5. The test receives the saved attachment. It becomes run output when the gateway accepts that test’s result.

Navigation, resizing, Stop, disposal, or lost ownership invalidates pending captures. Late results and partial files are removed. Interrupted runs also discard saved images that weren’t reported. Reported images remain on disk.

These operations return promises. Finishing a test or hook with unfinished screenshot or viewport work fails clearly. Missing optional capture produces a skipped image; provider and storage errors still fail. A screenshot records a rendered frame during the request. Masks are painted into the PNG. Browser-local preparation finishes finite animations and suspends infinite ones before measuring masks, then restores temporary caret styles and restarts suspended animations. Browser setup lists capture limits.

Chromium’s beyond-viewport capture briefly emulates a 1 × 1 page (upstream issue). For full-page and element captures, the managed provider pauses app JavaScript with a capture-owned debugger marker and captures through Playwright’s CDP session API. It preserves queued callbacks, restores execution before returning pixels, and never resumes a developer’s existing pause. The capture session matches the SDK’s viewport, screen, pixel ratio and mobile orientation; using an unconfigured second session would reset pixel ratio. CSS animation and backend work continue. Failed restoration closes the context instead of reusing a paused page. Document, viewport, execution and cancellation checks still apply.

The real browser workflows in tests cover image pixels, resizing, navigation races, manual/managed ownership, reporter controls, interruption, and cleanup. Internal modules aren’t mocked.

The gateway owns accepted run snapshots. On completion or interruption, a pure report model selects public result fields; formatters produce versioned JSON, JUnit, and standalone HTML. The writer copies accepted images into a temporary run folder and renames it into place after all files finish. Missing source images become explicit unavailable attachments.

Shutdown stops accepting new runs before waiting for pending writes. A join already rebuilding tests checks again before creating a run. The CLI waits for report persistence before releasing the gateway. Saved HTML needs neither a driver nor a running server.

Normal completion, browser replacement and shutdown share terminal cleanup and persistence. Final state becomes visible after the write succeeds. A failed save reports an infrastructure error while preserving accepted outcomes. The gateway keeps a small cache of accepted messages and the last live sequence hash. The browser can repeat one outstanding reporting request after a network failure, without repeating test callbacks, actions or native operations. Changed replays and sequence gaps fail plainly.

The runtime records bounded monotonic intervals around phases, app operations, and named steps. Interactor runs unchanged. Abort ends pending measurements, and immutable snapshots cross the existing result boundary; the gateway validates bounds and drops unknown fields. Inclusive intervals may overlap, so rankings do not add them together.

CLI lifecycle intervals use a separate session clock. Each report saves a running session snapshot; shutdown finalizes session.json atomically after resource cleanup. Browser-run and CLI wall times remain distinct. No per-poll messages or driver commands are added.

Interactor owns clicks, typing, app keyboard handlers, and scrolling through local actions. The managed input surface is limited to real keys for browser defaults and pointer movement for CSS hover. Permissions, media, offline transport, and virtual authenticators use the same runtime message, controller queue, authenticated gateway, and owned worker. Interactor resolves DOM targets locally. No exposed SDK binding, driver selector, or generic evaluation API enters the app.

Requests carry execution and document identity. The worker checks the original page bridge immediately before dispatch. Geometry stays tied to that document; keys may navigate within their execution. Cancellation rejects queued requests and late results, but cannot undo delivered input. The checks detect races rather than making browser input atomic.

The controller resets managed native state after each test. The gateway also restores it on completion, replacement, and shutdown. Cleanup must finish before another execution proceeds; failed restoration ends the session. Locale and time zone are context configuration. Native calls use the existing first-class timing model.

Element screenshots extend the existing capture path with a document-coordinate clip and checked PNG dimensions. They keep the viewport and scroll position unchanged. Fractional edges round outward to include the element’s painted bounds; element capture cannot combine full-page or mask options.

Declared order is the default. An explicit or generated seed ranks stable test IDs with versioned SHA-256 ordering; filtering preserves their relative priority. It does not replace application randomness.

The CLI, live reporter and saved report expose the seed. schedule.json records selected IDs, priority, actual assignments and completion order. Version 2 schedules include the source-content fingerprint and catalog. --replay validates those inputs and restores each recorded worker queue; an interrupted prefix explicitly omits unassigned tests. Replay constrains queue heads even when a later queued test could bypass a resource. It reproduces queues, not asynchronous interleaving.

Managed headless --repeat composes ordinary runs inside one CLI session. repeat.js folds accepted round outcomes into immutable per-test counts; the existing run model still accepts one execution per stable test ID. Each seeded round uses a versioned root-derived seed, with the literal root preserved for round one. Queue replay stays fixed. The coordinator’s common replacement path receives the next seed and reuses the pinned test bundle, worker contexts, servers and project resources.

Automatic repeated executions park their final app document before project draining and release, including when there is no later real test. The next round waits for terminal persistence and the current serialized operation to settle. Each round has its own browser-result deadline; lifecycle and storage failures stop advancement. Ordinary interactive completion keeps the final app inspectable as before. Storage and backend reset remain project-owned.

repeat-reports.js saves compact round outcomes and schedules independently of the coordinator’s 20-run cache. Failed/interrupted and final rounds use the ordinary portable formatter. Successful intermediate captures are removed after evidence persistence. A 256 MiB evidence limit stops further rounds explicitly; accepted counts and aggregate/session metadata remain available outside that budget. The test fingerprint does not identify the running app or backend, and rerunning a batch prefix does not replay asynchronous interleaving.

coordinator.js owns run snapshots, processes results in order, and coordinates assignment waiting, message replays, draining and final saves. run.js contains pure ownership/resource transitions. Private gateways bind requests to their worker ID; posted worker IDs cannot borrow a peer execution. Cross-gateway image reads resolve accepted UUIDs from the same canonical outcomes.

Project setup runs once per CLI session and optionally returns createWorker. Both receive one frozen session identity and immediate cleanup registration. Registration seals when acquisition settles; partial failures use the same cleanup stack as returned teardown. Every started acquisition settles before worker resources or shared parents are destroyed. The factory supplies private server/env/fixtures/beforeExecution/drain/teardown descriptors. Startup joins every worker task before cleanup, including on partial failure. Cleanup finishes the coordinator and gateways before stopping app servers, worker projects and shared setup. Browser shutdown follows.

Managed rerun and watch retire all old documents, drain the previous run, rebuild once and reopen one new group. Manual refresh parks its old app through browser APIs before joining. A missing controller has a bounded reconnection window; a lost managed worker ends the shared run without retrying it. Browser document retirement precedes project draining on failure. Project callbacks must honor cancellation; Dyno cannot force arbitrary Node code to stop.

run-timings.js derives wall, execution, handoff, queue and unassigned intervals from the coordinator clock. Browser intervals keep their own clocks. Saved JSON and live/offline reports distinguish elapsed wall from summed test work; project cleanup is a measured part of handoff.

The coordinator reserves an assignment, then runs its optional beforeExecution outside run serialization. Duplicate assignment requests join one preparation. Its JSON result is bound to that runtime through an exact source/run/execution message handshake; it never changes gateway-wide settings or enters frame URLs. Static and execution fixture keys cannot collide. Preparation consumes the selected test timeout; delivery/replay recomputes its remaining budget. Browser debug pause keeps its existing behavior. Preparation timings share the coordinator clock and appear inside execution duration, separately from cleanup.

Stop, rerun, controller loss and shutdown abort pending preparation before waiting on run queues. A failed preparation prevents worker reuse. The coordinator retains the callback’s actual promise until settlement before resource destruction; arbitrary Node work must honor cancellation. Successful final documents remain available for inspection while app-owned drain closes backend admission. Shutdown explicitly retires retained managed documents before app and project resources close.

The optional dyno/fixtures module composes named operation definitions and a transport-neutral app-process controller. Dependencies are explicit worker-owned handles. Its Node HTTP codec and control client share one bounded JSON protocol outside the gateway’s reserved path. Begin and drain use private control credentials; browser calls use only a fresh execution capability. Reset runs exclusively before opening an execution. Cancellation never implies a write was rolled back, so the controller retains real operation work through drain. Failed or cancelled lifecycle work cannot reopen admission later. Ordinary calls are never retried or redirected.

A parsed framework endpoint can call the same controller directly. Keep its control plane outside the app runtime being paused. Dyno owns no database implementation, queue discovery or required Cricket integration; app admission holds and explicit background-work draining supply those boundaries.