STATUS: Both implemented. One of them should be removed before 1.0.
Scenetest currently has two TypeScript execution models — Concurrent (scene()) and Classic Driver (test()). A third authoring style, Text DSL (.spec.md markdown files), compiles to the concurrent model.
This document exists so that future-us can make an informed decision about which model to keep. We are NOT shipping two ways to write scenes. We’re holding both in the codebase while we evaluate them against real usage on sunlo.app and other projects. Then we rip one out.
Note: Text DSL markdown files (
.spec.md) compile toscene()registrations. This is relevant to the decision — if we keep the concurrent model, markdown scenes work natively. If we keep the classic driver, the markdown parser would need to be rewritten to emittest()registrations (or we keepscene()as the internal model for markdown scenes only).
For the full side-by-side comparison of both models (syntax, multi-actor patterns, coordination, conditional monitors), see the Concurrent and Classic Mode reference.
scene('two users chat', ({ actor }) => {
const alice = actor('alice')
const bob = actor('bob')
alice.openTo('/chat')
alice.see('input').typeInto('input', 'Hello!').click('send')
bob.openTo('/chat')
bob.seeText('Hello!')
})
async, no await.actor() returns a handle immediately (browser launches later).await ordering.test('two users chat', async ({ actor }) => {
const alice = await actor('alice')
const bob = await actor('bob')
await alice.openTo('/chat')
await alice.see('input').typeInto('input', 'Hello!').click('send')
await bob.openTo('/chat')
await bob.seeText('Hello!')
})
await is an execution boundary.Promise.all().await).In the classic driver model the test writer carries a mental timeline. They
have to think: “has this happened yet? should I await before checking?”
This is exactly the class of bug that waitUntil / waitForSelector /
waitForNavigation APIs exist to paper over in Playwright and Cypress.
In the concurrent model there is no race because each actor’s observations (see,
seeText) naturally block/poll for DOM state. You can write
bob.seeText('Hello!') before or after alice.click('send') in the
source code and it doesn’t matter — bob will reach that instruction
whenever he reaches it in his queue.
The classic driver has two objects: SequentialActorHandle (creates chains) and
ActionChain (accumulates actions, is thenable). You need to understand
that .see('x').click('y') is a chain, that chains execute on await,
that scope resets between chains, and that Promise.all is required for
concurrency.
The concurrent model has one object: the actor. Every method returns the actor. Scope flows through the actor’s queue. Concurrency is the default, not the opt-in.
Scene scripts that jump between actors read like screenplays in the
concurrent model. There is no syntactic noise from await telling
the runtime things it could have figured out itself.
In the classic driver, await looks like it means “do this async thing.” But
the chain methods are synchronous — they just push closures onto an
array. The only async thing is then(), which flushes the queue. The
await is sugar for “flush now,” which is an implementation detail
masquerading as a language feature.
Every Playwright / Cypress / Puppeteer test ever written uses await-based
sequential orchestration. People migrating from those tools will reach
for test() instinctively. Asking them to unlearn await is a cost.
Some scenes genuinely need cross-actor ordering: “alice does X, then bob
does Y in response, then alice does Z.” In the classic driver this is trivially
expressed with sequential awaits. In the concurrent model you need the message bus
(emit / waitFor) or you rely on DOM state being sufficient, which it
sometimes isn’t (e.g. the ordering isn’t observable in the UI).
When something fails in classic driver mode, the stack trace points to a
specific await in a linear script. In the concurrent model, multiple actors
are running concurrently, failures trigger abort cascades, and the
original error might be obscured by “actor X aborted: actor Y failed.”
With the classic driver you can put a breakpoint on any await and inspect state
between steps. With the concurrent model, the declaration phase is instant and the
execution phase is a concurrent drain — stepping through it is less
intuitive.
ActionChainImpl from actor.ts (the thenable chain)SequentialActorHandleImpl (the handle that creates throwaway chains)test() export from scene.ts (keep sceneRegistry, runScene)ActionChain and SequentialActorHandle from types.tsrunner.ts — it calls runScene which calls scene.fn,
which already works with scene() since it registers as a scenetest() to scene().spec.md files already compile to scene() — no changes neededreactive.tsscene export from index.tsConcurrentActorHandle, SceneContext, SceneFn from types.tsgetCurrentSession() from scene.tsactionTimeout/warnAfter to private on TeamSession
(or leave — no harm)reactive.test.tsmarkdown-scene.ts to emit test() registrations
instead of scene() — or keep scene() as an internal mechanismThe concurrent removal is much smaller, which is expected — scene() was added on
top of the classic driver infrastructure. However, .spec.md files currently compile
to scene(), so removing the concurrent model would require either rewriting the markdown
parser or keeping scene() as an internal mechanism.
When evaluating, pay attention to:
Promise.all? — If every multi-actor
scene needs it, the classic driver is fighting you.emit/waitFor in concurrent scenes? — If every
multi-actor scene needs explicit bus coordination, the concurrent model
is fighting you.If we decide to go all-in on one model, the conversion is mechanical:
Classic driver → Concurrent: Remove await from DSL calls. Replace Promise.all
multi-actor blocks with plain sequential declarations (concurrency
becomes automatic). For cross-actor ordering that relied on await
sequencing, add emit/waitFor pairs.
Concurrent → Classic driver: Add await to every DSL call or chain. Replace
emit/waitFor pairs with sequential await ordering. For
concurrent actor work, wrap in Promise.all.
Both conversions are grep-and-replace level. Neither requires rethinking the test logic.
scene()) registers as a normal scene internally — the runner does
not know the difference. This is intentional: it means both models
share discovery, team management, reporting, and lifecycle hooks.ConcurrentActorHandleImpl duplicates some logic from ActionChainImpl
(selector resolution, scope management, warning polling). If we keep
the concurrent model, we should extract shared helpers. If we keep the classic driver,
delete reactive.ts and the duplication goes away.if() watcher API exists in both models but works slightly
differently. In the classic driver, watchers clear after each await. In
the concurrent model, if() is a one-shot persistent monitor — it polls during
every action from the point it’s declared, fires inline when the
selector matches, then stops. The concurrent version uses a queue-swap
trick: the callback receives the actor, but DSL calls inside it push
to the monitor’s sub-action list instead of the main queue:
alice.if('welcome-modal', a => a.click('dismiss'))
When the monitor fires mid-action, the sub-actions execute inline (pausing the current action), using the actor’s live scope. No separate API needed.
Written during initial implementation. Re-evaluate after real-world usage on sunlo.app.