A running watch session.
Async-iterable, and that is the intended way to consume it: for await (const result of session) yields the initial run and then one RunResult per rerun, in order, ending
when the session is closed. Every element is a complete result — counters, tests, diagnostics
— rather than a delta, so nothing has to be accumulated to know where the suite stands.
One consumer at a time. Two concurrent for await loops would race for each result rather
than both seeing it; if you need to fan out, do it inside the one loop.
// Defined, not invoked: a real session holds a browser open. async function untilGreen(session: WatchSession) { for await (const result of session) { if (result.ok) break; // breaking out closes the session } }
initial: RunResult
The initial run's result, available before anything has changed on disk.
latest: RunResult
The most recent run's result — initial until something re-runs.
The session's current state, readable without consuming the iteration. A UI redrawing for some reason of its own (a resize, a keystroke) needs the last result again, and taking it from the iterator would steal it from the loop that is driving the redraws.
How many events the feed dropped because a consumer fell behind. 0 unless something stalls.
A watch session has no natural end, so an events() feed nobody drains would grow for as
long as the session lives — the cap is what stops that, and this is how you see it happen.
Playwright's Browser for this session — unstable, playwright-core's type.
import type { WatchSession } from './watch.ts'; // Defined, not invoked: needs a live session. async function contexts(session: WatchSession) { return session.browser.contexts().length; }
The page the suite runs in — unstable, playwright-core's type.
import type { WatchSession } from './watch.ts'; // Defined, not invoked: needs a live session. async function shoot(session: WatchSession) { return await session.page.screenshot(); }
The HTTP + WebSocket server serving the bundle — unstable, this project's own
HTTPServer rather than a documented interface.
import type { WatchSession } from './watch.ts'; // Defined, not invoked: needs a live session. function addRoute(session: WatchSession) { session.webServer.get('/fixture.json', (_request, response) => response.json({ ok: true })); }
esbuild's incremental BuildContext — unstable, esbuild's type.
null until the first build has created one, and again after a restart() until that
session's first build. Genuinely nullable rather than defensively typed: the context is
created lazily by the first bundle, so a caller reaching for it early would find nothing.
import type { WatchSession } from './watch.ts'; // Defined, not invoked: needs a live session that has built at least once. async function rebuild(session: WatchSession) { return session.esbuild ? await session.esbuild.rebuild() : null; }
The live fs.watch handles keyed by watched path — unstable, and a PARTIAL view: the
parent-directory watchers, rescan intervals and symlink pollers the session also owns are
not in here. It answers "what is being watched", not "every handle the watcher holds".
The record is the watcher's own object, so it reflects additions and removals as they
happen. After close() the handles in it are closed but the keys remain.
import type { WatchSession } from './watch.ts'; // Defined, not invoked: needs a live session. function watchedPaths(session: WatchSession) { return Object.keys(session.fileWatchers); }
run(files?: string[]): Promise<RunResult>
Re-runs now, optionally scoped to files, and resolves with that run's result.
runAll(): Promise<RunResult>
Runs the whole suite, dropping any line-target selectors this session was scoped to.
Not the same as run(): a session started from test/cart-test.ts#34 stays pinned to that
line until something clears the pin, and only this clears it. -t/-m survive — those are a
standing instruction about which tests to run rather than a starting point.
runFailed(): Promise<RunResult>
Re-runs the files that last failed, or repeats the last run when nothing has failed yet —
saying so as an info notice on the result rather than silently doing something else.
Cuts the current run short: the browser drops the rest of its queue and the run ends where it
is, with aborted: true on its result.
Resolves once the interrupted run has settled, so await session.abort() leaves the session
idle and ready for the next verb. Aborting while nothing is running is a no-op.
The fine-grained feed: every event of every run, flat and in order, until the session closes.
The same events for a watch session as for a single run, so a UI written against one works
against the other — runStart … test … runEnd, then again for the next rerun. Iterating
the session itself gives the coarse view (one complete result per rerun); this gives the view
a progress bar needs.
Buffers from the first call, not from the first read, so events between calling this and starting to iterate are not lost. One consumer, like the session's own iteration.
The coarse feed as a Stream: one complete RunResult per rerun, with the
combinators attached. for await (const r of session) is the same sequence.
// Defined, not invoked: a real session holds a browser open. async function untilGreen(session: WatchSession) { const [red] = await session.results().filter((r) => !r.ok).take(1).collect(); return red.failures; }
restart(patch?: SessionPatch): Promise<RunResult>
Tears the session's machinery down and boots it again — browser, page, server, esbuild context and watchers — then runs the suite once, resolving with that run's result.
The session survives. this is the same object, and events() / results() keep
streaming straight across: a restart is one transition in the life of a session, not a close
followed by a new one. If it ended the feeds it would be close() plus watch() with extra
steps, and there would be no reason for it to exist.
For picking up a change no rerun can see — an edited package.json, a plugin whose module
you replaced, a browser wedged by the page under test.
initial still refers to the run the session STARTED with; it is readonly and callers hold
it. The restart's own first run arrives through results() and latest like any other.
url may change. The old port is released and reacquired, and if something took it
in between, the new server binds the next one — so re-read url afterwards rather than
caching it. The live objects are replaced too — all but WatchSession.esbuild, which a
restart deliberately keeps.
Ordering: it takes the same queue reruns take, so a restart waits for an in-flight run and a rerun asked for during one waits for the restart. Calling it twice concurrently gives both callers the SAME restart rather than tearing down twice.
import type { WatchSession } from './watch.ts'; // Defined, not invoked: needs a live session. async function afterConfigChange(session: WatchSession) { const result = await session.restart(); return { total: result.counts.total, url: session.url }; }