The packages are pre-1.0 and move fast. This page is the changelog of @unotest/web@0.33.0 — the version this site documents — taken straight from the package you install.
0.33.0 — 2026-09-06
-
Box: every run is announced — Slack, Telegram, or a webhook of your own — and the channels are managed from the guard.
A box now tells people how its runs went, whoever started them: the schedule’s ticks, a pull request’s check, somebody pressing Run in the viewer, a terminal inside the container. The daemon reads each run’s own journal, so the three producers are one source; the run’s manifest says what set it off —
trigger: { kind: "schedule" | "ci" | "manual" | "cli", by? }, written by the runner fromUNOTEST_RUN_TRIGGER/UNOTEST_RUN_ACTOR(a box sets them for its scheduled and CI runs, the viewer for a run ordered from its UI; a terminal sets nothing and reads ascli).A scheduled series is announced on a change of state:
failedafterafterFailuresred ticks in a row,recovered,still-failingat a rule’srepeatEvery,not-runwhen the suite could not run at all (no bundle, a bundle that does not install) — daily by default. A run somebody ordered is announced on its own,failedorpassed, every time. A withdrawn or aborted run says nothing; a run whose journal stops and whose heartbeat goes cold isinterrupted.Channels, rules, mutes and quiet hours live on the box and are managed from the guard’s
/_guard/notificationspage (orboxd notify …): a channel’s secret is set once and never shown again, a rule picks environments, triggers, events and channels, a series or the whole project can be muted until a time, and quiet hours hold reminders andpassedback until the morning. A generic webhook receives the event as JSON (version: 1, the box named, thetrigger) withX-Unotest-Eventand, when the channel has a secret,X-Unotest-Signature: sha256=<hex>— HMAC-SHA256 over the raw body, the scheme GitHub uses — plus any headers the channel declares.@unotest/protocol:RunManifest.triggerand the trigger helpers (runTriggerFromEnv,runTriggerEnv,manifestTrigger); the session-door paths, views and parsers of the notifications API (boxNotifyProjectPath,boxNotifyChannelPath,boxNotifyRulePath,boxNotifySeriesPath,boxNotifyMutePath,boxNotifyQuietHoursPath,boxNotifyPreviewPath,BoxNotifyProjectView, …). The first draft of this feature (channels declared in the box config, secrets by field) never shipped; this replaces it whole. -
lintande2eno longer fail on a project tree they cannot write to.Editor typings (
unotest/jsconfig.json,.unotest/types/env.d.ts) are a convenience for a human with an IDE open, and they were being written on every implicitlint/e2e. On a box that runs a suite in an isolated container the bundle is mounted read-only, so that write raisedEROFS/ENOENTand every scenario ended asinterrupted— a suite failing because a cache could not be refreshed.The write is now skipped when the tree refuses it (matched on the errno
code, never on a message), both artifacts reportskipped, the result carries the reason, and the run carries on; any other failure still throws.UNOTEST_EDITOR_TYPES=0turns the whole thing off up front — a box sets it on every run it starts, because there is no editor in a container and the attempt is only noise in the log. -
Judge: the bearer token on
/judgeis compared in constant time, and the docs say what a verdict sends and what it is not.UNOTEST_JUDGE_TOKENwas checked with a plain string comparison, which returns as soon as one character differs; both sides are now hashed and compared withtimingSafeEqual. The judge guide and the package README gain a section on what leaves the machine — the one element’s rendered text and the rubric, nothing else; the secret redactor does not act on that body; CI defaults tofake— and on why a verdict, being a model’s reading of the page’s own text, is a check on wording and never a security gate. -
Box notifications: a webhook channel’s header values are credentials.
The notifications API and the guard’s page now see a webhook’s headers as names with
{ set: true }— the values (the receiver’sAuthorization, most of the time) are never read back, like the signing secret. The edit form lists the names; typing a value after one replaces the set, names alone keep it./api/box/notifyis the guard’s own route: it is no longer reachable through the session proxy for any role — a readonly session could previously list every project’s channels with their headers. -
Box notifications: the webhook signature now covers a timestamp.
Every webhook delivery carries
X-Unotest-Timestamp(Unix milliseconds, when it was sent), andX-Unotest-Signatureis HMAC-SHA256 over<timestamp>.<raw body>rather than the body alone — GitHub’s scheme with a timestamp in front, so a delivery captured on the wire cannot be replayed to the receiver once its window (five minutes, documented) has passed. The GitHub webhook the box receives is unchanged: that is GitHub’s contract.The
byof a CI-triggered run (push a1b2c3d (branch)) is cut at 200 characters — a branch name is the pusher’s input, and it must not balloon the run’s manifest and every notification the run produces. -
Pin playwright to the exact release the box image ships.
@unotest/webdepended onplaywright ^1.62.0, so a fresh install of a suite could pick a newer playwright than the browsers inside the box’smcr.microsoft.com/playwrightimage, and every run on the box failed with “Browser ‘chromium’ is not installed” the day 1.63.0 shipped. The dependency is now the exact version of the image (1.62.1); the two are bumped together, and a guard in the monorepo’sverifykeeps them equal. -
Building the runner no longer writes into the project tree.
ExplorationServicecreated.unotest/explorations/in its constructor, and the composition root builds it for every entry point — including ane2erun. Where the project tree is read-only (a box mounts a pushed bundle that way) the run ended before a browser was asked for. Recording is an MCP activity, so the folder is now created with the first record written instead.With the editor-typings fix in the same release, an ordinary
lint/e2ewrites nothing under the sources: run artifacts go toUNOTEST_ARTIFACTS_ROOT(which the box makes writable), and everything else that writes into the project — recording, authoring,init,env set,bundle push— happens on a developer’s machine. -
Run isolation, stage 1: a box no longer executes a test bundle inside its own daemon.
box-runner (new, private): the run sidecar — the only service on a box holding the docker socket, and the only one that starts a container. Three authenticated routes on the
boxnetwork (POST /runs,GET /runs/:id/streamNDJSON,DELETE /runs/:id), a shared secret compared in constant time, and an API that cannot be told anything about how a container is built: no path, image, network, user, mount or flag. A request names a project, an environment, a bundle, a scenario and the environment’s values; the bind sources are derived from the first three, resolved withrealpathand re-checked against the projects root, and must already exist. The container is created over the Docker Engine API (pinnedv1.43) straight over the socket — nodockerbinary in the image and no client dependency, and a container that is a JSON document has no place for a value to become a flag. A daemon that refuses or is not there comes back as502 {reason}before the run is accepted, or as anerrorevent on the stream after — never as a run that “exited with code null”. An environment variable the box does not forward is named in the sidecar’s log rather than dropped silently. A run gets the bundle tree read-only as its working directory, the environment’sunotest/.runs.<env>read-write, anoexectmpfs/tmp, a read-only rootfs, all capabilities dropped,no-new-privileges, its own uid in boxd’s group, memory/pids/cpu limits clamped to both the operator’s ceilings and the host’s own size (docker refuses a container bigger than the machine instead of clamping it), a 512 MB/dev/shm(docker’s default 64 MB kills Chromium mid-page; the host’s IPC namespace is deliberately not borrowed), and therunsnetwork only. The container engine is an interface, so every rule is asserted on the container that would have reached docker — including the cases where the answer is no container at all.boxd: running the SUITE is its own contract (
IScenarioRunner), separate from running a command (ICommandRunner, still the daemon’s ownnpm ciand viewers).ContainerScenarioRunnertalks to the sidecar,ProcessScenarioRunnerkeeps the old child-process shape for a box without docker;BOXD_RUNNER_KIND=container|processchooses, and half a container configuration refuses to start. Reading a bundle’s schedules (unotest-web schedules --json) executes the project’s config module, so it goes the same way. A run’s environment is built from named parts (run-environment.ts) instead of inheritingprocess.env, and its debug tree is pointed at the run’s own directory rather than the read-only sources. New metricsboxd_run_container_total{outcome}andboxd_run_container_start_seconds. An environment’s runs directory is created by the daemon with2775, so the run’s uid may write into it and the daemon’s group may read it back.box-kit:
secretsMatch(constant-time secret comparison, moved out of dist-service),splitLines, andenvNumber/envPositiveNumber— the env-reading rule the box-side services share.npm ciof a bundle is a container of its own (kind: "install"): the bundle tree is its only mount and it is writable, there is no environment and no artifacts directory in reach, and a dependency’s install scripts RUN — a native module builds or fetches its binary as usual. That is what running them inside the daemon could never allow. The container’s last steps, only on success, check the tree against the box’s size cap, set the final modes on what the install created and write the install marker — each with an exit code of its own, so the daemon’s log names the step that failed — so a killed or oversized install leaves a tree the box will not mount; a failed install takes the tree with it. The daemon no longer passes--ignore-scriptsand no longer walks the tree afterwards.The daemon runs with
umask 002and clearsnode_modulesbefore each install, so a tree it created is one the install container (another uid in its group) can actually write.An install that fails KEEPS the tree, unmarked: nothing mounts it and the next attempt reinstalls in place (
npm ciwipesnode_modulesitself). And a bundle directory holding a manifest with neither an archive nor a tree behind it no longer counts as “this box has it” — a push of the same content brings it back instead of being answeredalready had this exact bundle.web:
bundle pushand the docs say that a dependency’s install scripts run on a box, in isolation — the earlier advice to vendor them is gone.A run ordered in the viewer now goes through the daemon’s queue instead of being spawned by the viewer.
POST/GET/DELETE /api/box/envs/<project>/<env>/runs[/<runId>](paths and wire types in@unotest/protocol) takes amanualticket like every other producer. Two callers may use it, and neither is taken on its word: the guard, proving it is the guard with a shared secret frombox-init(the actor header is read only next to it — a viewer container could otherwise claim to be any admin), and a viewer, with a per-environment token the daemon minted when it started that viewer plus a short-lived single-use ticket the guard signed over WHO clicked. The guard gates the route underadminand requires anOriginon a mutation; the daemon checks the token’s environment, the ticket’s environment and itsjtiagainst the path.@unotest/viewergetsBoxdRunner: on a box the Run button orders and the Stop button asks the daemon, and the progress still comes from the run’s journal on disk. A local viewer spawns as it always did — the composition root picks by whether a box handed it credentials. -
Run isolation, stage 2b and 3: an environment’s viewer runs in a container of its own, and a box ships the sidecar in its compose stack.
The viewer was the last thing on a box that executed a bundle’s code next to the daemon’s state: it runs the UI, the language service and the linter out of the bundle’s own
node_modules. It is now a container the run sidecar starts and the daemon asks for —POST /viewers, one per environment, addressed by a name both services derive (unotest-viewer-<project>-<env>-<8 hex>, port 7788).Each viewer gets a NETWORK of its own with exactly two other containers attached: the guard, which proxies people at it, and the daemon, which it orders runs from. Not the sidecar’s network, not the runs network, and above all not another viewer’s — a viewer has no authentication of its own, and two on one network would be two containers of untrusted code with a route to each other. That network is INTERNAL: a viewer’s server makes no outbound call, so it gets none, while the network a run joins stays open because a test drives an application. The name of a viewer and its network is a function of the project/environment PAIR rather than of the string they join to, so two environments whose names concatenate the same way cannot end up sharing one. Its mounts are the bundle tree read-only and the environment’s
unotest/read-write (the run history it renders and the queue it withdraws tickets from); the environment’s secrets and itscurrentlink sit in the directory above and are mounted by nobody. Its environment is assembled by the daemon — the target, the environment’s variables and secrets, its own credential — and the daemon’s ownprocess.envno longer travels.Editing a suite’s files from the viewer on a box no longer works: the sources are read-only there. Such edits never survived the next
bundle pushanyway, which replaced the tree. Everything else in the UI is unchanged.Readiness is the docker daemon’s own healthcheck, probed inside the container and read through the sidecar — the daemon no longer waits on a lock file, and nothing dials a viewer from the process holding the docker socket. A viewer’s output is streamed into the daemon’s log and reattached when the stream drops. A container whose bundle and values still match is ADOPTED across a restart of either service, keeping the credential it was born with; one whose environment is gone is stopped, with its network and its token. The sidecar recognises its own containers by label, never by name alone, and enforces its own ceiling on how many viewers may exist (
BOX_RUNNER_MAX_VIEWERS) — boxd knows how many environments there are right up until boxd is the thing that was compromised.A run somebody stopped now ENDS in the viewer instead of hanging. Stopping a run stops its container gracefully (SIGTERM, then SIGKILL after
BOX_RUNNER_STOP_GRACE_SECONDS, default 10) so the suite writes the terminal event of its own journal; if it could not — the grace ran out, the machine died — the daemon appends it. The same grace applies to a run killed on its timeout, so a run’s budget is the timeout plus the grace.A viewer that dies is noticed in about a second and replaced: the log stream the daemon holds open ends with the container, and the 404 that answers its immediate reattach is the signal. The route comes out of the table at once — the guard then says the environment has no viewer instead of proxying at a dead address — and a fresh container is started, on the same backoff the daemon uses for everything else it cannot reach.
A restart of the sidecar is no longer an outage. While it cannot be reached the daemon keeps the routes it published (the viewer containers are up, and the guard reaches them directly) and tries again with a short backoff instead of waiting for its next five-minute sweep. Editing a file in the viewer on a box now answers 403 with the reason — the sources are read-only there — rather than a blank 500.
The viewer no longer collapses the daemon’s refusals into a blank 500: the box’s own status and reason reach the browser, so “that click has already been used” (409), “the box cannot verify the caller” (503) and “the box daemon is not answering” (503) are told apart by the person who clicked.
Deployment:
compose.yamlgains therunnerservice (the only holder of the docker socket, the release ships its bundledbox-runner.mjsbesideboxd.mjs), fixed container names for the guard and the daemon, abox_secretsvolume whose secretsbox-initgenerates, and a one-off migration of the projects tree to the group layout an install container needs. Two values an operator fills in:BOX_PROJECTS_HOST_DIR(a bind source is resolved by the docker daemon on the host) andBOX_DOCKER_GID. Step by step in the box release’s cutover manual. -
Viewer: a run that started before the page was reloaded is shown again.
The live sidebar was rebuilt from WebSocket events only, and those speak from the moment the page connects — a collection already in progress never re-sent its
startedevent, so after a reload it was missing from ACTIVE, then reappeared nameless with no scenario list once its next scenario began, while its children showed up one by one. On load, and on an environment or target switch, the viewer now asks the server which runs are live and replays each one’s journal into the same state the live stream feeds, so a reload mid-run shows the collection with its name, scenarios and progress. -
Viewer: a session that may not edit variables does not see credential-shaped values, nor the project’s absolute path.
Behind the guard, a readonly session could read
GET /api/variablesand get every box variable’s value —PASSWORD=…included when the operator had pushed it as a variable rather than a secret. The viewer now reads the capabilities the guard stamps on each request: when the caller cannot edit variables, a value whose name matchesPASSWORD | PASSWD | SECRET | TOKEN | KEY | CREDENTIALis sent empty and flaggedsecret, andGET /api/targetsleaves outprojectRoot. Display-masking by capability, not authorization — a viewer without a proxy in front is unchanged, and the guard still refuses every write. -
Viewer: a collection run no longer shows up nameless and without scenarios.
The journal tailer moved its cursor to the end of the file even when the last line was still being written, so the half already on disk was lost and the other half was later read as garbage. The longest line in a journal is
collection-run:startedwith the scenario list, so that was the one it usually caught: the run appeared under its id instead of its collection name, with “collection has no scenarios” and a2/0 donecounter. The cursor now stops at the last complete line and the rest is read on the next change. -
Viewer: a run started the moment the viewer came up is shown, not lost until the next change.
Watching a directory becomes live a moment after the watcher reports it is ready, and a run directory created inside that gap was not merely late — it was invisible until something else changed in the same day, which in a quiet environment is the next run. Measured at 1-3% on macOS with a busy machine, and the window is exactly “open the viewer, start a run”. The viewer now proves the watch delivers before it declares itself up: it creates a directory of its own and waits to hear about it, so boot completes on evidence rather than on a promise. A tree it cannot write to, or a probe that never comes back, costs a warning in the log and boots as before.
A directory whose name starts with a dot is never reported as a run — an editor’s leftovers, or the watcher’s own probe, can no longer appear in the run list as a phantom.
Run queue: a waiter whose process paused is no longer mistaken for dead — the queue stamps its own files with its own clock.
A run waiting for a slot proves it is alive by touching its ticket, and what it is judged against was the modification time the filesystem wrote: on a network share that is the server’s clock, and against a machine busy enough to keep a process off the CPU, a ticket written a moment ago could look like it belonged to a process that died. The queue now stamps every file it creates with the same clock it judges them by.
-
Updated dependencies [2abfd4d]
-
Updated dependencies [b3e1220]
-
Updated dependencies [b3e1220]
-
Updated dependencies [5d1bde3]
-
Updated dependencies [348aba7]
-
Updated dependencies [9158968]
-
Updated dependencies [b3e1220]
-
Updated dependencies [bd6b7ba]
-
Updated dependencies [8846a06]
- @unotest/protocol@0.33.0
- @unotest/viewer@0.33.0
- @unotest/core@0.33.0
- @unotest/dsl@0.33.0
- @unotest/grounder-client@0.33.0
0.32.0 — 2026-09-05
-
npx @unotest/web box …reads a box’s results from a terminal:box envslists the environments a read token may look at,box runstheir history (--latestcollapses it to one line per scenario with its failing streak),box run <id>explains one run — the failure, the soft steps, the judge’s verdicts —box queueshows who is waiting, andbox screenshotsaves a frame the run captured.box run --downloadfetches the run’s*.unotest.zipinto.unotest/box/and unpacks its failure bundle into.unotest/failures/, wherelist_failures,get_failure_*andagent_fixalready look — so a run that failed on a box is debugged with the commands a local failure is.--no-screenshotsasks the box itself to leave the step frames out (GET /api/runs/:id/export?screenshots=0), which is what makes the download smaller rather than only the disk.Reading needs a personal read token in
UNOTEST_BOX_READ_TOKEN; the project’sUNOTEST_BOX_TOKENstill pushes bundles and values and cannot read runs.The viewer publishes its archive reader as
@unotest/viewer/snapshot, so the three places that open a*.unotest.zip— its server, its browser bundle and now the CLI — share one implementation and one message for a file that is not an archive. -
Six MCP tools close the loop that
bundle push --runopens:box_runsays what became of a run the agent ordered on a box,box_run_downloadunpacks its failure bundle into.unotest/failures/soget_failure_trace,get_failure_console,get_failure_a11y,get_failure_screenshot,get_failure_networkandagent_fixwork on it unchanged, andbox_envs,box_runs,box_queueandbox_screenshotcover the cases where the agent has no run id, no environment name, a run that never started, or a page it would rather see than read about. Their descriptions carry the route, not just the arguments.Without a read token the tools refuse with a message saying where to mint one, so an agent meets an instruction rather than an unexplained failure.
-
Box: personal read tokens, so an agent can read a box’s runs without a browser.
A user mints a token for themselves on the guard’s new Read tokens page (
/_guard/tokens), sees the value once, and points a client at the box withUNOTEST_BOX_READ_TOKEN. The token is alwaysreadonlywhatever its owner’s role, may onlyGET, and names the environment it means inX-Unotest-Environment: <project>/<environment>(GET /_guard/api/envslists them). It is not a machine identity: it follows its owner — revoked, disabled or a lapsed seat all stop it, and the refusal says which. An administrator sees every token on the box and can revoke one that is not theirs; issuing, first use and revocation all land in the audit trail.UNOTEST_BOX_TOKENis unchanged: the project token still pushes bundles and environment values, and read tokens cannot — asking with the wrong one now says which token the route wants instead of a bare “unauthenticated”.A read token is never passed on to the viewer behind the guard, so it cannot end up in the logs of a service that has no use for it. A box whose licence has lapsed, and an environment whose viewer is not up yet, answer a token in the read contract’s shape rather than with a page or a bare 503 — “wait” and “renew the licence” are not the same instruction as “your token is wrong”.
-
The agent integration guide’s tool catalog matches the server again. It listed fifteen per-action tools (
goto,click,fill,press, …) that were removed when recording moved intoexplore_step, had no section at all for the ten exploration tools that replaced them, counted the debugger’s eleven tools as six, and put the total at 39 when it is 50. An agent reading it as a map — which is exactly what this file is for — would call tools that do not exist and conclude the server is broken. Every name now comes from the list the test suite verifies against the real registry, and the section counts add up to the total. -
BoxReadClientreads a box’s runs — environments, run history, one run’s whole journal, a collection’s children, the queue, a run’s export zip and its individual artifacts — over the same HTTP routes a browser uses, authenticated with the personal read token inUNOTEST_BOX_READ_TOKEN. Every refusal arrives as a typedBoxReadErrorwhosekindsays what to do next, so throttling is never mistaken for a rejected token and a live run’s export says “wait” rather than looking like a broken box. Configuration mistakes surface when a read is attempted rather than at startup, so a stale token in a project’s.envcannot stop a local run that never touches a box.The viewer gains a
@unotest/viewer/wireentry point exporting its HTTP contract types (the runs page, a run’s full snapshot, the queue payload and the snapshot manifest), so a client can name the shapes it parses instead of keeping a second copy of them. -
The box address and the read token are resolved from
unotest/.envandunotest/.secretson every request, not once at startup, and theboxCLI reads those files directly.unotest-web box …runs without loading the project config, so nothing had flattenedunotest/.envinto its environment — the command told people to put the address there and then refused with “no box address”. And an MCP server that resolved once meant a token written while it ran was ignored until the editor reconnected it, which is exactly the restart this path exists to avoid. One rule for both settings, from both entry points: a flag, then the environment, then the project’s files. -
Protocol contract for reading a box’s run results with a personal read token:
UNOTEST_BOX_READ_TOKEN(distinct from the project’sUNOTEST_BOX_TOKEN, which stays a write credential for bundles and environment values), theX-Unotest-Environment: <project>/<environment>header every bearer request names its own environment with, the/_guard/api/envslisting and itsBoxReadEnvironmententry, and typed refusals (unauthorized,forbidden,unknown-environment,not-found,rate-limited,run-in-progress,unavailable,malformed) with parsers that reject anything that is not a box answering. Throttling gets a code of its own rather than anunauthorizedcarryingRetry-After: a rejected token means mint a new one, a throttled one means wait and resend the same one. The viewer’sViewerEnvOptionis now that same protocol type rather than a second copy of it. An environment whose viewer is still starting — the common state right after a bundle push — answersunavailablerather than looking like an unreachable box, so the advice is to wait rather than to check the address. -
A run exported as
*.unotest.zipnow carries everything needed to diagnose it away from the machine that produced it:stdout.logandstderr.log(previously dropped, which left the one artifact that explains a runner crash outside the bundle), the failing page’spage.htmlreachable through the manifest, and the run’s step screenshots — declared in a newmanifest.screenshotslist, because an importer keeps only what the manifest names and a frame absent from it did not survive the round trip. -
UNOTEST_BOX_READ_TOKENis now read fromunotest/.secretsas well as from the environment, with an exported value winning. This is what makes thebox_*tools usable from an agent at all: an MCP server is started by the editor, so a token that exists only in a shell is a token the server never receives — the alternative was editing the editor’s own JSON config and reconnecting the server.unotest/.envis deliberately not consulted: it travels inside a pushed bundle, while.secretsdoes not. The CLI resolves the token the same way, so one token serves both. -
box runandbox_runnow carry the{tag}of a failed soft step, in the JSON and in the printed line (Rubric [judge-red], the shape a local run already prints). A data-driven test runs onestep.softover many cases, so its failures all share a label and the tag is the only thing that says which case failed — without it a remote reader saw “Rubric failed” three times and had to tell them apart by line number. The tag was in the run’s journal all along; the summary dropped it. -
Secrets injected by a box are masked. Masking works by value, and the registry of values was built from
unotest/.secretsalone — a file that does not exist on a box, where the daemon passes the values as environment variables and names them inUNOTEST_BOX_SECRET_NAMES. The registry was therefore empty on every box run, and a password a scenario typed reached the run journal, the run’sstdout.log, the viewer’s System pane and anything an agent downloaded, in the clear. The runner now registers those values alongside the ones it reads from files, so masking no longer depends on where the run happens to be. Nothing changes on a developer’s machine, where the variable is not set. -
Failure text no longer carries terminal colouring into files and replies.
Playwright paints its call log whenever the environment claims a terminal is watching —
FORCE_COLOR, which an MCP server inherits from whatever launched it — and that message was copied verbatim intosteps.jsonl,runtime.jsonand the failure bundle. An agent reading the JSON got an escape sequence in the middle of the sentence it was trying to parse, and the viewer rendered it as a chewed-up word.The colouring is dropped where a thrown error becomes our data, so every reader of a failure gets the same clean text. Our own output is unaffected: it paints at print time, which is where colour belongs.
The same on mobile: a failure’s text in the run journal and in the report no longer carries terminal escapes.
-
A failure somebody paused on stays a failure, with the evidence to show for it.
A run driven through the debugger —
run_testthenresume, or the viewer’s Continue — reportedcompletedafter a failure it had paused on, wrote no failure bundle and nofailure/artifacts, and leftlist_failureswith nothing to show. Continuing past a failure is how it gets inspected; it was never meant to retract it. The verdict is now decided where the run’s own events are seen, so the journal,runtime.jsonand the reply agree, and a debug run leaves the same evidence a plainunotest-web e2erun does.abort_runtime(and Stop, and SIGTERM) now also ends the run inruntime.json, not only insteps.jsonl: the control file used to keep sayingpaused-stepabout a run that was over, so anything reading it rather than the journal saw a pause that never ended. -
README links the documentation site instead of manuals that are not shipped in the package (
guides/manuals/*), and says what a run on a box cannot reach:localhost, port-forwards, host tools, a judge service on your machine — with the recipe for the judge on a box. -
Secret values registered from
unotest/.secretsare now masked in what the runner prints, not only in what it writes. The run journal and every failure artifact were redacted; the terminal line was not, and that line is also copied into the run’sstdout.log/stderr.logand streamed to the viewer’s System pane. Masking is applied once, where the logger is built, so child loggers and message arguments are covered too. The collection runner is covered by the same rule: its messages go through its logger rather than straight to the stream, and the logger it builds when a caller passes none reads the project’s secrets the way the composition root’s does. -
A
step.soft(...)failure no longer stops a debug run — or goes missing from it.Under a debugger (
run_test, ore2e --debug), every soft failure raised the debug wheel: the run stopped on each one and an agent had toresumeits way through them. Worse, it was then lost — a paused failure is consumed where it paused, so it never reached thestep.softenvelope that records it, the envelope closed as if the block had passed, and a run with three soft failures could finish green with none of them listed.Pausing is now for a failure that ENDS the run. A failure under any enclosing soft step is recorded and stepped over exactly as it is on the command line, and
runtime.json— which therun_testreply is built from — carries every soft failure of the run, not just the last stop.A hard failure still pauses: that is what the debugger is for.
The same holds on mobile: a
step.soft(...)failure no longer stops a run under the debugger, and every soft failure of a run is now part of its runtime state instead of being lost at the pause. -
Updated dependencies [442fadf]
-
Updated dependencies [442fadf]
-
Updated dependencies [8aa0f30]
-
Updated dependencies [8aa0f30]
-
Updated dependencies [047ca68]
-
Updated dependencies [047ca68]
-
Updated dependencies [047ca68]
- @unotest/viewer@0.32.0
- @unotest/protocol@0.32.0
- @unotest/dsl@0.32.0
- @unotest/core@0.32.0
- @unotest/grounder-client@0.32.0
0.31.0 — 2026-09-04
-
New DSL function
note(label, value): attach a labelled value to the current step — the question a data-driven case asked, the answer it got — kept in the run journal and shown in the viewer under the step, during and after the run. Any value: a string as it is, anything else as JSON; cut at 4 KB (markedtruncated), secrets masked.log(...)now also lands in the journal under the step that wrote it (the stdout line stays), andassertJudgeverdicts carry the position of their statement, so the viewer can show a verdict under its step and the whole verdict on a failed step’s error card. Each of these events records the DSL call’sfile/line/coland the nearest entry-file statement (entryLine/entryCol) — a note from inside a helper is attributed to the entry step that called the helper. Without a run journal (exploration, an ad-hoc runtime)notegoes to the logger; nothing fails. -
ExecutionWalker.runtakes a trailingentryArgslist and binds it by position onto the entry function’s parameters (missing onesnull) in the root scope before the body runs — whatexplore_run_flowneeds to replay a parameterisedflow_*helper live. -
explore_run_flowreplays parameterised flows: passargs(positional JSON values,"{{NAME}}"for a variable) and the helper’s parameters are bound for the live replay —flow_login(username, password)no longer fails withVariable "username" is not defined.explore_startlists each flow’sparams; a wrong argument count is refused with the signature. The recording keeps variable NAMES, so the saved test readsflow_login(LOGIN, PASSWORD);—flow_callis now a regular action plugin rendering the call with its arguments. -
New
explore_stepactionscreenshot— the evidence step of an exploration. It files a PNG under the session (.unotest/explorations/<explorationId>/screenshots/<NNN>-<name>.png,adhoc/without a session), replies with the absolutepath,widthandheight, and returns the image itself as an MCP image block (a JPEG copy when the PNG is over 1 MiB; path only when both are).namedefaults to the section slug,fullPagecaptures the whole page,locatorcaptures one element,outline: trueadds the page outline to the reply,evidenceOnly: truekeeps the file but records no step. A recorded screenshot becomesscreenshot(name)in the saved test; an element capture degrades to a page capture with a comment and a non-blockingDEGRADED_STEPwarning.explore_stopnow lists the session’s files inartifacts. Driver:DriverPage.screenshotElement. -
New linter rule
lint:external-variable-undeclared(defaulterror): a bareUPPER_SNAKEidentifier that no layer file declares —unotest/.env,unotest/.secrets, or the.env.<name>/.secrets.<name>overlay of the environmentlint --env <name>/UNOTEST_ENVselects — is reported with the files searched, instead of failing at that statement at run time after the browser is up. A name bound in the file (assigned, a parameter, a loop counter) is never flagged. The shell is not a declaration: a value CI exports still needs its name inunotest/.env(NAME=with no value declares it; the shell’s value wins at run time, as before). As an error it is not silenced by// lint-ok:; downgrade it inlinter.rulesif a project needs that.run_test(MCP) ande2erun the same check before the browser starts, against what the run will actually resolve — files and the ambient shell — so a suite that lives on a shell variable keeps running; a name that would have failed in the scenario anyway now fails up front (run_testanswerslint_failed;e2eprints it and proceeds, like every pre-run diagnostic).The linter also descends into operator, array and property-access operands now, so a regex or
{{mustache}}literal insidea + bis diagnosed the same as one on its own. -
New linter rule
lint:one-test-per-file: a second (third, …) top-levelfunction test_*in one scenario file is reported at its declaration. A file is the unit a collection runs and the viewer shows — the Steps tree projects a run onto the firsttest_*, so the others executed with nowhere to be seen. Fold the cases into one test with tagged steps (step("…", {tag: id}, () => { … }),step.softwhen a case must not stop the run) or move shared journeys intoflow_*helpers underunotest/e2e/_helpers/;flow_*functions in a scenario file are not counted.Migration window: the rule is a warning in 0.31 — visible in
lint, the editor andrun_test’s warnings, silenced per line with// lint-ok: <reason>, never blocking. A later minor release flips the default toerror(which the pre-run gate ofrun_testrefuses); split files that hold severaltest_*before then. -
New DSL function
readJsonLine(path, filter) → object: read a JSONL file that already exists and return the first line matching the key filter — no polling.waitForJsonLinewas the only structured input into a scenario, and for a fixture or a finished export its 20s timeout only masked a wrong path.readJsonLinetakes the same filter (strict equality per key, dot paths for nesting, unparsable lines skipped) and fails at once:file not found: <path>when the file is missing,no line matches {…} in <path>with parse stats and the closest line when nothing matches. Data-driven tests read their cases with it and name evidence after them (screenshot(q.id)). -
Data-driven steps:
step("label", {tag: q.id}, () => { … })names the case an iteration is on, andstep.soft("label", [{tag}], () => { … })records a failure inside instead of stopping the run — the rest of that body is skipped, the next case runs, and the test still ends failed with every soft failure listed (3 soft step(s) failed: Question [q17]: …, one line per case in the CLI output;run_testanswersnext.softFailures;inspect_runtime’slastFailurecarriesstepTagandsoft). The failure bundle shows the page at the moment of the first soft failure, withsoft: trueandstepTaginfailure.json; later soft failures live in the journal.step.softis allowed insidetest_*only; nesting is free, and the outer step is a group, not an assertion — a soft failure inside does not change its outcome. The run journal gainsstep-block:started/step-block:finishedenvelopes per block (label, tag, soft, outcome, whole-block duration). Editor typings declare the new forms. -
The run journal records every loop pass (
loop:iteration, with the loop statement’s position and the pass index), which the viewer’s new Trace view uses to show a loop as one group per iteration. Also: when an error unwinds through enclosing statements,lastFailure(the failure bundle’s position,inspect_runtime, the CLI’sin step "…" [tag]line) now names the innermost statement that raised it — the assert inside the tagged step — instead of the outermost loop that re-threw it. -
A bundle no longer declares the runner’s own
UNOTEST_*settings (UNOTEST_HEADED,UNOTEST_LOG_LEVEL, …) from the suite’s.env, so a box stops warning on every push that an environment “neither sets nor holds” namesenv pushnever sends. Both sides now share one rule (isRunnerSettingin@unotest/protocol): those variables configure the machine a run happens on, not the suite. A per-suite value such as the navigation timeout travels with the bundle throughunotest.config.mjs(defaultNavigationTimeoutMs), not through.env. -
unotest.config.*is merged with the defaults recursively. A partially written section —linter: { rules: { 'lint:deep-css': 'off' } },failureBundle: { tier3: { video: true } },viewport: { width: 1920 }— now keeps the rest of that section’s defaults instead of failing with “config validation failed” (nested sections require all of their fields at validation time, so a top-level replace made every partial section invalid). Arrays and scalars still replace as a whole:browsers: ['firefox']means firefox only. The setup manual’s example config, which was exactly such a partiallinter, loads as written; itstimeoutsblock — never a config field — is corrected todefaultTimeoutMs/defaultNavigationTimeoutMs. -
The viewer’s editor flags an undeclared external variable (
lint:external-variable-undeclared), the wayunotest-web lintand the pre-run lint already do: a bareUPPER_SNAKEidentifier that nounotest/.env/.secretsfile of the active environment declares is an error in the editor, naming the files searched. Protocol:DslValidateContextgainsexternalNames/externalSources; the viewer supplies them from the active target + environment’s layer files (the same files the variables panel shows) and@unotest/web’s language service turns them into the linter’s lookup. -
Viewer editor: lint markers appear as soon as a file opens (or the page reloads), not only after the first keystroke — the editor validates the source it mounted with instead of waiting for a change event that a reopened tab never fires.
-
Docs:
hovernow lists its options (force,position,timeout) in the DSL reference, the editor signature and the generated typings — it used to read as if it took none, whilepositionis exactly what a hover-revealed menu at one edge of a tall element needs. The agent integration guide gains “Watching a collection run”: the collection’ssteps.jsonljournal (path, the fourcollection-run:*events, how to tail it) as the progress source for a run started in the background, where the CLI’s per-scenario lines only arrive when the process ends. -
unotest-web lintnow receives its arguments: the bin dispatcher forwarded only the command name, solint --env <name>linted the base environment and an explicit file argument was ignored (every file was linted instead).UNOTEST_ENV=<name>worked all along, which is why the gap went unnoticed. -
lintworks on a project that has not migrated to the 0.28 layout yet. Aunotest.config.*still at the project root used to stoplintwith the same refusale2egives — right for a command that would RUN on defaults, wrong for one that only grades: nobody lints a suite during the very migration that touches every file.lintnow builds its context on the default settings (rule severities, helpers dir), readsunotest/.env,.secretsand_helpers/as usual, and reports the layout once as the newlint:legacy-layoutwarning (defaultwarn, configurable like any rule) with the migration on one line. The exit code is unaffected by it.e2e,collectionandbundle pushrefuse exactly as before. -
The MCP server says when it is running a stale build.
run_test’s pre-spawn lint, flow discovery (explore_start),explore_run_flow,generate_dsl_from_explorationandsave_exploration_as_testparse and render DSL in the server’s own process, on the modules it loaded at start — after a rebuild a new DSL feature came back as a false parse error until the server was restarted, while the runner child was already on the new build. Those replies now carryserverStale: trueand aserverStaleWarning(“restart the MCP server”) once the code on disk is newer than the server’s start; nothing changes while it is fresh. -
serverStalenow also rides onexplore_step/explore_steps(locator resolution and recording run in the server’s process),find_elementandground_elementreplies — the same flagrun_testand the exploration tools already carry once the build on disk is newer than the running MCP server. -
gotowith a relative path resolves against the runtime’s own base URL instead of the browser context’s. In a CLI run the two coincide; in an exploration recording against anenvoverride,explore_run_flowreplayedgoto('/_guard/')on the MCP session’s shared context — created with the server’s base — and landed on the wrong host. The replay now follows the session’sbaseUrl(and its variables, as before). -
screenshot(name)accepts any string value, not only a literal. The runtime always slugified whatever it received, but the validator (and thereforelintandrun_test) rejectedscreenshot(q.id)andscreenshot(textJoin(['q-', id]))— a data-driven test had to name every capture the same. Thenameslot is now string-like, as infill; a non-string argument is still anarg-kinderror. -
A section label reopened after another section is called out instead of silently splitting a step:
explore_step/explore_stepsreply withsectionHintthe moment it happens, and the generated draft carries a non-blockingSPLIT_SECTIONwarning naming the reopened entries. The recorded order of actions is never changed;autoRunandsaveare not held back by it. -
A scenario that finished in the same instant the viewer looked at it is no longer recorded as
interruptedwith a finish an hour before its start. The runner ends a run by appendingrun:finishedand then backdating the heartbeat; the viewer read those two files in the opposite order, so a steps snapshot taken a few milliseconds early could meet the backdated heartbeat, and the provisional verdict stuck in the run index while the collection reported the scenario passed. The heartbeat is read first now. A viewer restart (which rebuilds the index from disk) already corrected such records; runs indexed live are right the first time. -
Viewer, Home tab: the tile of a scenario that is running now breathes through colour and shadow only — the scale pulse is gone. It moved the tile’s box every frame, so an automation driver never saw the tile as stable and a click on it waited out its timeout. A tile that never ran no longer carries
data-age="fresh": it has no age, so the attribute is absent. -
Viewer, Home tab: clicking a scenario tile opens the scenario’s LAST RUN — the run whose verdict the tile’s colour is showing — instead of the test file. A tile that has never run still opens the test (there is no run to open), and so does every tile in schedule mode, where the schedule is edited from the test. The legend now reads “hover for history · click to open the last run”.
-
Viewer, Home tab: the tile field no longer refits itself in a loop. The header and legend take the grid’s width; when the legend wrapped at one width and not at the next, the field’s height changed, the fit followed, the width changed back — at 60 fps every tile’s box moved every frame, and an automation click on ANY tile (“element is not stable”) waited out its timeout unless forced. Header and legend now never wrap (they grow past the grid instead), so the fit converges and tiles stay put. A run opened from a tile is titled by its scenario, like one opened from the Runs tree, instead of by its run id.
-
Recording no longer bakes relative time or counters into a locator. An accessible name such as
Run smoke — passed, 7m ago,just now,yesterday,5 passed,7 runsor a trailing badge (Inbox · 3,Errors: 12) now counts as volatile: the recorder emits the stable prefix ({name: "Run smoke — passed", exact: true}, then/^Run smoke — passed\b/) instead of the literal, and the draft carries theDYNAMIC_TEXTwarning with that prefix. Bare numbers elsewhere stay stable (Page 2,Q4 2026,v2,2FA). -
Updated dependencies [b8e105b]
-
Updated dependencies [b8e105b]
-
Updated dependencies [b8e105b]
-
Updated dependencies [9ae6d59]
-
Updated dependencies [56784b3]
-
Updated dependencies [e048334]
-
Updated dependencies [dcf4c9e]
-
Updated dependencies [100ab9d]
-
Updated dependencies [100ab9d]
-
Updated dependencies [100ab9d]
-
Updated dependencies [100ab9d]
-
Updated dependencies [ad7c918]
-
Updated dependencies [ad7c918]
-
Updated dependencies [ad7c918]
-
Updated dependencies [9ae6d59]
-
Updated dependencies [13a2d51]
-
Updated dependencies [91fb562]
-
Updated dependencies [dcf4c9e]
- @unotest/core@0.31.0
- @unotest/protocol@0.31.0
- @unotest/viewer@0.31.0
- @unotest/dsl@0.31.0
- @unotest/grounder-client@0.31.0
0.30.0 — 2026-09-03
-
On a hosted viewer (a box), the project and environment you are looking at are now named and switched in the viewer’s own tree header, in place of the checkout directory it used to show there (
current, which told nobody anything). Previously the only way to another environment was logging out and using the picker page. The chevrons appear on hover, like the refresh button beside them, and only when something fronts the viewer and offers it a list; a plain local viewer keeps the folder name it always showed. Because each environment on a box is its own viewer process, picking one is a full page load into that viewer, and the menu shows which test bundle each environment is running. The status bar’s.env.<name>overlay switcher is now labelled “env overlay”, so the two senses of “environment” no longer share a name.@unotest/viewer/sessioncarries the two URLs this needs (environmentsUrl,switchEnvUrl) and theViewerEnvOptiontype, both optional — a proxy that does not offer them gets the old behaviour. -
A run started in the first moments after the viewer boots is now discovered: the run watcher finishes its initial scan of the day directory before reporting itself started, instead of filing such a run as history.
-
bundle pushandenv pushno longer send you to “the box’s admin page” for a project token — there is no such page; tokens are issued per project by the box’s operator, and the refusal hints and the missing-token message now say so. -
The vocabulary’s description of
evaluatenow says how extra arguments reach the body — none → nothing, exactly one → the value itself, two or more → one array — matching the runtime, the linter’s hint and the authoring skill; it used to read as if they were positional (evaluate(js, ...args)), and so did the generated DSL typings. -
A scenario that answers a native
confirm()— a “Remove” button that asks first, say — no longer fails whenrun_testauto-attaches the MCP session to the run’s browser, or afterattach_debug_session. The attaching client now applies the same dialog policy the run does (dialogPolicy, accept by default) to the page it watches; before, it listened for no dialogs at all, and Playwright dismisses every dialog on behalf of a client that has no listener — so the second pair of eyes was answering “no” before the run’s own accept could land. The same scenario passed with an exploration session open only because that session happened to keep the run’s browser to itself. Undermanualthe attached client leaves the dialog to whoever handles it instead of dismissing it. -
On a box, an administrator can now see a secret’s value from the guard’s values page — the eye reveals it in the row, the copy button puts it on the clipboard without showing it — and every look is a
secret.revealedentry in the box’s audit trail, naming who, which secret and whether it was shown or copied. The page is one table now (target, variables, secrets), edited in place, with a JSON view that changes the whole environment at once; a secret left as"••••"there is kept as it is. The token doorenv push/env settalk to is unchanged: secrets stay write-only on the wire.@unotest/protocolcarries the contract:boxEnvSecretRevealPath/boxEnvSecretsRevealPathwith their response parsers, the session-door batchBoxEnvValuesReplaceRequest(nullfor a secret means “keep”) andparseBoxEnvValuesReplaceRequest, and the refusal codeunknown-secretfor a reveal or a keep that names a secret the environment does not hold.isEnvVarNamenow refuses__proto__: it is spelled like a variable, but a plain object cannot hold it and the value was silently lost. The CLI explains anunknown-secretrefusal. -
evaluate(js, …args)has always handed the body its extra arguments in one of three shapes — none, the single value, or ONE array for two or more — while the authoring skill described them as positional. The skill now states the rule with both spellings (evaluate('function(n){…}', 21)/evaluate('([a, b]) => …', a, b)), and thelint:evaluate-discouragedwarning adds the same hint when it sees two or more extra arguments; the runtime is unchanged. The skill also gains the getters it left out (count,getInputValue,textContains), the note thatwaitForTextnever sees a textarea’s value, the parser’s refusal of!/indexOf/ string methods, the shared-stand rule against “the first row” (mark your own data, filter by it, clean up), that native dialogs are accepted by the runner, and what to do when the grounder is unavailable. -
While recording with
explore_step/explore_steps, the bare NAME of a scenario variable is substituted only where the step types a value into the page —fill’s value,press’s key,select_option’s value,goto’s url. In a locator’s text or an assertion’s expected text a bare name is now the literal it looks like:assertText(el, "BOX_LAB_PASSWORD")expects those letters, where before the live step silently waited for the secret’s value and the saved test said something else. To reference a variable in a matcher, write{{NAME}}; the reply warns when a literal happens to equal a variable’s name. And a secret’s value typed into a value field is recorded as the secret’s name (the reply says which), so the value never lands in the test file. -
run_testno longer reportsspawn_failedabout a runner that is still starting. Two runs launched together on a busy box took longer than the fixed 10-second wait to write their firstruntime.json, and the second was declared dead while it was booting a browser. The wait now lasts as long as the runner child is alive (up to 60 seconds), ends early when the child joins the run queue, and — when the child really dies — says so with its exit code and the last lines of its stderr, which the runner now writes tospawn.stderr.login its run directory.explore_startnow reports whether the grounder is usable for the session:grounder: {available: true}, or{available: false, reason, hint}when grounding is off, the backend is unreachable, or a required model is not pulled ("embeddinggemma" is not available there — pull it: \ollama pull embeddinggemma“). An intent step in such a session fails with the same words, not with the backend’s raw error; the probe is one short request per session.Secret masking no longer rewrites the runner’s own identifiers in
runtime.json: withLOGIN=admin, the scenario pathguard-admin.js, the test functiontest_guard_adminand the run id kept their names ininspect_runtimereplies instead of turning intoguard-‹secret:LOGIN›.js. Masking by value is unchanged everywhere else; the run id, scenario path, test function and call-stack names are an explicit exception. -
save_exploration_as_test(andgenerate_dsl_from_exploration) now write scoped locators — agetByRole/getByText/locator(css)step under a parent, such as the “Reveal” button inside one table row — as the chain the DSL already runs:locator('tr[data-row="A"]').getByRole('button', {name: 'Reveal', exact: true}). Before, any recorded step whose locator went below its root was skipped withNO_DSL_PRIMITIVE, and the save was refused, although the step had executed and the hand-written form worked. Every locator is now rendered in the same method-chain spelling — narrowing steps too (getByRole('row').filter({hasText: 'Alice'}).first()instead offirst(filter(getByRole('row'), {hasText: 'Alice'}))); both spellings remain valid input, the runtime is unchanged. -
Updated dependencies [23de03c]
-
Updated dependencies [9f2e244]
- @unotest/viewer@0.30.0
- @unotest/protocol@0.30.0
- @unotest/core@0.30.0
- @unotest/dsl@0.30.0
- @unotest/grounder-client@0.30.0
0.29.0 — 2026-09-02
-
New
env push,env setandenv rmcommands send an environment’s values to a box — the target, the variables and the secrets a suite runs with there — from the same files a local run reads (unotest/.env,.env.<env>,.secrets,.secrets.<env>), or from files named with--env-file/--secrets-file..env*become the box environment’s variables,.secrets*its secrets andAPP_BASE_URLits target;UNOTEST_*and empty values stay home and are listed as skipped. A push replaces the box’s layers and reports what it removed;env setreads one value from stdin, never from the command line. Values never print. The commands use the project token ofbundle push, minted on the box with the new--valuesscope — a token without it is refused, so a CI token that pushes suites cannot rewrite an environment’s credentials.@unotest/protocolcarries the wire (box-values.ts) andTARGET_ENV_VAR. The viewer’s messages about box-held variables now point at these commands instead of the box’s shell. -
The Variables panel of a viewer on a box now lists what the box injects into every run of the environment — the target URL, the operator’s variables and the names of the environment’s secrets — as read-only rows with a
boxbadge. It used to readunotest/.env*, which never travel in a bundle, and show an empty panel next to a full run history; a secret’s value is still never sent, only its name. The box passes the names throughUNOTEST_BOX_VARIABLE_NAMES/UNOTEST_BOX_SECRET_NAMES(constants in@unotest/protocol); writes to such a key are refused with 409 and point atboxd.config.mjs/boxd.mjs secret set. -
UNOTEST_BROWSERandUNOTEST_BROWSER_CHANNELnow exist as per-run overrides ofbrowsers[0]andchannelinunotest.config.*.UNOTEST_BROWSERwas shown in the CI manual and the.env.exampletemplate, but nothing read it — a browser matrix ran Chromium three times. A value outsidechromium | firefox | webkit(orchrome | msedge | chrome-beta | bundled) is an error, not a silent default. The config travels with the suite in a bundle, so achannel: "chrome"written on a laptop used to ask a box for a Google Chrome it does not have; a box now setsUNOTEST_BROWSER_CHANNEL=bundledfor every run and the run log names each override. Constants:BROWSER_ENV,BROWSER_CHANNEL_ENV,BUNDLED_BROWSER_CHANNELin@unotest/protocol. -
initwithout--forcestill leaves an existingunotest/.env.examplealone, but the skip now names the keys the current template declares that the file does not — a project seeded on an older version never learnt aboutUNOTEST_STEP_SCREENSHOTS, because nothing compared its file with the template. The file is not touched.declaredEnvKeys()in@unotest/protocolreads active and commented-out keys alike. The DSL reference now documentsUNOTEST_STEP_SCREENSHOTSnext toscreenshot(): readers of that page concluded the every-step mode did not exist. -
shell()tells a missing working directory apart from a missing binary. Node reports both asENOENT, and the old message sent people looking for a tool that was there all along while the real cause was asandbox.shellCwdthat exists on the laptop and not in CI or on a box. The directory is checked before the spawn and the newShellCwdMissingErrornames it and where it came from. -
Updated dependencies [ab296ff]
-
Updated dependencies [64108b8]
-
Updated dependencies [64108b8]
-
Updated dependencies [64108b8]
- @unotest/protocol@0.29.0
- @unotest/viewer@0.29.0
- @unotest/core@0.29.0
- @unotest/dsl@0.29.0
- @unotest/grounder-client@0.29.0
0.28.0 — 2026-09-01
-
BREAKING. The suite is its own npm package now:
unotest/package.jsonpins@unotest/web,unotest.config.*moved intounotest/, and a bundle carries that directory and nothing above it.A bundle used to carry the project’s root
package.jsonand lockfile, and a box rannpm ciover the whole product tree to obtain one dependency. On a real customer that failed outright: their own peer conflict (@nestjs/core@10against@nestjs/websockets@11) was hidden locally behindlegacy-peer-deps=truein~/.npmrc, which does not travel in a bundle and must not — registry tokens live there. The bundle arrived, the install died, the environment never got it. The same class of failure was waiting for anyone with a private registry, a native build or apreparehook. Fixing it by editing the customer’s dependency stack is not an option: a test runner has no business in the dependency graph of the application it tests.What follows from it: we no longer dictate your package manager (the “your project locks with pnpm, and a box installs with
npm ci” refusal is gone — npm is now only aboutunotest/), a box install shrinks from the product tree to the suite’s own dependencies, and upgrading@unotest/webcan no longer collide with your application.The CLI finds the project root by walking UP for
unotest/e2e, the way git finds a repository, socd unotest && npx @unotest/web e2e <name>works as well as running from the root. Relative path arguments resolve against the directory you typed them in.Migrating from 0.27:
npx @unotest/web initgit mv unotest.config.mjs unotest/unotest.config.mjsnpm install --prefix unotestand drop
@unotest/webfrom your application’spackage.json. The old layout is diagnosed rather than ignored: a config left at the project root fails the load with these instructions, because the alternative is a suite running silently on defaults — nobaseUrl, nosandbox, no schedules.Two traps worth knowing about, both named by that diagnostic:
unotest/jsconfig.json— anincludewritten for the old layout points at../node_modules, where the package no longer is, and editor autocomplete for the DSL dies silently. It is your file, so nothing rewrites it:../node_modules/becomes./node_modules/.- a config that derives paths from its own location
(
dirname(fileURLToPath(import.meta.url))) now sits one level deeper, so those paths need a...
On the box side: bundles pushed before 0.28.0 are refused with an explicit message instead of being installed — push them again from 0.28.0 or newer. Without that,
npm ciin aunotest/that has no manifest would walk up the tree (that is how npm behaves), install the old root manifest, and leave the boxunhealthyfor twenty minutes without saying why. -
@unotest/viewer@0.28.0
-
@unotest/core@0.28.0
-
@unotest/dsl@0.28.0
-
@unotest/grounder-client@0.28.0
-
@unotest/protocol@0.28.0
0.27.0 — 2026-08-31
-
viewerstarts quietly: the Nest boot log (a line per module and route) no longer floods the terminal — you get the viewer URL and nothing else. Errors and warnings still print;UNOTEST_DEBUG=1brings the full boot map back. -
The LOCAL viewer now shows the same effective schedule set a box would run — what you see is what a push ships, 1:1.
- Locally the schedule registry reads the merged set through
schedules --json(the same path a box uses): config-declared entries are finally visible, marked read-only (“declared inunotest.config— edit it there”); yaml entries stay editable as before. A runner without a schedules command falls back to the yaml half. bundle pushrecords a fingerprint of the effective set in the bundle manifest. The box re-computes the set in its own environment and — when a config that readsprocess.envcomputes something different there — warns in its log, and the deployed viewer shows a drift line instead of letting “what I see is what I pushed” stand.bundle pushnow refuses to pack a project whose schedules registry cannot be read — the same broken yaml used to upload fine and un-schedule the suite on the box.npx @unotest/web schedulesmarks entries outside the previewable cron subset (not previewable — an executor may drop it), and the new--checkflag turns that mark into a non-zero exit for a pre-push hook or a CI gate.
- Locally the schedule registry reads the merged set through
-
Schedules stop lying on a box, and a wrong cron is caught before the push.
npx @unotest/web schedulesnow previews every entry’s next run —next tomorrow 03:00— on this machine’s clock (an executor ticks in its own timezone, and the output says so). Expressions outside the schedule-builder subset are shown verbatim, never guessed at.- On a box the viewer shows the EFFECTIVE schedule set — the config’s
schedulesmerged withunotest/schedules.yaml, read through the project’s own CLI — as read-only, with a badge: schedules are declared in the repo, and the box runs whatever the deployed bundle declares. Editing there used to write into a disposable bundle copy nothing ever re-read; now the popup points at the repo and the API refuses the write (403).
-
Schedules from the viewer — for collections and single tests.
Right-click a test or a collection in the viewer → Schedule…: a human schedule builder (frequency select — every 5/15 min, hourly, every 2/4/6/12 h, daily, weekly — plus toggle badges for minutes, hours and weekdays; multi-select, so
04+16×00+30runs four times a day). Saved tounotest/schedules.yaml, versioned next to the tests; a box applies it after commit & sync. The Home screen gains alast run | scheduletoggle: in schedule mode tiles are tinted by how often a test runs (grey = not scheduled), with an upcoming list and per-tile next-run times in your browser’s clock.Under the hood:
unotest/schedules.yamlis a new schedule registry;unotest-web schedules [--json]prints it merged with the config’sschedules(the file wins on a duplicate target), so boxes pick both up unchanged.- A schedule entry may now target a single test:
scenario: <ref>instead ofcollection: <name>. unotest-web e2e <name> --scheduled[=index]runs a test the way its entry says (env + prepare, exit 94 when prepare fails) — the per-test mirror ofcollection --scheduled.- Viewer server:
GET/PUT /api/schedules(validated by the same parser the CLI reads the file with).
-
Updated dependencies [c843d21]
- @unotest/protocol@0.27.0
- @unotest/viewer@0.27.0
- @unotest/core@0.27.0
- @unotest/dsl@0.27.0
- @unotest/grounder-client@0.27.0
0.26.1 — 2026-08-30
-
A push now says which environments could not run the suite
The values a suite’s externals need live with the environment on a box — no bundle carries them, deliberately — so a suite pushed to a box that has none of them was accepted happily and went red an hour later, complaining about a missing login. The bundle now carries the NAMES its
.env/.secrets(or their.exampletwins) declare — names only, never values — and the box answers the push with what it cannot supply, per environment, plus the command that sets each one. -
One command cuts the whole release
publish:ecosystemnow also commits the docs pin and cuts the box release (theguard-v<version>tag) when something a box runs changed since the last one —--skip-boxrefuses,--boxforces. Two artifacts from one commit used to be two commands, and the second one is the one people forget: a fleet on yesterday’s daemon while npm has today’s. What is left for a human is onegit push --follow-tags. -
A collection prepares its own environment:
prepare:inunotest/e2e/_collections/<name>.yamlprepareused to live only in aschedulesentry, so it ran on a scheduled tick and nowhere else. A suite that needs a seeded database was therefore red for every other way of starting it — a CI check of a deployment, the Run button in a box’s viewer — with a message about a missing table rather than about an unprepared environment. It belongs to the suite, so it now lives with the suite: the collection’s ownprepare:runs however the collection was started, inside the same queue slot, and a failure still exits 94 rather than pretending the tests ran. Aschedulesentry may still name its own (it wins for that tick), and--no-prepareskips it for a run against an environment somebody else prepared. -
Fix: the mirror shipped the monorepo’s
.secretsverbatimpublish-examplescopiedunotest/.secretsinto the public examples repository as.secrets.example. That was harmless while the file held only the playground’s public demo login, and stopped being harmless the moment a real credential was added to it. Secret KEYS are now spelled out in the generator (values only where they are genuinely public), and a guard refuses to publish when the two lists disagree — the same discipline.envhas had. -
Fix:
bundle push --run --env <name>refused to run--envwas consumed globally, where it selects a local.env.<name>overlay, and never reachedbundle push— whose--envnames an environment ON A BOX. The command every CI job and the box manual use (“—run needs —env”) could not work as documented.bundlenow keeps its own flag; every other command is unchanged. -
Fix: a viewer started for an environment listed no runs at all
A box starts one viewer per environment and names it through
UNOTEST_ENV; the viewer’s launcher ignored that variable and came up on the base runs directory, while runs were filed under.runs.<environment>. Effect on a box: an empty run history right after a suite finished. -
Fix: screenshots stayed blank in the viewer when runs write to a separate artifact root
A run whose artifacts do not live under the working directory — every run on a box, where the sources are a disposable copy of a test bundle — emitted
screenshotevents whose path was relativised against the working directory. That came out as../../../…, which the viewer’s asset route refuses to serve, so every step preview rendered as a broken image. Artifact paths are now relative to the artifact root, which is still the project root on every local run.The failure bundle had the mirror-image problem: it was written to
.unotest/failures/under the working directory, so on a box it landed inside the bundle copy — outside the tree the viewer serves, and discarded on the next bundle push. It now hangs off the artifact root too, which leaves local runs byte-for-byte unchanged.The inspector’s Semantic DOM, Console, Network and Snapshot panes were dark for a third reason, and this one bit local runs as well: they composed the run’s directory in the browser and left out the date shard runs have been filed under since M-10, plus the environment suffix on a box. The viewer’s asset route now takes
?file=<name-relative-to-the-run-dir>and resolves the directory itself — runs root, target suffix, environment suffix and shard are all things only the server knows.?path=stays for the whole paths the server ships on run events. -
bundle pushstrips the project’s own install hooks (postinstall,prepare, …) from the packedpackage.json. A box installs the suite’s dependencies; the project’s hooks are not its business, and they point at files a bundle deliberately does not carry (build scripts, Makefiles), sonpm cion the box died with MODULE_NOT_FOUND. Dependency scripts — native module builds — are untouched, and the push reports what it removed on anote:line. -
Updated dependencies [9424f6b]
- @unotest/protocol@0.26.1
- @unotest/viewer@0.26.1
- @unotest/core@0.26.1
- @unotest/dsl@0.26.1
- @unotest/grounder-client@0.26.1
0.26.0 — 2026-08-30
-
The viewer can now be hosted: it shows which test bundle an environment is running, and lets you switch it.
This is the other half of
bundle push. Nothing changes for a local viewer — everything here appears only when something is hosting it.- The artifact root and the project root are now genuinely separate.
The viewer already took
UNOTEST_ARTIFACTS_ROOTfor the run queue; run discovery, the run index, snapshots, log capture and the viewer’s own lock file now honour it too. Locally the two are the same directory and nothing moves. Where they differ — sources in a disposable copy of a bundle, history in the environment beside it — the history is no longer read out of (or written into) the sources. - Bundle badge in the status bar.
main@1a2b3c4, orwipfor a bundle packed from an uncommitted tree, with the author, the pinned@unotest/weband who switched the environment last. Clicking it lists the bundles pushed to this environment and switches to one — that runs as a maintenance ticket on the host, so it waits for the environment to be free instead of pulling the code out from under a running suite. - Runs remember their bundle.
manifest.jsonandGET /api/runscarrybundleId(fromUNOTEST_BUNDLE_ID, set by the host); the runs list marks the ones that ran on another bundle, so “was this red run even the code I am looking at?” has an answer. - The contract is a file, not a mode.
@unotest/protocolgainsWorkspaceFile— the host writesunotest/.workspace.jsonbeside the artifacts and the viewer reads it, the same way the guard’s session header narrows the UI. No file, no bundle UI; the viewer never learns what a box is. Values of environment variables and secrets never appear in it — only their names. - Queue tickets can name a bundle.
QueueTicket.source.bundleIdis recorded when a run is queued, so a run executes the code it was ordered with even if the environment moved on while it waited.
- The artifact root and the project root are now genuinely separate.
The viewer already took
-
npx @unotest/web bundle push— send your suite to a box, instead of giving a box access to your repository.Terminal window npx @unotest/web bundle push --box https://tests.example.comIt packs
unotest/,unotest.config,package.jsonand its lockfile into one archive and uploads it. That is the whole channel: a box holds no deploy key and no token for anybody’s code and never pulls anything, so nothing is on it that was not pushed to it. (There is no path-scoped git credential anywhere — a key that can fetchunotest/can fetch the product source next to it, which is why this direction is the only one we offer.)- Uncommitted work travels. The manifest records
dirty: trueand the bundle shows up as wip, so pushing a fix you have not committed yet is a normal loop, not a release step. - What would fail on the box is refused here. A scenario that reads a
file outside
unotest/(MODULE_NOT_FOUNDon the box, hours later, on a run nobody is watching), a missing lockfile (npm ciwill not run without one), no@unotest/webin the dependencies, a symlink. Each problem names the file, the line and the fix. - Secrets do not travel.
unotest/.env*and.secrets*are excluded even if committed: environment values belong to the environment and are injected over the bundle when it runs. - In a git project, git decides what belongs. The file list comes from
git ls-files --cached --others --exclude-standard, so whatever your.gitignorekeeps out — generated databases, recordings,.runs— stays out. Content is read from the working tree, not the index. - The id is the content.
bundleIdis a hash of the packed files, not a commit sha (a dirty tree’s sha is not unique), so re-pushing an unchanged suite is an instant no-op and two people packing the same tree get the same bundle. --dry-runpacks and checks without uploading,--out <file>keeps the archive (plaintar.gz—tar -tzfshows what is inside),--jsonprints{ bundleId, status }for a CI job that runs it afterwards.UNOTEST_BOX_URL/UNOTEST_BOX_TOKENsupply the address and the token, which a CI job usually takes from its secret store. Tokens are issued per project on the box.
- Uncommitted work travels. The manifest records
-
Your CI can now ask a box to run the suite it just pushed, and a box can report the result back to GitHub.
Terminal window npx @unotest/web bundle push --run --env test --collection smoke--runsends the second half of the CI recipe: upload the bundle, then order a run of it. Two requests rather than one flag on the upload, because a bundle is content — pushing an identical tree twice is normal and answersexists— while “run it” is an event. The same split is what lets a deploy script run a bundle somebody pushed hours ago.- The box answers as soon as the runs are queued (202) with their run ids. A suite takes minutes to hours; an HTTP request held open for that long is a timeout in somebody’s proxy, not a result. The ids are real from the moment you get them, so a CI log can print a link into the viewer straight away.
--env <name>is required with--run. Which environments a box has is the operator’s decision, and guessing one would be guessing which system gets tested.- Without
--collection, the box runs what that environment normally runs — the collections itsschedulesname. Naming them explicitly is the deploy-check case; leaving it out is the “same as every night” case. --pr <number>coalesces. A newer push of the same pull request withdraws its older runs that are still waiting; a run that already started is never killed. Three pushes in five minutes cost one suite.--jsonreports how many were withdrawn, so “my run vanished” has an answer in the log of the run that replaced it.- Refusals are typed and say what to do: no such environment, an environment belonging to another project, a bundle the box no longer holds, nothing to run.
On a hosted viewer, the queue panel now names the change each machine ticket belongs to (
PR #412@1a2b3c4) instead of a row of identical “ci” entries, and the history gained a this bundle only filter for the moment you are judging the bundle in front of you rather than the last month of runs.Nothing changes for a local
npx @unotest/web viewer: there are no bundles there, so neither the badge nor the filter appears. -
Runs of one project now take turns instead of colliding.
Until now nothing coordinated them: the viewer refused a second run with HTTP 409, and that was the whole defence — a
npx @unotest/web e2ein a terminal, an agent’srun_testand a scheduled suite would all start on top of each other, driving the same browser and the same seeded database at the same time. The 409 protected the UI, not the machine.- A filesystem queue, in
unotest/.queue<target>[.<env>]/beside the runs root it belongs to. A run writes a ticket, waits until it is at the head, takes a slot, runs, and gives the slot back. There is no daemon: whoever holds the slot executes the run itself, so nothing new has to be installed, started or kept alive. Onlyopen("wx"), rename, unlink and mtime — noflock, which lies on network and container filesystems. A crashed producer is reaped by whoever comes next. - Every producer is in it: the CLI (
e2e,collection,author), the viewer’s Run button, and the MCPrun_test(which spawns the CLI). A collection is ONE run — itsworkersstill run in parallel inside it, because a child process carries its parent’s lease and skips the queue. - The viewer shows the queue: the Active panel lists what is waiting,
including tickets a terminal or an agent wrote, with a button to take
any of them back out.
GET /api/queue,DELETE /api/queue/:ticket, and aqueue:changedwebsocket message. - Config:
queue.concurrency(default 1) andqueue.enabled(default true) inunotest.config. Both the CLI and the viewer read the same file, so they cannot disagree about how many slots exist. - Escape hatches:
UNOTEST_NO_QUEUE=1runs without queueing; Ctrl-C while waiting gives up your place in line and runs nothing. - For boxes:
UNOTEST_QUEUE_GLOBAL_DIR+UNOTEST_QUEUE_GLOBAL_SLOTSadd a host-wide budget several environments share (a collection weighs its worker count), andUNOTEST_ARTIFACTS_ROOTputs.runs/.queuesomewhere other than the directory the tests live in — so the history outlives a throwaway copy of the tests.
Migration notes
POST /api/runno longer answers 409. Ordering a run while another one is active used to fail withActiveRunConflictError(HTTP 409); it now succeeds and the run waits. The response changed shape with it:{ runId, kind, ref, pid, startedAt }{ runId, kind, ref, status: "queued" | "running", ticket, acceptedAt }runIdis still final and immediately usable — open the tab on it as before — but the run may not have started yet, andpidis not known at that point.POST /api/runs/:runId/abortcovers both states: it withdraws a waiting ticket or SIGTERMs a running child.Anything that treated 409 as “busy, try later” should now simply order the run. Anything that relied on “only one run can exist” should read
GET /api/queue. To keep the old immediate-start behaviour (without the protection), setqueue.enabled: false. - A filesystem queue, in
-
Scheduled runs: declare what should run on its own, next to the tests it runs.
unotest.config.mjs export default {schedules: [{ collection: "smoke", cron: "0 * * * *" },{ collection: "nightly", cron: "0 3 * * *", prepare: "node seed.mjs" },],};schedules— a new config leaf: collection + cron, plus an optionalenv(which environment the run happens in) andprepare(a command run before the suite). Versioned with the tests, so “what is supposed to run nightly” is in the repo instead of on a machine nobody can see.npx @unotest/web schedulesprints what is declared;--jsonis the machine shape a scheduler reads back. Each entry also carries the collection’sworkersresolved from its yaml manifest, so an executor can weigh the run without parsing yaml itself.npx @unotest/web collection <name> --scheduledruns a collection the way its schedule does: in the entry’senv(unless you set one) and with itspreparecommand first, inside the same run slot (preparing touches the target, which is exactly what the run queue serialises). A failed prepare exits 94, not 1 — “the environment was not prepared, nothing was tested” is a different event from “the suite is red”, and alerting has to tell them apart. A collection listed inschedulesmore than once needs--scheduled=<index>(zero-based) — guessing which entry’s prepare to run is refused.
Nothing in this package ticks.
schedulesis data: no cron parser, no timers, no background runs. Cron is executed by whatever hosts your runs — your CI (on: schedule+unotest-web collection), or a box. A clone of a repo that declares schedules therefore never starts running suites by itself;unotest-web schedulessays so out loud instead of implying a timer that does not exist. -
Updated dependencies [3eede7c]
-
Updated dependencies [6d1c612]
-
Updated dependencies [dbfa36f]
-
Updated dependencies [c43aa00]
-
Updated dependencies [8ec4177]
- @unotest/viewer@0.26.0
- @unotest/protocol@0.26.0
- @unotest/core@0.26.0
- @unotest/dsl@0.26.0
- @unotest/grounder-client@0.26.0
0.25.0 — 2026-08-29
-
Viewer: a pinned Home tab, a multi-row tab strip, and live tiles.
The viewer ships with
@unotest/weband opens vianpx @unotest/web viewer, so the change lands here too. The project-health overview used to render only when no tab was open — it disappeared exactly while you were working. It is now a pinned first tab: always present, never closable, and where “Close All” leaves you. The tab strip wraps into up to three rows instead of scrolling sideways, tabs are pills (middle-click closes,⋮opens the right-click actions), and the strip is a propertablistfor screen readers. A scenario running right now breathes on the overview field: the tile fades under a warm ring, still when paused. Full notes in@unotest/viewer’s changelog. -
Judge: provider edge cases, a startup preflight, and logs.
Vertex
GOOGLE_CLOUD_LOCATION=globalcould not work. The endpoint was built ashttps://<location>-aiplatform.googleapis.com, soglobalaskedglobal-aiplatform.googleapis.com— not a Vertex host. Google’s edge answered 404 with an HTML page, which arrived as “model not found”. The global endpoint has no region prefix; it is now special-cased.globalis the default in Google’s own docs, so this was the first thing a Vertex user hit. A model id containing a slash (publishers/anthropic/models/claude-haiku-4-5) is now passed through instead of being forced underpublishers/google, and a pastedmodels/gemini-2.5-flashno longer doubles the prefix on the Gemini API.Backend replies are classified instead of lumped together.
401/403→configwith the variable to fix (OPENAI_API_KEY, ADC, an expiredUNOTEST_JUDGE_ACCESS_TOKEN), no longer indistinguishable from a model fault.429and5xx→ retried transparently with a short backoff that honoursRetry-After. A single rate limit used to kill a whole scenario. The retry policy is a decorator overJudgeProvider, not a loop inside the HTTP helper, so it covers every provider —claudespawns a process and therefore had no re-send at all. Aclaudeusage limit is deliberately still reported rather than retried: the window is seconds, the limit is not.- A non-JSON error body says the endpoint is wrong rather than quoting an
HTML page back as the backend’s opinion of the model. The body is kept
for
debuglogging. - OpenAI
finish_reason: lengthand Anthropicstop_reason: max_tokensare named as token-limit truncation instead of surfacing as “did not reply with the requested JSON verdict”. - The verdict parser accepts
"PASS"and a JSON object embedded in prose — providers without a JSON mode do both.
UNOTEST_JUDGE_VOTE=Nno longer fails on one bad ballot. The N calls ran underPromise.all, so voting — the reliability feature — tripled the chance that one transient error killed the request. Errored ballots are now dropped as long as a majority of the N calls still voted; a tie among the survivors re-raises the failure rather than picking a side.The service checks its credentials before it says “listening”. Each provider exposes a free probe (an ADC token exchange,
GET /models,claude --version) — no model call, no cost. A failing probe prints the reason and exits non-zero (UNOTEST_JUDGE_SKIP_PREFLIGHT=1waives it), andnpx @unotest/judge --checkruns just the probe for start scripts.GET /healthreports the same thing instead of an unconditional{"ok":true}:503+{"ok":false,"code":"config","error":"..."}, cached for a few seconds. The waiver covers the startup gate only — a service started under it still answers/healthfrom a real probe, and--checkstill answers honestly, because the operator who reaches for the flag is precisely the one whose credentials are dead. A staticUNOTEST_JUDGE_ACCESS_TOKENis validated too, against Google’s freetokeninfo: those tokens live about an hour and refresh themselves never, so “valid at startup” is a different question from “valid now”.ADC that expires on a daily reauth policy is a normal morning state, not a one-off misconfiguration — it should not cost a five-minute run to discover.
New:
UNOTEST_JUDGE_LOG_LEVEL(falls back to theUNOTEST_LOG_LEVEL@unotest/webalready reads). At the defaultinfo, one line per judged request — verdict, attempts, model, latency — with errors on stderr; a run of 21 assertions withVOTE=3is 63 billed model calls that previously left no trace. Atdebug, per call: the effective prompt (rubric including anyUNOTEST_JUDGE_PREAMBLE), each ballot with its reasoning, and the raw provider reply. The judged text is application content — debug writes it to stdout verbatim, which is why it is opt-in and never the default.An oversized request body now returns
413with a reason; the socket used to be destroyed while the client was still writing, so the client sawsocket hang up.@unotest/web: thejudge:verdictrun artifact recordspreamblebesiderubricwhen one is set. The model saw the concatenation, but only the split says which half came from the scenario and which from the environment — without it, “a red step is explainable without a re-run” held only for whoever also knew the env file. A judge request that outlivesUNOTEST_JUDGE_TIMEOUT_MSnow says so, and names the service-sideUNOTEST_JUDGE_CALL_TIMEOUT_MSit may be racing.The Claude CLI provider also stopped leaking a timer: the per-call budget is now our own timer, cleared on every exit path.
child_process.spawn’stimeoutoption does not clear itself when the spawn fails, so a missing binary held the event loop for the whole budget — 15s for a--versionprobe, 120s for a verdict.Breaking (0.x minor):
@unotest/judgeno longer exportsExecFileFn/ExecFileResult; the Claude CLI provider takesrunImpl: RunProcessinstead ofexecImpl, because the prompt moved fromargvto stdin — a long judged text used to overflowARG_MAXand fail as a bareE2BIG. -
Updated dependencies [70f2e11]
-
Updated dependencies [adb9d72]
- @unotest/protocol@0.25.0
- @unotest/viewer@0.25.0
- @unotest/core@0.25.0
- @unotest/dsl@0.25.0
- @unotest/grounder-client@0.25.0
0.24.0 — 2026-08-26
-
Judge: rendered text, one env namespace, preamble and majority vote.
Breaking (judge env, no aliases). Every
JUDGE_*variable of the@unotest/judgeservice is nowUNOTEST_JUDGE_*, matching the client-side names@unotest/webalready used.@unotest/judgeshipped a day before this change with no known installs outside the monorepo, so the rename lands without a deprecation window — the window would never be cheaper. Migration:Old New JUDGE_PROVIDERUNOTEST_JUDGE_PROVIDERJUDGE_MODELUNOTEST_JUDGE_MODELJUDGE_RETRIESUNOTEST_JUDGE_RETRIESJUDGE_TIMEOUT_MSUNOTEST_JUDGE_CALL_TIMEOUT_MSJUDGE_ACCESS_TOKENUNOTEST_JUDGE_ACCESS_TOKENJUDGE_CLAUDE_BINUNOTEST_JUDGE_CLAUDE_BINJUDGE_HOST/JUDGE_PORTUNOTEST_JUDGE_HOST/UNOTEST_JUDGE_PORTJUDGE_TOKENUNOTEST_JUDGE_TOKENTwo names were deliberately not a straight prefixing. The service’s per-call budget became
UNOTEST_JUDGE_CALL_TIMEOUT_MSbecauseUNOTEST_JUDGE_TIMEOUT_MSwas already taken by the client’s whole-request budget — same family, different question. And the service’s bearer token merged into the client’sUNOTEST_JUDGE_TOKEN: it is one secret, and in a shared overlay the two ends have to carry the same value anyway.Behavioral fix: rendered text.
assertJudgeand the DSL querygetInnerText(loc)now readinnerText, nottextContent. Both promised rendered text and delivered the raw source-order concatenation: line breaks gone,display:nonesubtrees and<script>contents folded in. A rubric about structure (“the answer is three bullets”) was unjudgeable, andgetInnerTextreturned text no user can see. Scenarios that relied on hidden text reaching these two functions will now see it excluded.Vertex fixes.
google-auth-librarymoved from optional peer to a regular dependency: as a peer it did not resolve undernpx @unotest/judgeat all, which broke the advertised keyless path for anyone who never installs the package locally. It is still loaded lazily, so non-vertex runs pay nothing. Expired Application Default Credentials now produce “rungcloud auth application-default login” instead of a raw Google OAuth blob, read off the structured response body rather than message text.New:
UNOTEST_JUDGE_PREAMBLE(web side) — text prepended to every rubric in the project, for standing context (“the app under test is a support bot, Russian UI”) that would otherwise be copy-pasted into each rubric. It applies in both local and remote mode; a remote judge stays project-agnostic.New:
UNOTEST_JUDGE_VOTE=N(service side, odd N, default 1 = off) — N independent calls decided by majority. It replacesUNOTEST_JUDGE_RETRIESrather than stacking with it: re-asking is a deliberate bias towardpass, a vote is deliberately symmetric, and running both would quietly restore the bias the vote was chosen to remove. Ballots run concurrently, so a vote costs N provider calls but roughly one call of wall-clock.Wire protocol. New error code
configon the judge’s error responses: the service’s own setup is broken (bad env, expired credentials) and the message carries the fix.@unotest/websurfaces it as aConfigErrorinstead of burying it as a generic “judge unavailable” fault. -
assertJudge local mode: the “needs @unotest/judge installed” ConfigError now appends the real import failure cause — a half-built dist failed the same way and read as a phantom install problem. Also: the viewer run-from-ui dogfood scenario accepts history’s run-length collapse row.
-
Updated dependencies
- @unotest/protocol@0.24.0
- @unotest/viewer@0.24.0
- @unotest/core@0.24.0
- @unotest/dsl@0.24.0
- @unotest/grounder-client@0.24.0
0.23.0 — 2026-08-26
-
LLM-judge assertion for free-form text:
assertJudge(locator, rubric).- New package
@unotest/judge— the judge service (npx @unotest/judge): judges text against a natural-language rubric and returns a structured{verdict, reasoning, model, attempts}. Providers:vertex(Google Vertex AI via ADC, no API keys; temperature pinned to 0) andfake(deterministic, CI-safe). Non-determinism policy lives in the service:JUDGE_RETRIESre-asks onfail, firstpasswins. @unotest/web: new DSL assertionassertJudge(locator, rubric)— the element’s rendered text + the rubric go to the judge; afailverdict fails the step with the judge’s reasoning. Wired via env so the config sits in the--envoverlay:UNOTEST_JUDGE_MODE=off|local|remote,UNOTEST_JUDGE_URL,UNOTEST_JUDGE_TOKEN,UNOTEST_JUDGE_TIMEOUT_MS. Every verdict (pass and fail) is recorded into the run’ssteps.jsonl. Also documented:shell()runs with cwd = the project root (sandbox.shellCwdto override) and returns{stdout, stderr, code}.@unotest/protocol: judge wire types (JudgeRequest,JudgeVerdict,JUDGE_ROUTES) and the newjudge:verdictrun-artifact event.
- New package
-
Judge provider matrix: four new
JUDGE_PROVIDERbackends.claude— spawns the local Claude Code CLI (claude -p, no shell interpretation): auth comes from the Claude Code session, so a subscription works with no API key.JUDGE_MODELis passed as--model(aliases likesonnetwork);JUDGE_CLAUDE_BINoverrides the binary; defaultJUDGE_TIMEOUT_MSis 120000 for this provider.gemini— the Gemini API withGEMINI_API_KEY(same wire format asvertex, temperature pinned to 0; default modelgemini-2.5-flash).openai— OpenAI chat completions withOPENAI_API_KEY(response_format: json_object; default modelgpt-5-mini).anthropic— the Anthropic API (/v1/messages) withANTHROPIC_API_KEY(default modelclaude-haiku-4-5).
openaiandanthropicdeliberately send no sampling params — current reasoning models rejecttemperature; determinism relies on the strict JSON verdict prompt plus theJUDGE_RETRIESpolicy. Still zero provider SDKs: everything is raw HTTP or a local process.@unotest/web: docs only — provider matrix in theassertJudgereference and the.env.examplejudge block. -
Updated dependencies
- @unotest/protocol@0.23.0
- @unotest/viewer@0.23.0
- @unotest/core@0.23.0
- @unotest/dsl@0.23.0
- @unotest/grounder-client@0.23.0
0.22.0 — 2026-08-26
- Updated dependencies [80619a9]
- @unotest/viewer@0.22.0
- @unotest/core@0.22.0
- @unotest/dsl@0.22.0
- @unotest/grounder-client@0.22.0
- @unotest/protocol@0.22.0
0.21.0 — 2026-08-25
-
feat: environments are first-class in the viewer — a switcher, per-env run history
Run artifacts are laid out per environment, as folders. Each environment’s history lives in its own root, mirroring the env-file scheme:
unotest/.runs(base),unotest/.runs.<env>(e.g..runs.stagingfor--env staging/UNOTEST_ENV=staging); the axes compose —.runs-mobile.staging. Indexes (_day.jsonl,_latest.json) are per-env automatically: switching environments is a change of root, not a filter and not a rebuild. Old runs stay in.runs(= base), no migration needed.runsDirFor/projectRunsRoot/projectRunDirForgained an optionalenvNameparameter; the run manifest carriesenvfor self-description. Make sure your.gitignoreuses the wildcard form —unotest/.runs*/(initalready writes it that way for new projects).The viewer knows about environments. A new switcher sits at the top of the Variables panel and in the status bar: base plus every environment discovered from
unotest/.env.<name>/.secrets.<name>(.env.exampleand other templates do not count). The active environment is server-side (GET/POST /api/environments), and all tabs converge via theenv:changedWS message:- the Variables panel shows layers WITH the active environment’s
overlay (previously base only); values coming from
.env.<name>carry a badge; an edit goes to the file where the key is defined; new variables go to base; - Overview and the Runs list show only the active environment — tiles are colored by the latest run in that environment;
- a run started from the viewer gets the active environment’s
UNOTEST_ENV(RunRequest.envis a per-request override); - a run opened from another environment still resolves by runId across
all
.runs*roots.
MCP:
run_test {env}already switched the child process’s environment; its artifacts are now correctly found by the server in.runs.<env>(inspect/step/attach/list_runtimes scan all roots).Four fixes uncovered while shaking this down:
- The
--envoverlay actually reaches the run. Long-lived hosts (viewer, MCP server) flattened the base.envinto their ownprocess.envonloadConfig; children inherited it as ambient (ambient beats files) — the overlay’sAPP_BASE_URLwas silently clobbered by the base value. The viewer now rolls its env back after loading the config; MCP spawns children from a clean pre-flatten snapshot. - Pause/Abort from the viewer work again. Debug commands were
written to the flat
<root>/<runId>/commands.jsonl, while runs have lived in date shards since 0.19 — the write 404’d and abort was silently ignored. Order fixed too: SIGTERM to the own process first (tests AND collections), then the command file. - A collection no longer “dies” in the UI after 30 seconds. The
parent run wrote no heartbeat — the monitor declared it interrupted,
the row vanished from ACTIVE and the tail detached. The orchestrator
now maintains the heartbeat (shared machinery in
@unotest/core), and the viewer additionally treats a flowing steps.jsonl as a sign of life (compatibility with older runners). - No phantom
failedafter a clean exit. The “child exited without artifacts” check looked at the flat path and fired bogusrun-finished: failedevents plus an error toast on every exit.
- the Variables panel shows layers WITH the active environment’s
overlay (previously base only); values coming from
-
Updated dependencies [174d27e]
- @unotest/protocol@0.21.0
- @unotest/core@0.21.0
- @unotest/viewer@0.21.0
- @unotest/dsl@0.21.0
- @unotest/grounder-client@0.21.0
0.20.0 — 2026-08-24
-
Value helpers:
returnis no longer tied to theflow_prefixA helper that reads state and returns a value had to be named
flow_*— the only place the validator acceptedreturn. Butflow_*is an executable entry: the runner accepts it as a scenario and flow discovery offers it to the agent to replay while recording, so a width getter showed up in the list of reusable flows.The boundary now keys off
test_*instead.returnis legal in any helper —flow_*composites and plainsnake_casevalue helpers alike — and still rejected in atest_*entry, where it would mean the scenario silently ended halfway.unotest/e2e/_helpers/viewer.js function overview_header_width() {return evaluate('(() => document.querySelector("header").getBoundingClientRect().width)()');}New linter rule
lint:flow-returns-value(warning) flags aflow_*that returns a value: legitimate when the flow acts and yields its result (email = flow_signup()), a rename hint when it only reads. Override it underlinter.ruleslike any other rule.No migration needed — existing
flow_*helpers that return keep working. -
A failure in the first test of a file is no longer lost, and the dashboard no longer waits for F5.
One run, one
run:finished. The event was written by the executor’s per-entry tap, so a file with fourtest_*()functions left four terminal events insteps.jsonl. Every consumer reads the last one — socollectionreported a scenario green whenever the final function passed and the first one failed. The runner now writes the event once, folding the outcomes of every entry:failed>interrupted>aborted>completed. A run that never got started (no entry function, browser failed to launch) writes a terminal event too — its artifact used to read as “the process died”.The viewer asks for the index again. The first
load()could land while the server was still rebuilding the run index: the answer was “no runs”, and nobody asked again — WS only reports a run starting and finishing. On a project with history the whole overview showed “never run” until a page reload.indexReady: falsenow schedules a retry with a 0.5s → 30s backoff, until the first ready answer. -
Updated dependencies [7957469]
-
Updated dependencies [aab65a5]
-
Updated dependencies [c6efd92]
- @unotest/protocol@0.20.0
- @unotest/viewer@0.20.0
- @unotest/dsl@0.20.0
- @unotest/core@0.20.0
- @unotest/grounder-client@0.20.0
0.19.0 — 2026-08-23
-
The CLI passes termination on to the process it runs.
unotest-webis a dispatcher that spawns the real entry (viewer, a scenario run, the MCP server) and waits; killing the dispatcher used to leave that process alive, reparented to init and still holding its port. Ctrl-C hid it — a terminal signals the whole foreground group — so it only bit tooling that kills the process it started: each such call leaked one server. SIGTERM / SIGINT / SIGHUP now reach the child, and the exit code still comes from it. -
Per-collection parallelism via a
workers:field in the collection YAML.workers: N(integer ≥ 1) inunotest/e2e/_collections/*.yamlsets how many scenarios of that collection run concurrently; omitted = serial.- Precedence: CLI
--workers=Nflag > manifestworkers:> 1. - Viewer: the collection header shows an editable
workersvalue; the setting is stored in the YAML, so CLI and CI runs pick it up too. CollectionMeta(protocol) gains aworkers: number | nullfield.
-
Run history scales: runs are filed under daily shards and the viewer stops polling history it is not showing.
- New on-disk layout
unotest/.runs/<YYYY>/<MM>/<DD>/<runId>/(UTC date the run started). The shard is a pure function of therunId, which already carries the timestamp — a direct link to a run keeps working even with the index deleted. Existing flat.runs/<runId>/history is migrated automatically (see theruns migrateentry). - The viewer’s watcher polls only LIVE runs; finished history never touches
it. Discovery watches the current day’s directory with native
fs.watchand re-attaches at UTC midnight. - A JSONL index per day and per scenario backs the history list. It is fully rebuildable — rebuilt on start, non-blocking — so a corrupted or missing index degrades the listing, never the runs themselves.
@unotest/protocol: newrun-shardandrun-indexmodules (shard path derivation, index record shapes) exported from the root entry.
- New on-disk layout
-
Step screenshots are stored once and shared, and old runs expire on their own — a long history stops growing without bound.
- Identical frames are content-addressed in
unotest/.runs/_blobs/and hardlinked into each run.duand ordinary copies see whole files;cp -a/rsync -Hpreserve the sharing. On one dogfood suite: 45 frames, 2.04 MB by apparent size, 0.96 MB on disk. - Reference counting is the filesystem’s (
st_nlink), so nothing to keep in sync. Blob generations keep the dedup honest past ext4’s 65 000-link ceiling, and a fresh blob is untouchable for an hour so the window between writing it and linking it is never mistaken for garbage. - Retention:
UNOTEST_RUNS_RETENTION_DAYS(whole days, default 180) sweeps runs older than the window at the end of a collection run. It never changes the exit code — a failed sweep is reported, not fatal. @unotest/protocol: newrun-blobsmodule exported from the root entry.
- Identical frames are content-addressed in
-
Upgrading moves existing run history to the new layout by itself, and Playwright moves to 1.62.
- The flat
.runs/<runId>/history is migrated on the first run of the new version. Default is to remove old runs (nobody asked to keep them);UNOTEST_RUNS_LEGACY=keepre-files them into date shards instead. The completion marker is written last, so an interrupted or failed move is retried on the next start rather than silently marked done. - New command
unotest-web runs migrate [--dry-run]— the same migration, runnable FIRST, with a report-only mode. A migration nobody can look at before it happens is one that runs unannounced on someone else’s machine. playwrightmoves to^1.62.0in@unotest/weband@unotest/viewer. Runnpx playwright installonce after upgrading if your browser cache predates it.- Frame capture is now format-parameterised internally, still PNG. WebP was
measured and rejected: Chromium’s lossless
type: "webp"is pixel-exact but 1.5× LARGER than PNG on real frames (3.17 MB vs 2.04 MB on one suite). - Fix: the viewer served step screenshots as 403 after the layout change — its asset allow-list still expected the flat layout. The route now has tests.
- The flat
0.18.0 — 2026-08-23
-
Editor typings for scenarios —
.jsfiles stop looking like broken JavaScript in VS Code / WebStorm: autocomplete, hover docs, signature help and locator-chain resolution, without turning on a second type-checker (checkJsstays off; diagnostics remain the DSL validator’s job).- New shipped file
types/unotest-dsl.d.ts— ambient declarations for every DSL function, generated from the same contracts the validator uses (buildDslVocab()), so it can never drift from the installed version. - New CLI command
unotest-web types— createsunotest/jsconfig.jsonif missing and regenerates the.unotest/types/env.d.tscache (variable NAMES fromunotest/.env/.secrets; values never leave those files). initscaffolds the jsconfig + env cache;scaffoldWorkspace()now includes the jsconfig too.- Upgrade path is automatic:
lintande2ecreate a missingunotest/jsconfig.jsonon the fly (one announcement line) and silently refresh the env cache. Skipped under CI. No action needed after upgrading — run any test orunotest-web lintonce and the editor comes alive. If you keep a customunotest/jsconfig.json, it is never overwritten (re-runinit --forceto reset it). @unotest/protocol: additive optional fields on the DSL vocab —DslVocabArg.enumValues(literal members behindkind: "enum") andDslVocabEntry.variadic.
- New shipped file
-
New package
@unotest/eslint-plugin— unotest DSL scenarios as first-class ESLint citizens. One rule (@unotest/dsl) delegates every check to the validator inside the installed@unotest/web(parser, vocabulary, linter rules,linter.rulesseverities from unotest.config), sonpx eslint unotest/e2eandunotest-web lintreport identical diagnostics; a new validator rule needs zero plugin changes.configs.recommended()(async factory, flat config) wiresunotest/e2e/**/*.js, turnsno-undefoff (implicit globals are the DSL’s design; unknown names are the engine’s job) and declares globals for the vocabulary, your helpers and your.env/.secretsvariable names.@unotest/web: the@unotest/web/dslsubpath now exportsloadLintProjectContext()(config severities + helper discovery + variable names) andlinterRulesToSeverity()— the same loader the CLIlintcommand now uses, so the two frontends cannot drift. -
screenshot()appears in the DSL vocab (completion, hover, signature help, generated editor typings). It always had a validator contract, but its diagnostics contract group was missing from the vocab builder, so every vocab consumer silently lacked it. A parity test now pins the vocab to the contract registry, so a future contract group cannot be forgotten again.@unotest/protocol: newDslVocabCategoryvalue"diagnostic".
0.17.0 — 2026-08-20
-
Upload/API-step DX, driven by 0.16.0 consumer feedback (a multipart upload authored as
apiCall(m, p, {file: …})failed only as a remote 400, with nothing local pointing atupload()):- New DSL function
json(value)— serialize any value to a JSON string for readable assert messages:assertTrue(res.status == 202, json(res.body)). Objects concatenated with+collapse into[object Object]; this is the readable form. - New linter rule
lint:api-call-file-body(warning) — anapiCallbody object with afilekey is sent as plain JSON, no file attached; the diagnostic shows theupload(...)shape to use. Deliberate JSON fields namedfileare kept with// lint-ok: <reason>. - The CLI
e2ecommand now runs the same pre-run lint as the MCPrun_testtool and prints diagnostics before the run (advisory — the run still proceeds). Previously authoring mistakes the linter knows about surfaced only throughunotest-web lint. - Reaching for a JS global in a scenario (
JSON.stringify,Date) now answers with the DSL alternative (json(value),nowMs()) instead of the misleadingadd it to unotest/.enverror. - The generated
unotest.config.mjstemplate documents thesandboxsection (apiBaseUrl,uploadDir,database) —uploadDirwas referenced by the guides but absent from the template.
- New DSL function
0.16.0 — 2026-08-19
-
File oracles as first-class citizens, plus an interaction-state wait (bot-service feedback, round 4).
A background process that speaks by appending JSONL was only half supported:
waitForFilematches a substring against the WHOLE file, so a multi-key event filter was inexpressible — JSON key order is not guaranteed, and"userId":"x","isBot":truebreaks the first time the writer reorders its fields. Every positive wait therefore went through a hand-written node script. Four functions close that:waitForJsonLine(path, filter, {timeoutMs})→ the matching line, parsed. Strict equality per key, dot paths ('user.id') for nesting, unparsable lines skipped (a file caught mid-append is normal). The timeout says how many lines were read, how many parsed, and which one matched the most keys.assertNoJsonLine(path, filter, {withinMs})— the negative twin.waitForFileCount(path, pattern, count, {timeoutMs})→ the count. Waits for at leastcountmatching lines: between two polls a writer can append more than one, and a strict wait would then never see its target.patternis a substring, a regex, or the same key filter.assertFileCount(path, pattern, count)— snapshot, strict equality, for “exactly N happened” after the window has been awaited.
Counting is per LINE (
grep -csemantics), not per occurrence.waitFor(loc, {state})also accepts'enabled'/'disabled'now, alongside the DOM-presence states. It polls the same probeisEnabled/isDisableduse, soaria-disabledwidgets and<fieldset disabled>inheritance count — Playwright’s own wait knows neither. This replaces theforloop aroundpause()that a widget which unlocks on a timeout otherwise forces.Two smaller things that came with them:
- Options objects on the file oracles reject unknown keys. A misspelled
{timeout: 5000}used to be ignored and the step silently waited the default instead. UNOTEST_DEBUG=1gained asteplayer: every executed statement is bracketed withenter/exit(file, line, step label, ms, ok) and every nesteddriver/resolverevent carries the step it ran under. A CLI failure can now be diagnosed by reading the log as step → driver call → answer, instead of matching timestamps by eye.
-
unotest-web viewerlearnsUNOTEST_VIEWER_HOST/UNOTEST_VIEWER_PORT.By default the viewer still binds
localhostplus a free port the OS picks. Set the variables for headless / containerized setups where a reverse proxy reaches the viewer at a fixed address (e.g.viewer:4000inside a compose stack). An invalid port fails fast with an actionable message instead of silently falling back.UNOTEST_VIEWER_NO_OPEN=1still skips opening the browser. -
UnotestError— the typed-error base class — now lives in@unotest/coreand is part of its public API.@unotest/webre-exports it unchanged, so existing imports andinstanceofchains keep working. Motivation: other packages need the same base without copying it, and a published package is the only home they may all import. -
Unknown function: indexOfnow says what to write instead.The DSL has no string or array methods, so a JS habit (
out.stdout.indexOf(x),s.toLowerCase(),JSON.parse(line)) failed with the same bare message as a typo in a helper name — indistinguishable, and the reason bot-service asked for a linter hint back in 0.10.0.The engine stays vocab-agnostic:
FunctionContractRegistrygained an optionalsuggestForUnknown(name)hook, and the web vocab supplies the table (indexOf→textContains,concat→textJoin,JSON.parse→waitForJsonLine,Date.now→nowMs, and so on). A registry without the hook — or a name that is genuinely just misspelled — keeps the old message unchanged.
0.15.0 — 2026-08-17
-
Report collection runs scenario by scenario.
collection <name>used to print a single line —pass=21, fail=1— which told you a scenario failed but never which one, and the child processes’ stdout was piped and then dropped on the floor, so nothing else survived either. A CI job that went red was undiagnosable without downloading the run directory.Each scenario now gets a start line and a result line; anything that doesn’t pass also gets its own output quoted (last 16 KiB, a dropped head is marked) plus the path to its run directory. Failures go to stderr, progress to stdout.
Draining those pipes also removes a hang that was waiting to happen: an unread pipe blocks the child once the OS buffer fills.
Rendering sits behind a
CollectionReporterseam — the orchestrator still only emitscollection-run:*events. -
The examples mirror now tops up
unotest/.envandunotest/.secretswith keys a release added, instead of skipping the files because they already exist. Your values are never touched — only missing keys are appended.The old behaviour broke every upgrade that introduced a variable:
.env.examplegrew the key,.envstayed as it was, and the run died withexternal variable "CATALOG_SEED" not found. The workaround was to deleteunotest/.envby hand — on the runner, and for anyone who forked the examples.
0.14.0 — 2026-08-16
-
Negative window assertions + a string probe (bot-service feedback, round 3):
assertNeverAppears(loc, {withinMs})— watch the window and FAIL the moment the locator becomes visible; pass only when the whole window elapsed with zero sightings. The opposite of everywaitFor*— for “must NOT happen” requirements (a private message must not leak into a public feed, a disabled bot must not reply).withinMsis mandatory: absence is only provable within an explicit window, and the step always costs that long.assertNoFile(path, pattern?, {withinMs})— the file-side mirror ofwaitForFilewith inverted polarity: fail as soon as the file exists (and matchespattern), pass when the window elapsed clean. Replaces the fragile “helper worker + assertTrue(res.code != 0)” inversion.textContains(haystack, needle) → boolean— substring probe for strings that came back as data:assertTrue(textContains(out.stdout, 'ready'), out.stderr).
-
evaluate(js, ...args)now actually passes its arguments. The documented multi-arg form —result = evaluate(`function([a, b]) { return a + b; }`, 10, 20);— reached the page with
undefinedinstead: the driver’sevaluatesignature had no parameter for the argument at all, so the registry handed it over and the driver dropped it. Silently wrong answers, not an error. Whenjsevaluates to a function it is now called with the argument.Also adds the seven functions that were missing from the editor vocabulary (
randomFirstName,randomLastName,randomEmail,randomText,randomNth,waitForCount,screenshot) — they were documented in the DSL reference but invisible to hover and completion. -
Fix
assertValue,isDisabled/isEnabled,setLocalStorage/getLocalStoragein the published build.These four went through functions that Playwright serializes into the page. They were written inline in the driver, so the obfuscation step of the release build rewrote their string literals and member access into string-array decoder calls — and that decoder only exists in Node. In the browser they died with
ReferenceError: _0x… is not definedon every call.assertValuewas affected on ANY element, not just checkboxes: it probes the checked state first.Broken since 0.12.0 (
assertValue,isDisabled,isEnabled) and since the first published version (setLocalStorage,getLocalStorage). Only the built package was affected — running from source was always fine, which is why the test suite stayed green.The functions now live in the un-obfuscated
page-injectentry, next to the snapshot algorithms. Two new guards keep them there: a source check (check:no-inline-evaluate, part ofcheck:surface) rejects any browser-bound function outside that entry, and the bundle smoke — the one step that runs AFTER obfuscation — now exercises all four against a real page.
0.13.1 — 2026-08-15
- Docs: the
textJoinsection overstated the+rejection.+with a string literal on either side ('prefix-' + name) concatenates and lints clean; the validator rejects only literal-free string addition (a + bbetween string-typed variables/calls), which reads as arithmetic. Existing literal-based+code needs no rewrite (bot-service feedback on 0.13.0).- @unotest/core@0.13.1
- @unotest/dsl@0.13.1
- @unotest/grounder-client@0.13.1
- @unotest/protocol@0.13.1
- @unotest/viewer@0.13.1
0.13.0 — 2026-08-15
-
Per-call environments for explore sessions + init guidance (bot-service feedback, round 2):
explore_startacceptsenv: that session’s variables, secrets andAPP_BASE_URLresolve against the.env.<env>/.secrets.<env>overlays, without touching the server’s own environment or other sessions. Overlay secrets are also masked in all log sinks. Theexplore_stepsautoRun finale runs the saved test with the sameenv. Symmetric withrun_test’s per-callenv.init“Next steps” now says to restart an already-open agent session (MCP clients load.mcp.jsonservers at session start — no hot-connect in the protocol); same recipe added to the troubleshooting manual.
-
New DSL function
textJoin(parts) → string— concatenates an array of parts (numbers coerced). The validator has always rejected+on strings with a hint pointing attextJoin([...]), but the function did not exist in the web registry, leaving scenarios with no legal way to build a dynamic string. Found by the dogfood suite on its first lint. -
waitForTextnow respects the activeenterFramestack — it used to build its locator from the top document, so inside a frame it waited on text the frame content could never satisfy. Found by the dogfood suite (nested-iframes scenario).
All notable changes to @unotest/web are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.12.0 — 2026-08-14
-
The active variable environment is now agent-visible instead of docs-only.
explore_startandrun_testreplies carry anenvironmentfield: the active env name (UNOTEST_ENV,null= base) plus every.env/.secretslayer file with anexistsflag. A missing external variable error now also lists the searched layers (Looked in environment "dev" (UNOTEST_ENV): unotest/.env, unotest/.env.dev (no such file)). Tool descriptions forrun_test/explore_startexplain the env mechanism — MCP sessions inheritUNOTEST_ENVfrom the server process (set it in the client’senvblock); CLI runs take--env <name>. The shipped agent guide gains a “Variable environments” section. -
Feedback batch from a real-world integration (bot-service): shell() safety, run diagnostics, boolean-attribute probes, linter ergonomics.
BREAKING —
shell()fails the step on a non-zero exit code. Previously a non-zero exit resolved silently and a forgottenres.codecheck produced false-green tests. Migration: calls where non-zero is expected (probes,grep -q) pass{allowNonZero: true}as the last argument and keep checkingres.code; calls that assertedassertTrue(out.code == 0, ...)can drop the assert. More shell() hardening: a missing binary is a distinct “failed to start” error (was a fakecode: 1), output over 10 MiB fails loudly (was silent truncation), and every call has a wall-clock budget (sandbox.shellTimeoutMs, default 120 s, or per-call{timeoutMs}) — a detached grandchild holding the stdio pipes no longer hangs the scenario forever; the timeout error explains the detach recipe.New capabilities:
waitForFile(path, pattern?, {timeoutMs})— poll a file until it exists / contains a substring or regex; the file-sidewaitForTextfor background processes. Returns the content.sandbox.exportSecrets: [names]— explicit allowlist of.secrets*variables exported intoshell()subprocess env (still masked in logs). Secrets stay out ofprocess.envotherwise.isDisabled(loc)/isEnabled(loc)— honest disabled probe (native:disabledincl. fieldset inheritance, oraria-disabled);hasAttribute(loc, name)— presence probe for boolean attributes (getAttributereads""for present and absent alike).- Generators:
randomWord(len)(letters only — digit-free markers),nowMs(),today(),daysFromNow(n)(previously documented in the skills but not implemented). - Failed runs report the enclosing step:
✗ test: … in step "…"in the console,stepLabelin the failure bundle andinspect_runtime. - Every CLI run prints a one-line header: active environment, layer
files (missing ones flagged) and effective
baseUrl;run_test/explore_startreplies echobaseUrlnext toenvironment. run_testaccepts per-callenv— the spawned runner child getsUNOTEST_ENV=<env>for that run only.- Linter:
// lint-ok: <reason>silences warning/info diagnostics on its line (reason required; errors never suppressible); newlint:echo-assertwarns whenwaitForText/assertTextoverlaps a value the scenario itself filled (chat echo trap);lint:goto-concat/lint:regex-invalidare now configurable inlinter.rules(they were emitted but missing from the config schema). - A
_helpersfile that fails to parse is reported as the root cause inunotest-web lint(and explore replies listunparsableHelperFiles) instead of silently cascading into “Unknown function” at every call site. The viewer logs the same condition (patch).
0.11.0 — 2026-08-13
-
e2eCLI argument diagnostics: extra positional arguments are rejected instead of silently ignored (with a “did you mean: unotest-web e2e” hint for a duplicated subcommand); an argument that resolves to a directory gets its own message instead of the misleading “.js extension” error; unknown scenario names now suggest close matches from unotest/e2e/**(newsuggestClosest/levenshteinDistanceutilities in@unotest/core). Thecollectioncommand also rejects extra positionals. -
Failure artifacts are always on, and steps can carry screenshots.
- On any scenario failure the runner writes
screenshot.png+page.html(runtime DOM, open shadow roots included, secrets redacted) tounotest/.runs/<runId>/failure/— noUNOTEST_DEBUGneeded. The.unotest/failures/bundle gains the samepage.html; its screenshot keeps honouringfailureBundle.tier2. - New env flag
UNOTEST_STEP_SCREENSHOTS=1captures a PNG after every executed step intounotest/.runs/<runId>/screenshot/. - Protocol:
screenshotevents may carry the step’sfile/line/col. - Viewer: a step row with a captured screenshot shows a camera icon and
toggles an inline preview on click (both
screenshot()DSL captures and per-step ones).
- On any scenario failure the runner writes
-
Parse errors now carry an exact position and a caret code frame.
- Every lexer failure is a
ParseErrorwithline/column(previously bareErrorwith no position). - New
formatCodeFrame(source, line, column)/formatParseErrorexports in@unotest/dsl. - The web runner’s “parse error in
” message now includes <file>:<line>:<col>plus the code frame — no more hunting for an “unexpected LBRACKET” by eye. - Token columns are now uniformly 1-based at the token start.
Previously identifier/number/string tokens were 0-based while
operators were 1-based, so carets and editor squiggles missed by one.
Migration note:
line:colbreakpoint keys stored in.debugger[-suffix].jsonagainst word-initial statements shift by +1 — re-set those breakpoints once.
- Every lexer failure is a
-
BREAKING: multipart uploads in
apiCallare now opted into explicitly with the newupload(path, {field?, fields?})DSL function — the{file, field?, fields?}body-shape magic is removed.A JSON body containing a
filekey now always posts as JSON (the old key-name detection broke JSON APIs with a legitimatefilefield —{file: 'x.jsonl'}tried to read a file from disk and died with ENOENT).Migration:
// beforeapiCall("POST", "/documents", {file: "fixtures/doc.pdf",fields: { source: "e2e" },});// afterapiCall("POST","/documents",upload("fixtures/doc.pdf", { fields: { source: "e2e" } }));upload()must be the body itself (a nested{doc: upload(...)}is rejected with a pointer to the correct usage). Path containment insandbox.uploadDirand the Content-Type rules are unchanged. -
Fixes and DX follow-ups from the real-time-polls field check:
- Fix:
unotest/.runs/<runId>/failure/was never written. The runner derived the run dir from its internal runtime id (r-N…) while the on-disk dir is named bymakeRunId(scenario), so the always-on failure artifacts silently skipped. The CLI now passes its run dir into the runner (RunJsScenarioOptions.runDir), and the failure screenshot + page.html land where documented. initre-syncs tool-owned skill copies..claude/skills/*and.claude/hooks/*shipped by the package are overwritten on a plaininitre-run when they diverge from the installed version (statusupdated) — stale skills after a package upgrade no longer require--force. User-scaffolded files keep the skip-unless---forcebehaviour.- The MCP
agent-test-authorprompt caught up with the runtime: multipart viaupload(...)and theobj.prop[i]workaround are now documented there too, and both facts are enforced bycheck-skill-prompt-sync.
- Fix:
-
Screenshots are no longer saved as blank white PNGs.
goto()resolves on theloadevent, which on any client-rendered app fires before the first frame carries content — so a per-step capture (UNOTEST_STEP_SCREENSHOTS=1) of a step that ended ongoto(), or ascreenshot()call placed right after one, froze the pre-render frame. Both paths now settle first: wait for the network to go quiet, then for a painted frame, capped at 2s total. A page that never goes quiet (polling, live feeds) spends the cap once per capture and is then shot as-is; the wait never fails a run. The failure screenshot deliberately skips the settle — it must show the page as it was at the failure.Adds
DriverPage.waitForLoadState(state, opts?)— diagnostics-only, backing the settle. It is not exposed to the DSL: a test that waits on “the network went quiet” instead of on visible app state is the flaky pattern the linter rejects.
0.10.0 — 2026-08-06
-
API-setup primitives for pure-
_helpers/test setup (multi-host RAG smoke flows and similar):apiCall(method, path, body?, headers?, options?)gains{base: 'API_BASE_X'}— the NAME of aunotest/.envvariable holding an http(s) base URL. Default behavior (path againstsandbox.apiBaseUrl) is unchanged; URLs still never appear in scenarios.apiCallmultipart upload: a body of the exact shape{file, field?, fields?}postsmultipart/form-dataviafetch+FormData. The path is relative and confined tosandbox.uploadDir(new optional config field; defaults to the project root) — absolute paths and../escapes throw, and a manualContent-Typeheader with a multipart body is rejected.- DSL validator now allows
breakinside loop bodies (break-outside-looperror elsewhere) and index access on data variables (docs[0].objectsCount); indexing a Locator is rejected aslocator-index-access, andwhile/do…while/continue/arr[i] = vstay unsupported. Parser and executor already supported these forms; only validation changed.
-
Assert steps ground as whole units.
Intent.prefergains"unit": the grounder returns the semantic unit itself (heading / record / text) and suppresses the record→control hop — the profile for read-only targets. Web assert actions (assert_text/visible/hidden/value/count) declareintentTarget: "unit"on their plugins, and the intent resolver materializes a DOM ref for unit resolutions via the newmaterializeRefScript(nodeId)(grounder-client dom-walker): one lazy attribute write on the unit’s own element, reused by later captures. Previously an assert intent like “the page heading” was rejected with a RECORD error and forced afind_elementdetour. Control-target errors now name the unit kind correctly (heading / record / text block) and point at assert steps for check-not-click intents.Materialized record containers now resolve to a stable locator. A record row / card / tree-item (
<li>,<tr>,<article>) carries no accessible name of its own — its text lives in children — soRefResolvergains a container strategy:getByRole(role).filter({hasText}), keyed off the container’s short descendant text (capped so a whole table is never captured) and verified for uniqueness + ref identity. Fixesassert_visibleon a grounded tree item failing withno stable identifier. -
assertValueon a checkbox / radio / switch now asserts its checked state instead of the DOMvalue. Their value is the submitted payload ("on"by default) and is identical whether the box is ticked or not, so the old behaviour made a correct test fail on DOM trivia (live agent run:assertValue(checkbox, "true")→expected "true", got "on").Pass
'true'/'false'; any other expectation on a checkable element is rejected with a usage error naming the rule. Text inputs, textareas and selects are unchanged. Applies to theassert_valueMCP action too.Migration: a test asserting the literal payload —
assertValue(checkbox, 'on')— now fails with that usage error. Assert the state ('true') instead; to read the raw attribute usegetAttribute(loc, 'value').The
WebDriverpage contract gainscheckedState(loc)(boolean | null, null for non-checkable elements) — relevant to custom driver implementations.assert_valuealso grounds intent locators as a control now (intentTarget: "control"). Under the previous"unit"profile an intent like “selection checkbox of the Pending row” resolved to the ROW, materialized a ref on the<tr>, and failed withNode is not an <input>. The read-only asserts (assert_text/assert_visible/assert_hidden/assert_count) keep the unit profile. -
Chat-feed-friendly wait primitives (ai-support plan, tasks 5–6):
waitForTextnow waits for ≥1 VISIBLE occurrence instead of failing strict mode on duplicated text;{first: true}becomes a no-op. New{exact: true}option and regex matchers (waitForText(/\bhi\b/)) fix substring-inside-a-word false matches.- New
waitForCount(loc, n, {timeout, exact})DSL primitive andwait_for_countexplore action: polls until the locator matches at leastnelements (exactlynwith{exact: true}) — the “wait for reply №N” pattern without index locators; counts as a verification step for autoRun / NO_VERIFICATION.
-
Shared injected DOM helpers: one
createDomHelpers()factory (visibility, ARIA roles, W3C accname, state flags, ref minting) now backs the grounder-client DOM walker and the web snapshot/find algorithms, composed into the page bycomposeInjectedScript— replaces four hand-maintained copies of the same in-page logic.Breaking (
@unotest/grounder-client):captureRawTreetakes the helpers object as its first parameter, so passing it straight topage.evaluate(captureRawTree)no longer works (evaluate arguments are JSON-serialized and cannot carry the helpers). Migration: inject the serialized script instead —page.evaluate(domWalkerScript())— which composes the helpers automatically. New exports:createDomHelpers,composeInjectedScript,InjectedDomHelpers,DomHelperOptions,InjectedScriptOptions.@unotest/webbehavior deltas from unifying on the walker’s ARIA mapping:get_aria_snapshotnow mapsdltolist(dt/ddstay name-only) andtbody/thead/tfoottorowgroup, and never names structural containers from their text content;find_elementmatches against the full W3C accname (label-for lookup, composed multi-child names) instead of a reduced approximation. -
Recorded assertions and one-call test authoring in exploration mode. Six new recordable actions —
assert_text,assert_visible,assert_hidden,assert_value,assert_count,assert_url— available inexplore_step,explore_stepsandexplore_record; they execute live through the same poll-until-timeout cores as the runtimeassertText(...)family (extracted todsl/assert-core.ts), so a recorded assert has already passed against the real page and the generated DSL carries the matching assert lines. Assert steps count as verification for theNO_VERIFICATIONsave gate. NewautoRun: trueflag onexplore_steps(requiresexplorationId): when every step succeeds, a verification step is recorded, the draft has no blocking warnings (FRAGILE_LOCATORis informational — it passes and rides along in the reply;DYNAMIC_TEXT/NO_DSL_PRIMITIVEblock, mirroring save’s policy) and the target file is new, the same call stops the session, savesunotest/e2e/<scenarioName>.js, resets the browser context and runs the test withrun_testsemantics (autoRun.run.next.outcome); any gate failure degrades toautoRun.status: "skipped"+ reason with the session left active. The green happy-path shrinks tonew_context→explore_start→explore_steps {…, autoRun: true}.UNOTEST_TOOLSET=coreunchanged — asserts are actions, not tools. -
Two MCP round-trips removed from every authoring flow.
explore_step/explore_stepsnow reply withurlwhenever the step CHANGED the page address — the same curegroundedTogave intents, applied to navigation: after a batch the agent reads where it ended up instead of spending a call onget_active_context/get_url. Unchanged addresses print nothing, so a batch of ten clicks on one page adds no lines.explore_startopens the browser context itself when none is open, so thenew_contextprologue is gone from the happy path.new_contextkeeps its role as the explicit RESET (between the recording and the first run, and insideautoRun); an existing or attached collaborative context is never re-created. -
Steps whose locator was an intent now report
groundedTo— the element line the grounder resolved it to (checkbox "Select row R-00147" (in row "Row R-00147: … Pending …")), per locator argument. Present onexplore_step, every step of anexplore_stepsbatch, andexplore_record.Until now a step’s reply said only
executed / recorded / success, so an agent could not tell WHICH element a positional intent hit without a separateground_elementprobe — grounding the same intent twice. A live run spent 4 extra calls and doubled grounder time doing exactly that. The skill and the tool descriptions now point atgroundedToinstead. -
New
screenshot(name?, {fullPage?})DSL command — on-demand PNG capture of the active page intounotest/.runs/<runId>/screenshot/<NNN>-<name>.png(returns the project-relative path). Evidence tool for green runs: fixes what the page actually looked like at a chosen point. Each capture is also mirrored as ascreenshotRunArtifact event, so the viewer’s event stream links to it. In runtimes with no artifact dir (exploration) the call logs a warning and returnsnullinstead of failing the run. -
The
write-e2e-test-groundskill now records batch-first: when the task brief already spells out the step sequence, Phase 2 sends it as oneexplore_stepscall (goto included, intent locators ground at execution time) instead of oneexplore_stepper action, with a repair recipe for mid-batch failures. A/B across four eval tasks showed equal-or-better scores with fewer calls, lower cost, and 9–38% less agent time. The snapshot-mode skill is unchanged — its ref discipline requires a snapshot before locator steps, so pre-discovery batching does not apply. -
Dedup pass across the tree (jscpd 0 clones, ratchet threshold 0). New public export in
@unotest/grounder-client:intEnvInRange(env, name, def, min, max)— theUNOTEST_GROUNDER_*integer-env parser shared by the consumer side and the grounder server. Everything else is internal refactoring with no behavior change: protocol gains a shared fs-safe-name validator behindvalidateCollectionName/validateSegmentNameand a shared env-file assignment iterator; core’sRuntimeInspectionbecomes aPickofRuntimeStateFile; dsl’sExecutionWalkerthreads oneWalkCtxobject instead of an eight-argument clump and AST nodes use constructor parameter properties; web deduplicates the MCP tool scaffolds, action plugins, DSL contracts, perception LRU caches, and semantic-dom grouping passes. -
Grounder infra resilience. The MCP server now runs a cheap grounder reachability ping at startup when
UNOTEST_GROUNDER_MODEislocalorremote(/api/tagsfor local ollama,/healthfor a remote grounding server) — non-blocking, warning-only. Network-level failures on the intent /ground_elementpath are wrapped into a typedGrounderUnreachableErrorciting the backend URL, a mode-specific fix hint and the boot-time health verdict, instead of surfacing undici’s rawfetch failed. The ground-mode skill now spells out the exact fallback recipe: a single{kind:"intent"}step, thereffromfind_element, or the ready-madelocatorobject fromground_element— hand-assembledgetByRole/csslocators are rejected in recording mode.
0.9.2 — 2026-06-16
Added
- Run snapshot export. A finished run can be exported as a portable
*.unotest.zipbundle (events + captured artifacts) for a teammate to open in their own viewer. The runner now recordsconsole/networkcapture flags in the run manifest so a re-rendered snapshot shows “capture was off” rather than an empty console, andunotest/.snapshots/is added to the generated.gitignore.
Fixed
- Locator picker survives navigation and reaches into shadow DOM. A navigation
that tore down the JS context mid-drain now surfaces as a typed
ExecutionContextDestroyedError, so the picker overlay reports not-alive and self-heals (re-stamp refs + re-inject) instead of logging a dead end. Hit-testing descends through open shadow roots and ref-lookup climbs back out across shadow boundaries, keeping the picked element and its stamped ref symmetric with the snapshot capture.
0.9.1 — 2026-06-14
Added
check_locatorMCP tool + runtime locator probe. Evaluate a DSL locator string against the live page and see what it actually matches — count, the N a trailing.first()/.last()/.nth()silently collapsed, and where each match points — instead of guessing or re-running the whole test. Same parser/core (probe-locator) the runner uses, so a result here equals the runtime locator.- Interactive debug + auto-attach.
run_test --debugauto-attaches the MCP session to the paused run’s shared browser, soget_page_snapshot/find_element/check_locatorhit the page being debugged. Collaborativeprobecommand (the human’scheck_locatortwin) is handled by the runner. - Agent tool calls in the viewer’s System console. A new
StepsToolEventSinkpublishes each MCP tool call + result (secret-redacted, capped) into the attached run’ssteps.jsonl, so the human sees what the agent is doing. - Run breakpoints published to
runtime.json(.debugger.jsonseed +--breakpointsoverride), so the viewer paints gutter dots for non-persisted breakpoints.
Changed
- Shared runner machinery extracted to
@unotest/core(run-artifact / run-manifest / runtime-state writers, debug-commands watcher, env reader) and consumed via thin web bindings.
Fixed
abortnow actually terminates a paused run. A{cmd:abort}on a run paused mid-statement could not unwind the suspended executor, leaving the process + headed browser alive with a fresh heartbeat (the viewer showed a ghost “active” session). Abort now force-terminates the runner the same clean way Ctrl-C / SIGTERM does, emitting a terminalrun:finishedand closing the browser.
0.9.0 — 2026-06-11
Added
- Opt-in capture artifacts for the viewer inspector.
UNOTEST_NETWORK=1records each request/response to the run’snetwork.json;UNOTEST_CONSOLE=1records the browser console toconsole.json. Written per run (any outcome), redacted of secrets. Off by default — zero overhead. runtime.jsonfor every run. The runtime-state writer (vars / scope / call stack) now runs for plain headless runs too, not just--debug, so the viewer’s Vars inspector is populated on any run.- Friendly relative-
gotoerror.goto('/path')with nobaseUrlconfigured now throws a typedNavigationErrorthat names the URL and the fix, instead of Playwright’s “Cannot navigate to invalid URL”. WhenbaseUrlis set, Playwright resolves the relative path as before.
Changed
- MCP collaboration is discoverable.
get_active_contextnow reports an attachable live debug session (attachable+ a hint); the no-active-context error points atattach_debug_session; the serverinstructionsmention joining a human’s live session.findShareableRunis shared betweenattach_debug_sessionandget_active_context. initdefaults. Generatedunotest/.envsetsAPP_BASE_URLto the unotest playground and the example scenarios use relativegoto('/...');API_BASE_URLandPROJECT_ROOTare dropped;UNOTEST_NETWORK/UNOTEST_CONSOLEare added under Runner config.
0.6.2 — 2026-06-05
Added
- MCP server
instructions. The server now ships start-of-sessioninstructions(injected by the MCP host into the agent’s context the moment the server is approved), pointing the agent at thewrite-e2e-testskill and the canonical MCP tools before it free-researches the repo. initwrites/merges Claude Code config.initnow copies thewrite-e2e-testskill as a real skill directory (.claude/skills/write-e2e-test/SKILL.md, so/write-e2e-testand auto-invoke work) plus an agent-trace hook (.claude/hooks/log-agent.sh, a no-op unlessUNOTEST_DEBUGis set), and merges managed blocks into the consumer’sCLAUDE.mdand.claude/settings.json(pre-approve MCP tools, register the trace hook) without clobbering user content.
Changed
- CLI help uses
examples/01-smokeas thee2eexample (runnable example scenarios live underunotest/e2e/examples/).
Added
- External variables and secrets. Scenarios reference external values
by bare
UPPER_SNAKEidentifiers (APP_BASE_URL,TEST_USER_EMAIL) instead of hard-coding them. Non-secret values live inunotest/.env, secrets inunotest/.secrets(gitignored); both are created byinitand loaded fresh on every run. Secret values are masked as‹secret:NAME›in logs, debug output, and recorded artifacts, so they never reach a saved file in plaintext. - Reusable flows. Recorded exploration steps can be tagged with a
flowgroup and extracted on save into aflow_<name>()helper underunotest/e2e/_helpers/; the generated test calls the helper instead of inlining the steps. The newexplore_run_flowMCP tool replays an existingflow_*helper without re-recording it, andexplore_startnow surfaces the available flows and the declared variable names. - Run source snapshot. Each run writes a
sources.json(the entry scenario plus every loaded helper as they were when the run began), so a run always renders against the code that actually executed even if the file is edited afterward. (Consumes@unotest/protocol’sRunSources.) - New scenario linter rules:
scenario-in-root(scenarios must live in a feature subfolder) andmustache-in-dsl(flags{{NAME}}template syntax inside DSL string literals).
Changed (BREAKING)
- Scenarios must live in a feature subfolder. A scenario placed
directly in
unotest/e2e/is now rejected by the linter — useunotest/e2e/<feature>/<name>.js(thee2e/root is reserved for_helpers/). The MCPscenarioNameargument (explore_start/save_exploration_as_test) likewise requires<feature>/<name>form, andinitscaffolds the welcome smoke test atunotest/e2e/welcome/smoke.js. {{NAME}}inside a DSL string literal is now a lint error. That form is only for MCP recorded values; in scenario code, reference a variable as a bare identifier (fill(loc, PASSWORD)).
Fixed
- Windows: path handling. Run-artifact paths (entry,
sources.jsonkeys, stepfileevents) are normalized to project-relative posix, so the viewer’s asset allowlist and source mapping match on Windows — a native\previously broke them. - Actionable error when no browser is installed. Playwright launch now
preflights the browser binary and fails with a clear message hinting
npx @unotest/web install-chromium, instead of a raw stack trace; launch failures are printed to the console instead of exiting silently.
0.5.0 — 2026-05-29
Added
- Recording mode (exploration sessions). Eight new MCP tools —
explore_start,explore_stop,explore_state,explore_step,explore_record,explore_remove_step,generate_dsl_from_exploration,save_exploration_as_test. The agent drives the browser throughexplore_step, which both executes the action and records it into a session log. When done,save_exploration_as_testgenerates a DSL scenario and runs it viarun_test. Session data persists as JSONL underunotest/.explorations/(override viaEXPLORATIONS_DIR). - Ref resolve-on-record. Wire-only
{kind:"ref",ref:"eN"}locators emitted byget_page_snapshotare resolved to stable forms (getByTestId/getByRole/getByLabel/getByText/locator) at record time. The persisted scenario contains no stale refs; execution still uses the original ref-locator for precise targeting.
Changed (BREAKING)
- 14 per-action MCP tools removed.
click,double_click,fill,hover,press,check,uncheck,select_option,scroll_into_view,goto,reload,wait_for,wait_for_text,wait_for_urlare replaced byexplore_step { action, locator, … }. Without anexplorationId,explore_stepruns the action ad-hoc with the same semantics as the removed tools.
0.4.0 — 2026-04-25
Added
- Debug logging v2 — folder per call (
UNOTEST_DEBUG=1). Each MCP tool call gets its own directoryunotest/.debug/<session>/<NNN>-<tool>/containing:call.json(args, events, result),before.html/after.html(full shadow-pierced DOM),before.txt/after.txt(outline),diff.txt(line-level LCS diff of outline),aria.yaml(ARIA tree),resolved/<ref>.html(outerHTML of the resolved element). On failure:page.html+screenshot.png. Levels:=1/=fullall artifacts;=summarytool + snapshot only, no HTML; unset — no I/O. audit_last_runMCP tool. Deterministic rule-based audit of the last debug session: catchesref-before-snapshot,resolve-loop, missingsectiondescriptions, and selector downgrades.get_last_mcp_logfilters.errors,layer:N,tool:N,exploration:<id>— narrow the JSONL log to relevant events.- Resolver:
data-*stable-id hints.data-id,data-guid,data-keyand similar attributes are detected and used as primary CSS selectors ([data-id="…"]), making table-row locators stable across re-renders. - Resolver: tooltip / label
data-*hints.data-tooltip,data-shadow-title,data-original-titleand similar attributes provide display names for icon-only controls. - Resolver: clickable-row detection. Non-control elements with
cursor:pointeroronclickand a stabledata-*id appear in the outline as[clickable]entries, collapsing large grids to the meaningful rows. - Ancestor-scope disambiguation. When a best locator candidate is
ambiguous (multiple matches), the resolver climbs to the nearest
unique ancestor and emits a single combined CSS selector
(
<ancestorSel> <childCss>) rather than failing.
0.3.0 — 2026-03-18
Added
UNOTEST_DEBUGlogging. SetUNOTEST_DEBUG=1to write a structured JSONL log tounotest/.debug/mcp-latest.jsonl. Captures tool calls, resolver decisions, and driver events. Readable via theget_last_mcp_logMCP tool.get_last_mcp_logMCP tool. Returns the last N lines of the debug log. Agents use it to self-diagnose stale refs and resolve failures without human intervention.- Shadow DOM full-capture.
before.html/after.htmlartifacts pierce shadow roots viagetComputedStyle— plainouterHTMLmisses shadow-hosted content.
Fixed
- Outline renderer truncated role names longer than 32 chars; now preserves full name.
RefResolveremitted duplicategetByTextselectors when two adjacent elements had identical visible text; now falls back to positional nth-selector.
0.2.0 — 2026-01-08
Added
- Local results viewer (
unotest-web viewer). Opens a browser UI showing live test results, step logs, and screenshots. Backed by@unotest/viewer— installed automatically as a dependency. open_viewerMCP tool. Starts the viewer server in the background and returns its URL. Subsequent calls reuse the running instance.- Collection runner (
unotest-web collection <name>). Runs every scenario listed inunotest/e2e/_collections/<name>.yamlunder a shared parent run-id. Supports--workers=Nfor parallel execution and--bailto stop on first failure. install-chromiumcommand. Downloads Playwright’s bundled Chromium (~150 MB) for environments without a system browser.list_runtimesMCP tool. Returns all active scenario runtimes in the current session (run-id, scenario name, status, step count).
Fixed
unotest-web initfailed silently when the target directory existed but was missingunotest/; now creates missing subdirectories.run_testreturned a stale run-id when called concurrently with an in-progress run; each call now allocates a fresh id atomically.
0.1.0 — 2025-10-01
Initial release.
Added
- DSL — JavaScript scenarios on a sandboxed AST engine. Scenario
files are
.jsinunotest/e2e/using Playwright-vocabulary primitives (goto,click,fill,getByRole,getByTestId,assertText, and ~54 others). The AST parser (now published as@unotest/dsl) supports method chains, options-object literals, and raw backtick strings. - MCP server with 37 tools. Bundled server (
unotest-web mcp, default subcommand): 6 debugger tools (run_test,step,resume,inspect_runtime,abort_runtime,list_runtimes), 21 browser interaction tools, 4 multi-context tools (list_pages,switch_page,new_context,close_context), 6 failure-bundle tools, andagent_fix. - Semantic DOM canvas. Inspection layer producing a compact, agent-readable view of the page (role / name / relevant attributes). Used for both live snapshots and failure bundles.
- Failure bundle — tier 1 + 2 capture. On test failure the runner
writes
failure.json,console.json,snapshot.json, andscreenshot.pngtounotest/.runs/<runId>/. Accessible vialist_failures,get_failure_trace, and related MCP tools. agent_fixworkflow.mcp__unotest-web__agent_fixbuilds a structured fix-context bundle (last failure, relevant DOM, scenario excerpt). The package does not call an LLM or auto-apply patches — human review is required.- AST scenario linter (
unotest-web lint). Six rules: deep CSS selectors, XPath, obfuscated class names, explicitpause()calls, disambiguate-by-index, and discouragedevaluate()patterns. Severity is config-driven viaunotest.config.*. - Project-agnostic sandbox primitives.
shell(cmd, …args)(execFile, no shell interpolation),dbQuery/dbExec(lazy peer dependency — pg / mysql2 / better-sqlite3 resolved fromsandbox.databaseURL),apiCall(method, path, body?, headers?)(againstsandbox.apiBaseUrl). Consumer-specific seeders live inunotest/e2e/_helpers/. - Cross-browser support. Chromium, Firefox, WebKit via Playwright.
unotest-web init. Bootstrapsunotest.config.mjs,unotest/e2e/smoke-welcome.js,unotest/e2e/_helpers/,unotest/.env, MCP config, and Claude Code skill into any project.