This documentation is available as Markdown. For the complete index, see llms.txt. Skip to content

Changelog

For the complete documentation index, see llms.txt

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 from UNOTEST_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 as cli).

    A scheduled series is announced on a change of state: failed after afterFailures red ticks in a row, recovered, still-failing at a rule’s repeatEvery, not-run when 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, failed or passed, every time. A withdrawn or aborted run says nothing; a run whose journal stops and whose heartbeat goes cold is interrupted.

    Channels, rules, mutes and quiet hours live on the box and are managed from the guard’s /_guard/notifications page (or boxd 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 and passed back until the morning. A generic webhook receives the event as JSON (version: 1, the box named, the trigger) with X-Unotest-Event and, 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.trigger and 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.

  • lint and e2e no 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 implicit lint / e2e. On a box that runs a suite in an isolated container the bundle is mounted read-only, so that write raised EROFS / ENOENT and every scenario ended as interrupted — 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 report skipped, the result carries the reason, and the run carries on; any other failure still throws. UNOTEST_EDITOR_TYPES=0 turns 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 /judge is compared in constant time, and the docs say what a verdict sends and what it is not.

    UNOTEST_JUDGE_TOKEN was checked with a plain string comparison, which returns as soon as one character differs; both sides are now hashed and compared with timingSafeEqual. 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 to fake — 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’s Authorization, 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/notify is 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), and X-Unotest-Signature is 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 by of 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/web depended on playwright ^1.62.0, so a fresh install of a suite could pick a newer playwright than the browsers inside the box’s mcr.microsoft.com/playwright image, 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’s verify keeps them equal.

  • Building the runner no longer writes into the project tree.

    ExplorationService created .unotest/explorations/ in its constructor, and the composition root builds it for every entry point — including an e2e run. 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 / e2e writes nothing under the sources: run artifacts go to UNOTEST_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 box network (POST /runs, GET /runs/:id/stream NDJSON, 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 with realpath and re-checked against the projects root, and must already exist. The container is created over the Docker Engine API (pinned v1.43) straight over the socket — no docker binary 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 as 502 {reason} before the run is accepted, or as an error event 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’s unotest/.runs.<env> read-write, a noexec tmpfs /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 the runs network 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 own npm ci and viewers). ContainerScenarioRunner talks to the sidecar, ProcessScenarioRunner keeps the old child-process shape for a box without docker; BOXD_RUNNER_KIND=container|process chooses, 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 inheriting process.env, and its debug tree is pointed at the run’s own directory rather than the read-only sources. New metrics boxd_run_container_total{outcome} and boxd_run_container_start_seconds. An environment’s runs directory is created by the daemon with 2775, 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, and envNumber / envPositiveNumber — the env-reading rule the box-side services share.

    npm ci of 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-scripts and no longer walks the tree afterwards.

    The daemon runs with umask 002 and clears node_modules before 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 ci wipes node_modules itself). 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 answered already had this exact bundle.

    web: bundle push and 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 a manual ticket 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 from box-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 under admin and requires an Origin on a mutation; the daemon checks the token’s environment, the ticket’s environment and its jti against the path.

    @unotest/viewer gets BoxdRunner: 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 its current link 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 own process.env no 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 push anyway, 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.yaml gains the runner service (the only holder of the docker socket, the release ships its bundled box-runner.mjs beside boxd.mjs), fixed container names for the guard and the daemon, a box_secrets volume whose secrets box-init generates, 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) and BOX_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 started event, 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/variables and 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 matches PASSWORD | PASSWD | SECRET | TOKEN | KEY | CREDENTIAL is sent empty and flagged secret, and GET /api/targets leaves out projectRoot. 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:started with 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 a 2/0 done counter. 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 envs lists the environments a read token may look at, box runs their history (--latest collapses 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 queue shows who is waiting, and box screenshot saves a frame the run captured.

    box run --download fetches the run’s *.unotest.zip into .unotest/box/ and unpacks its failure bundle into .unotest/failures/, where list_failures, get_failure_* and agent_fix already look — so a run that failed on a box is debugged with the commands a local failure is. --no-screenshots asks 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’s UNOTEST_BOX_TOKEN still 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 --run opens: box_run says what became of a run the agent ordered on a box, box_run_download unpacks its failure bundle into .unotest/failures/ so get_failure_trace, get_failure_console, get_failure_a11y, get_failure_screenshot, get_failure_network and agent_fix work on it unchanged, and box_envs, box_runs, box_queue and box_screenshot cover 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 with UNOTEST_BOX_READ_TOKEN. The token is always readonly whatever its owner’s role, may only GET, and names the environment it means in X-Unotest-Environment: <project>/<environment> (GET /_guard/api/envs lists 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_TOKEN is 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 into explore_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.

  • BoxReadClient reads 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 in UNOTEST_BOX_READ_TOKEN. Every refusal arrives as a typed BoxReadError whose kind says 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 .env cannot stop a local run that never touches a box.

    The viewer gains a @unotest/viewer/wire entry 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/.env and unotest/.secrets on every request, not once at startup, and the box CLI reads those files directly. unotest-web box … runs without loading the project config, so nothing had flattened unotest/.env into 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’s UNOTEST_BOX_TOKEN, which stays a write credential for bundles and environment values), the X-Unotest-Environment: <project>/<environment> header every bearer request names its own environment with, the /_guard/api/envs listing and its BoxReadEnvironment entry, 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 an unauthorized carrying Retry-After: a rejected token means mint a new one, a throttled one means wait and resend the same one. The viewer’s ViewerEnvOption is 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 — answers unavailable rather than looking like an unreachable box, so the advice is to wait rather than to check the address.

  • A run exported as *.unotest.zip now carries everything needed to diagnose it away from the machine that produced it: stdout.log and stderr.log (previously dropped, which left the one artifact that explains a runner crash outside the bundle), the failing page’s page.html reachable through the manifest, and the run’s step screenshots — declared in a new manifest.screenshots list, because an importer keeps only what the manifest names and a frame absent from it did not survive the round trip.

  • UNOTEST_BOX_READ_TOKEN is now read from unotest/.secrets as well as from the environment, with an exported value winning. This is what makes the box_* 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/.env is deliberately not consulted: it travels inside a pushed bundle, while .secrets does not. The CLI resolves the token the same way, so one token serves both.

  • box run and box_run now 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 one step.soft over 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/.secrets alone — a file that does not exist on a box, where the daemon passes the values as environment variables and names them in UNOTEST_BOX_SECRET_NAMES. The registry was therefore empty on every box run, and a password a scenario typed reached the run journal, the run’s stdout.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 into steps.jsonl, runtime.json and 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_test then resume, or the viewer’s Continue — reported completed after a failure it had paused on, wrote no failure bundle and no failure/ artifacts, and left list_failures with 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.json and the reply agree, and a debug run leaves the same evidence a plain unotest-web e2e run does.

    abort_runtime (and Stop, and SIGTERM) now also ends the run in runtime.json, not only in steps.jsonl: the control file used to keep saying paused-step about 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/.secrets are 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’s stdout.log / stderr.log and 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, or e2e --debug), every soft failure raised the debug wheel: the run stopped on each one and an agent had to resume its way through them. Worse, it was then lost — a paused failure is consumed where it paused, so it never reached the step.soft envelope 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 the run_test reply 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 (marked truncated), secrets masked. log(...) now also lands in the journal under the step that wrote it (the stdout line stays), and assertJudge verdicts 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’s file / line / col and 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) note goes to the logger; nothing fails.

  • ExecutionWalker.run takes a trailing entryArgs list and binds it by position onto the entry function’s parameters (missing ones null) in the root scope before the body runs — what explore_run_flow needs to replay a parameterised flow_* helper live.

  • explore_run_flow replays parameterised flows: pass args (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 with Variable "username" is not defined. explore_start lists each flow’s params; a wrong argument count is refused with the signature. The recording keeps variable NAMES, so the saved test reads flow_login(LOGIN, PASSWORD);flow_call is now a regular action plugin rendering the call with its arguments.

  • New explore_step action screenshot — 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 absolute path, width and height, 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). name defaults to the section slug, fullPage captures the whole page, locator captures one element, outline: true adds the page outline to the reply, evidenceOnly: true keeps the file but records no step. A recorded screenshot becomes screenshot(name) in the saved test; an element capture degrades to a page capture with a comment and a non-blocking DEGRADED_STEP warning. explore_stop now lists the session’s files in artifacts. Driver: DriverPage.screenshotElement.

  • New linter rule lint:external-variable-undeclared (default error): a bare UPPER_SNAKE identifier that no layer file declares — unotest/.env, unotest/.secrets, or the .env.<name> / .secrets.<name> overlay of the environment lint --env <name> / UNOTEST_ENV selects — 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 in unotest/.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 in linter.rules if a project needs that.

    run_test (MCP) and e2e run 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_test answers lint_failed; e2e prints 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 inside a + b is diagnosed the same as one on its own.

  • New linter rule lint:one-test-per-file: a second (third, …) top-level function 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 first test_*, so the others executed with nowhere to be seen. Fold the cases into one test with tagged steps (step("…", {tag: id}, () => { … }), step.soft when a case must not stop the run) or move shared journeys into flow_* helpers under unotest/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 and run_test’s warnings, silenced per line with // lint-ok: <reason>, never blocking. A later minor release flips the default to error (which the pre-run gate of run_test refuses); split files that hold several test_* 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. waitForJsonLine was the only structured input into a scenario, and for a fixture or a finished export its 20s timeout only masked a wrong path. readJsonLine takes 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, and step.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_test answers next.softFailures; inspect_runtime’s lastFailure carries stepTag and soft). The failure bundle shows the page at the moment of the first soft failure, with soft: true and stepTag in failure.json; later soft failures live in the journal. step.soft is allowed inside test_* 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 gains step-block:started / step-block:finished envelopes 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’s in 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” names env push never sends. Both sides now share one rule (isRunnerSetting in @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 through unotest.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 partial linter, loads as written; its timeouts block — never a config field — is corrected to defaultTimeoutMs / defaultNavigationTimeoutMs.

  • The viewer’s editor flags an undeclared external variable (lint:external-variable-undeclared), the way unotest-web lint and the pre-run lint already do: a bare UPPER_SNAKE identifier that no unotest/.env / .secrets file of the active environment declares is an error in the editor, naming the files searched. Protocol: DslValidateContext gains externalNames / 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: hover now 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, while position is 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’s steps.jsonl journal (path, the four collection-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 lint now receives its arguments: the bin dispatcher forwarded only the command name, so lint --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.

  • lint works on a project that has not migrated to the 0.28 layout yet. A unotest.config.* still at the project root used to stop lint with the same refusal e2e gives — 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. lint now builds its context on the default settings (rule severities, helpers dir), reads unotest/.env, .secrets and _helpers/ as usual, and reports the layout once as the new lint:legacy-layout warning (default warn, configurable like any rule) with the migration on one line. The exit code is unaffected by it. e2e, collection and bundle push refuse 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_exploration and save_exploration_as_test parse 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 carry serverStale: true and a serverStaleWarning (“restart the MCP server”) once the code on disk is newer than the server’s start; nothing changes while it is fresh.

  • serverStale now also rides on explore_step / explore_steps (locator resolution and recording run in the server’s process), find_element and ground_element replies — the same flag run_test and the exploration tools already carry once the build on disk is newer than the running MCP server.

  • goto with 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 an env override, explore_run_flow replayed goto('/_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’s baseUrl (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 therefore lint and run_test) rejected screenshot(q.id) and screenshot(textJoin(['q-', id])) — a data-driven test had to name every capture the same. The name slot is now string-like, as in fill; a non-string argument is still an arg-kind error.

  • A section label reopened after another section is called out instead of silently splitting a step: explore_step / explore_steps reply with sectionHint the moment it happens, and the generated draft carries a non-blocking SPLIT_SECTION warning naming the reopened entries. The recorded order of actions is never changed; autoRun and save are not held back by it.

  • A scenario that finished in the same instant the viewer looked at it is no longer recorded as interrupted with a finish an hour before its start. The runner ends a run by appending run:finished and 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 runs or 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 the DYNAMIC_TEXT warning 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/session carries the two URLs this needs (environmentsUrl, switchEnvUrl) and the ViewerEnvOption type, 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 push and env push no 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 evaluate now 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 when run_test auto-attaches the MCP session to the run’s browser, or after attach_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. Under manual the 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.revealed entry 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 door env push / env set talk to is unchanged: secrets stay write-only on the wire.

    @unotest/protocol carries the contract: boxEnvSecretRevealPath / boxEnvSecretsRevealPath with their response parsers, the session-door batch BoxEnvValuesReplaceRequest (null for a secret means “keep”) and parseBoxEnvValuesReplaceRequest, and the refusal code unknown-secret for a reveal or a keep that names a secret the environment does not hold. isEnvVarName now refuses __proto__: it is spelled like a variable, but a plain object cannot hold it and the value was silently lost. The CLI explains an unknown-secret refusal.

  • 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 the lint:evaluate-discouraged warning 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 that waitForText never 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_test no longer reports spawn_failed about 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 first runtime.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 to spawn.stderr.log in its run directory.

    explore_start now 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: with LOGIN=admin, the scenario path guard-admin.js, the test function test_guard_admin and the run id kept their names in inspect_runtime replies instead of turning into guard-‹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 (and generate_dsl_from_exploration) now write scoped locators — a getByRole / 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 with NO_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 of first(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 set and env rm commands 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 and APP_BASE_URL its 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 set reads one value from stdin, never from the command line. Values never print. The commands use the project token of bundle push, minted on the box with the new --values scope — a token without it is refused, so a CI token that pushes suites cannot rewrite an environment’s credentials. @unotest/protocol carries the wire (box-values.ts) and TARGET_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 box badge. It used to read unotest/.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 through UNOTEST_BOX_VARIABLE_NAMES / UNOTEST_BOX_SECRET_NAMES (constants in @unotest/protocol); writes to such a key are refused with 409 and point at boxd.config.mjs / boxd.mjs secret set.

  • UNOTEST_BROWSER and UNOTEST_BROWSER_CHANNEL now exist as per-run overrides of browsers[0] and channel in unotest.config.*. UNOTEST_BROWSER was shown in the CI manual and the .env.example template, but nothing read it — a browser matrix ran Chromium three times. A value outside chromium | firefox | webkit (or chrome | msedge | chrome-beta | bundled) is an error, not a silent default. The config travels with the suite in a bundle, so a channel: "chrome" written on a laptop used to ask a box for a Google Chrome it does not have; a box now sets UNOTEST_BROWSER_CHANNEL=bundled for every run and the run log names each override. Constants: BROWSER_ENV, BROWSER_CHANNEL_ENV, BUNDLED_BROWSER_CHANNEL in @unotest/protocol.

  • init without --force still leaves an existing unotest/.env.example alone, 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 about UNOTEST_STEP_SCREENSHOTS, because nothing compared its file with the template. The file is not touched. declaredEnvKeys() in @unotest/protocol reads active and commented-out keys alike. The DSL reference now documents UNOTEST_STEP_SCREENSHOTS next to screenshot(): 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 as ENOENT, and the old message sent people looking for a tool that was there all along while the real cause was a sandbox.shellCwd that exists on the laptop and not in CI or on a box. The directory is checked before the spawn and the new ShellCwdMissingError names 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.json pins @unotest/web, unotest.config.* moved into unotest/, and a bundle carries that directory and nothing above it.

    A bundle used to carry the project’s root package.json and lockfile, and a box ran npm ci over the whole product tree to obtain one dependency. On a real customer that failed outright: their own peer conflict (@nestjs/core@10 against @nestjs/websockets@11) was hidden locally behind legacy-peer-deps=true in ~/.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 a prepare hook. 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 about unotest/), a box install shrinks from the product tree to the suite’s own dependencies, and upgrading @unotest/web can no longer collide with your application.

    The CLI finds the project root by walking UP for unotest/e2e, the way git finds a repository, so cd 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 init
    git mv unotest.config.mjs unotest/unotest.config.mjs
    npm install --prefix unotest

    and drop @unotest/web from your application’s package.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 — no baseUrl, no sandbox, no schedules.

    Two traps worth knowing about, both named by that diagnostic:

    • unotest/jsconfig.json — an include written 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 ci in a unotest/ that has no manifest would walk up the tree (that is how npm behaves), install the old root manifest, and leave the box unhealthy for 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

  • viewer starts 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=1 brings 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 in unotest.config — edit it there”); yaml entries stay editable as before. A runner without a schedules command falls back to the yaml half.
    • bundle push records 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 reads process.env computes 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 push now 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 schedules marks entries outside the previewable cron subset (not previewable — an executor may drop it), and the new --check flag turns that mark into a non-zero exit for a pre-push hook or a CI gate.
  • Schedules stop lying on a box, and a wrong cron is caught before the push.

    • npx @unotest/web schedules now 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 schedules merged with unotest/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 + 30 runs four times a day). Saved to unotest/schedules.yaml, versioned next to the tests; a box applies it after commit & sync. The Home screen gains a last run | schedule toggle: 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.yaml is a new schedule registry; unotest-web schedules [--json] prints it merged with the config’s schedules (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 of collection: <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 of collection --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 .example twins) 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:ecosystem now also commits the docs pin and cuts the box release (the guard-v<version> tag) when something a box runs changed since the last one — --skip-box refuses, --box forces. 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 one git push --follow-tags.

  • A collection prepares its own environment: prepare: in unotest/e2e/_collections/<name>.yaml

    prepare used to live only in a schedules entry, 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 own prepare: runs however the collection was started, inside the same queue slot, and a failure still exits 94 rather than pretending the tests ran. A schedules entry may still name its own (it wins for that tick), and --no-prepare skips it for a run against an environment somebody else prepared.

  • Fix: the mirror shipped the monorepo’s .secrets verbatim

    publish-examples copied unotest/.secrets into 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 .env has had.

  • Fix: bundle push --run --env <name> refused to run

    --env was consumed globally, where it selects a local .env.<name> overlay, and never reached bundle push — whose --env names an environment ON A BOX. The command every CI job and the box manual use (“—run needs —env”) could not work as documented. bundle now 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 screenshot events 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 push strips the project’s own install hooks (postinstall, prepare, …) from the packed package.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), so npm ci on the box died with MODULE_NOT_FOUND. Dependency scripts — native module builds — are untouched, and the push reports what it removed on a note: 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_ROOT for 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, or wip for a bundle packed from an uncommitted tree, with the author, the pinned @unotest/web and 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.json and GET /api/runs carry bundleId (from UNOTEST_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/protocol gains WorkspaceFile — the host writes unotest/.workspace.json beside 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.bundleId is 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.
  • 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.com

    It packs unotest/, unotest.config, package.json and 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 fetch unotest/ 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: true and 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_FOUND on the box, hours later, on a run nobody is watching), a missing lockfile (npm ci will not run without one), no @unotest/web in 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 .gitignore keeps out — generated databases, recordings, .runs — stays out. Content is read from the working tree, not the index.
    • The id is the content. bundleId is 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-run packs and checks without uploading, --out <file> keeps the archive (plain tar.gztar -tzf shows what is inside), --json prints { bundleId, status } for a CI job that runs it afterwards.
    • UNOTEST_BOX_URL / UNOTEST_BOX_TOKEN supply the address and the token, which a CI job usually takes from its secret store. Tokens are issued per project on the box.
  • 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

    --run sends 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 answers exists — 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 its schedules name. 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. --json reports 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 e2e in a terminal, an agent’s run_test and 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. Only open("wx"), rename, unlink and mtime — no flock, 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 MCP run_test (which spawns the CLI). A collection is ONE run — its workers still 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 a queue:changed websocket message.
    • Config: queue.concurrency (default 1) and queue.enabled (default true) in unotest.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=1 runs without queueing; Ctrl-C while waiting gives up your place in line and runs nothing.
    • For boxes: UNOTEST_QUEUE_GLOBAL_DIR + UNOTEST_QUEUE_GLOBAL_SLOTS add a host-wide budget several environments share (a collection weighs its worker count), and UNOTEST_ARTIFACTS_ROOT puts .runs / .queue somewhere other than the directory the tests live in — so the history outlives a throwaway copy of the tests.

    Migration notes

    POST /api/run no longer answers 409. Ordering a run while another one is active used to fail with ActiveRunConflictError (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 }

    runId is still final and immediately usable — open the tab on it as before — but the run may not have started yet, and pid is not known at that point. POST /api/runs/:runId/abort covers 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), set queue.enabled: false.

  • 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 optional env (which environment the run happens in) and prepare (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 schedules prints what is declared; --json is the machine shape a scheduler reads back. Each entry also carries the collection’s workers resolved from its yaml manifest, so an executor can weigh the run without parsing yaml itself.
    • npx @unotest/web collection <name> --scheduled runs a collection the way its schedule does: in the entry’s env (unless you set one) and with its prepare command 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 in schedules more than once needs --scheduled=<index> (zero-based) — guessing which entry’s prepare to run is refused.

    Nothing in this package ticks. schedules is 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 schedules says 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/web and opens via npx @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 proper tablist for 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=global could not work. The endpoint was built as https://<location>-aiplatform.googleapis.com, so global asked global-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. global is 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 under publishers/google, and a pasted models/gemini-2.5-flash no longer doubles the prefix on the Gemini API.

    Backend replies are classified instead of lumped together.

    • 401/403config with the variable to fix (OPENAI_API_KEY, ADC, an expired UNOTEST_JUDGE_ACCESS_TOKEN), no longer indistinguishable from a model fault.
    • 429 and 5xx → retried transparently with a short backoff that honours Retry-After. A single rate limit used to kill a whole scenario. The retry policy is a decorator over JudgeProvider, not a loop inside the HTTP helper, so it covers every provider — claude spawns a process and therefore had no re-send at all. A claude usage 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 debug logging.
    • OpenAI finish_reason: length and Anthropic stop_reason: max_tokens are 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=N no longer fails on one bad ballot. The N calls ran under Promise.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=1 waives it), and npx @unotest/judge --check runs just the probe for start scripts. GET /health reports 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 /health from a real probe, and --check still answers honestly, because the operator who reaches for the flag is precisely the one whose credentials are dead. A static UNOTEST_JUDGE_ACCESS_TOKEN is validated too, against Google’s free tokeninfo: 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 the UNOTEST_LOG_LEVEL @unotest/web already reads). At the default info, one line per judged request — verdict, attempts, model, latency — with errors on stderr; a run of 21 assertions with VOTE=3 is 63 billed model calls that previously left no trace. At debug, per call: the effective prompt (rubric including any UNOTEST_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 413 with a reason; the socket used to be destroyed while the client was still writing, so the client saw socket hang up.

    @unotest/web: the judge:verdict run artifact records preamble beside rubric when 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 outlives UNOTEST_JUDGE_TIMEOUT_MS now says so, and names the service-side UNOTEST_JUDGE_CALL_TIMEOUT_MS it 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’s timeout option does not clear itself when the spawn fails, so a missing binary held the event loop for the whole budget — 15s for a --version probe, 120s for a verdict.

    Breaking (0.x minor): @unotest/judge no longer exports ExecFileFn / ExecFileResult; the Claude CLI provider takes runImpl: RunProcess instead of execImpl, because the prompt moved from argv to stdin — a long judged text used to overflow ARG_MAX and fail as a bare E2BIG.

  • 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/judge service is now UNOTEST_JUDGE_*, matching the client-side names @unotest/web already used. @unotest/judge shipped 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:

    OldNew
    JUDGE_PROVIDERUNOTEST_JUDGE_PROVIDER
    JUDGE_MODELUNOTEST_JUDGE_MODEL
    JUDGE_RETRIESUNOTEST_JUDGE_RETRIES
    JUDGE_TIMEOUT_MSUNOTEST_JUDGE_CALL_TIMEOUT_MS
    JUDGE_ACCESS_TOKENUNOTEST_JUDGE_ACCESS_TOKEN
    JUDGE_CLAUDE_BINUNOTEST_JUDGE_CLAUDE_BIN
    JUDGE_HOST / JUDGE_PORTUNOTEST_JUDGE_HOST / UNOTEST_JUDGE_PORT
    JUDGE_TOKENUNOTEST_JUDGE_TOKEN

    Two names were deliberately not a straight prefixing. The service’s per-call budget became UNOTEST_JUDGE_CALL_TIMEOUT_MS because UNOTEST_JUDGE_TIMEOUT_MS was already taken by the client’s whole-request budget — same family, different question. And the service’s bearer token merged into the client’s UNOTEST_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. assertJudge and the DSL query getInnerText(loc) now read innerText, not textContent. Both promised rendered text and delivered the raw source-order concatenation: line breaks gone, display:none subtrees and <script> contents folded in. A rubric about structure (“the answer is three bullets”) was unjudgeable, and getInnerText returned text no user can see. Scenarios that relied on hidden text reaching these two functions will now see it excluded.

    Vertex fixes. google-auth-library moved from optional peer to a regular dependency: as a peer it did not resolve under npx @unotest/judge at 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 “run gcloud 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 replaces UNOTEST_JUDGE_RETRIES rather than stacking with it: re-asking is a deliberate bias toward pass, 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 config on the judge’s error responses: the service’s own setup is broken (bad env, expired credentials) and the message carries the fix. @unotest/web surfaces it as a ConfigError instead 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) and fake (deterministic, CI-safe). Non-determinism policy lives in the service: JUDGE_RETRIES re-asks on fail, first pass wins.
    • @unotest/web: new DSL assertion assertJudge(locator, rubric) — the element’s rendered text + the rubric go to the judge; a fail verdict fails the step with the judge’s reasoning. Wired via env so the config sits in the --env overlay: 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’s steps.jsonl. Also documented: shell() runs with cwd = the project root (sandbox.shellCwd to override) and returns {stdout, stderr, code}.
    • @unotest/protocol: judge wire types (JudgeRequest, JudgeVerdict, JUDGE_ROUTES) and the new judge:verdict run-artifact event.
  • Judge provider matrix: four new JUDGE_PROVIDER backends.

    • 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_MODEL is passed as --model (aliases like sonnet work); JUDGE_CLAUDE_BIN overrides the binary; default JUDGE_TIMEOUT_MS is 120000 for this provider.
    • gemini — the Gemini API with GEMINI_API_KEY (same wire format as vertex, temperature pinned to 0; default model gemini-2.5-flash).
    • openai — OpenAI chat completions with OPENAI_API_KEY (response_format: json_object; default model gpt-5-mini).
    • anthropic — the Anthropic API (/v1/messages) with ANTHROPIC_API_KEY (default model claude-haiku-4-5).

    openai and anthropic deliberately send no sampling params — current reasoning models reject temperature; determinism relies on the strict JSON verdict prompt plus the JUDGE_RETRIES policy. Still zero provider SDKs: everything is raw HTTP or a local process.

    @unotest/web: docs only — provider matrix in the assertJudge reference and the .env.example judge 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.staging for --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 / projectRunDirFor gained an optional envName parameter; the run manifest carries env for self-description. Make sure your .gitignore uses the wildcard form — unotest/.runs*/ (init already 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.example and other templates do not count). The active environment is server-side (GET/POST /api/environments), and all tabs converge via the env:changed WS 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.env is 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 --env overlay actually reaches the run. Long-lived hosts (viewer, MCP server) flattened the base .env into their own process.env on loadConfig; children inherited it as ambient (ambient beats files) — the overlay’s APP_BASE_URL was 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 failed after a clean exit. The “child exited without artifacts” check looked at the flat path and fired bogus run-finished: failed events plus an error toast on every exit.
  • 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: return is no longer tied to the flow_ prefix

    A helper that reads state and returns a value had to be named flow_* — the only place the validator accepted return. But flow_* 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. return is legal in any helper — flow_* composites and plain snake_case value helpers alike — and still rejected in a test_* 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 a flow_* 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 under linter.rules like 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 four test_*() functions left four terminal events in steps.jsonl. Every consumer reads the last one — so collection reported 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: false now 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-web is 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) in unotest/e2e/_collections/*.yaml sets how many scenarios of that collection run concurrently; omitted = serial.
    • Precedence: CLI --workers=N flag > manifest workers: > 1.
    • Viewer: the collection header shows an editable workers value; the setting is stored in the YAML, so CLI and CI runs pick it up too.
    • CollectionMeta (protocol) gains a workers: number | null field.
  • 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 the runId, 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 the runs migrate entry).
    • The viewer’s watcher polls only LIVE runs; finished history never touches it. Discovery watches the current day’s directory with native fs.watch and 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: new run-shard and run-index modules (shard path derivation, index record shapes) exported from the root entry.
  • 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. du and ordinary copies see whole files; cp -a / rsync -H preserve 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: new run-blobs module exported from the root entry.
  • 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=keep re-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.
    • playwright moves to ^1.62.0 in @unotest/web and @unotest/viewer. Run npx playwright install once 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.

0.18.0 — 2026-08-23

  • Editor typings for scenarios — .js files 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 (checkJs stays 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 — creates unotest/jsconfig.json if missing and regenerates the .unotest/types/env.d.ts cache (variable NAMES from unotest/.env / .secrets; values never leave those files).
    • init scaffolds the jsconfig + env cache; scaffoldWorkspace() now includes the jsconfig too.
    • Upgrade path is automatic: lint and e2e create a missing unotest/jsconfig.json on the fly (one announcement line) and silently refresh the env cache. Skipped under CI. No action needed after upgrading — run any test or unotest-web lint once and the editor comes alive. If you keep a custom unotest/jsconfig.json, it is never overwritten (re-run init --force to reset it).
    • @unotest/protocol: additive optional fields on the DSL vocab — DslVocabArg.enumValues (literal members behind kind: "enum") and DslVocabEntry.variadic.
  • 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.rules severities from unotest.config), so npx eslint unotest/e2e and unotest-web lint report identical diagnostics; a new validator rule needs zero plugin changes. configs.recommended() (async factory, flat config) wires unotest/e2e/**/*.js, turns no-undef off (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/.secrets variable names.

    @unotest/web: the @unotest/web/dsl subpath now exports loadLintProjectContext() (config severities + helper discovery + variable names) and linterRulesToSeverity() — the same loader the CLI lint command 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: new DslVocabCategory value "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 at upload()):

    • 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) — an apiCall body object with a file key is sent as plain JSON, no file attached; the diagnostic shows the upload(...) shape to use. Deliberate JSON fields named file are kept with // lint-ok: <reason>.
    • The CLI e2e command now runs the same pre-run lint as the MCP run_test tool and prints diagnostics before the run (advisory — the run still proceeds). Previously authoring mistakes the linter knows about surfaced only through unotest-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 misleading add it to unotest/.env error.
    • The generated unotest.config.mjs template documents the sandbox section (apiBaseUrl, uploadDir, database) — uploadDir was referenced by the guides but absent from the template.

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: waitForFile matches a substring against the WHOLE file, so a multi-key event filter was inexpressible — JSON key order is not guaranteed, and "userId":"x","isBot":true breaks 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 least count matching lines: between two polls a writer can append more than one, and a strict wait would then never see its target. pattern is 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 -c semantics), not per occurrence.

    waitFor(loc, {state}) also accepts 'enabled' / 'disabled' now, alongside the DOM-presence states. It polls the same probe isEnabled / isDisabled use, so aria-disabled widgets and <fieldset disabled> inheritance count — Playwright’s own wait knows neither. This replaces the for loop around pause() 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=1 gained a step layer: every executed statement is bracketed with enter/exit (file, line, step label, ms, ok) and every nested driver / resolver event 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 viewer learns UNOTEST_VIEWER_HOST / UNOTEST_VIEWER_PORT.

    By default the viewer still binds localhost plus 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:4000 inside a compose stack). An invalid port fails fast with an actionable message instead of silently falling back. UNOTEST_VIEWER_NO_OPEN=1 still skips opening the browser.

  • UnotestError — the typed-error base class — now lives in @unotest/core and is part of its public API. @unotest/web re-exports it unchanged, so existing imports and instanceof chains 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: indexOf now 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: FunctionContractRegistry gained an optional suggestForUnknown(name) hook, and the web vocab supplies the table (indexOftextContains, concattextJoin, JSON.parsewaitForJsonLine, Date.nownowMs, 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 CollectionReporter seam — the orchestrator still only emits collection-run:* events.

  • The examples mirror now tops up unotest/.env and unotest/.secrets with 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.example grew the key, .env stayed as it was, and the run died with external variable "CATALOG_SEED" not found. The workaround was to delete unotest/.env by 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 every waitFor* — for “must NOT happen” requirements (a private message must not leak into a public feed, a disabled bot must not reply). withinMs is mandatory: absence is only provable within an explicit window, and the step always costs that long.
    • assertNoFile(path, pattern?, {withinMs}) — the file-side mirror of waitForFile with inverted polarity: fail as soon as the file exists (and matches pattern), 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 undefined instead: the driver’s evaluate signature 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. When js evaluates 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 / getLocalStorage in 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 defined on every call. assertValue was 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-inject entry, next to the snapshot algorithms. Two new guards keep them there: a source check (check:no-inline-evaluate, part of check: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 textJoin section 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 + b between 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_start accepts env: that session’s variables, secrets and APP_BASE_URL resolve 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. The explore_steps autoRun finale runs the saved test with the same env. Symmetric with run_test’s per-call env.
    • init “Next steps” now says to restart an already-open agent session (MCP clients load .mcp.json servers 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 at textJoin([...]), 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.

  • waitForText now respects the active enterFrame stack — 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_start and run_test replies carry an environment field: the active env name (UNOTEST_ENV, null = base) plus every .env / .secrets layer file with an exists flag. 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 for run_test / explore_start explain the env mechanism — MCP sessions inherit UNOTEST_ENV from the server process (set it in the client’s env block); 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 forgotten res.code check produced false-green tests. Migration: calls where non-zero is expected (probes, grep -q) pass {allowNonZero: true} as the last argument and keep checking res.code; calls that asserted assertTrue(out.code == 0, ...) can drop the assert. More shell() hardening: a missing binary is a distinct “failed to start” error (was a fake code: 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-side waitForText for background processes. Returns the content.
    • sandbox.exportSecrets: [names] — explicit allowlist of .secrets* variables exported into shell() subprocess env (still masked in logs). Secrets stay out of process.env otherwise.
    • isDisabled(loc) / isEnabled(loc) — honest disabled probe (native :disabled incl. fieldset inheritance, or aria-disabled); hasAttribute(loc, name) — presence probe for boolean attributes (getAttribute reads "" 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, stepLabel in the failure bundle and inspect_runtime.
    • Every CLI run prints a one-line header: active environment, layer files (missing ones flagged) and effective baseUrl; run_test / explore_start replies echo baseUrl next to environment.
    • run_test accepts per-call env — the spawned runner child gets UNOTEST_ENV=<env> for that run only.
    • Linter: // lint-ok: <reason> silences warning/info diagnostics on its line (reason required; errors never suppressible); new lint:echo-assert warns when waitForText/assertText overlaps a value the scenario itself filled (chat echo trap); lint:goto-concat / lint:regex-invalid are now configurable in linter.rules (they were emitted but missing from the config schema).
    • A _helpers file that fails to parse is reported as the root cause in unotest-web lint (and explore replies list unparsableHelperFiles) instead of silently cascading into “Unknown function” at every call site. The viewer logs the same condition (patch).

0.11.0 — 2026-08-13

  • e2e CLI 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/** (new suggestClosest / levenshteinDistance utilities in @unotest/core). The collection command 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) to unotest/.runs/<runId>/failure/ — no UNOTEST_DEBUG needed. The .unotest/failures/ bundle gains the same page.html; its screenshot keeps honouring failureBundle.tier2.
    • New env flag UNOTEST_STEP_SCREENSHOTS=1 captures a PNG after every executed step into unotest/.runs/<runId>/screenshot/.
    • Protocol: screenshot events may carry the step’s file/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).
  • Parse errors now carry an exact position and a caret code frame.

    • Every lexer failure is a ParseError with line/column (previously bare Error with no position).
    • New formatCodeFrame(source, line, column) / formatParseError exports 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:col breakpoint keys stored in .debugger[-suffix].json against word-initial statements shift by +1 — re-set those breakpoints once.
  • BREAKING: multipart uploads in apiCall are now opted into explicitly with the new upload(path, {field?, fields?}) DSL function — the {file, field?, fields?} body-shape magic is removed.

    A JSON body containing a file key now always posts as JSON (the old key-name detection broke JSON APIs with a legitimate file field — {file: 'x.jsonl'} tried to read a file from disk and died with ENOENT).

    Migration:

    // before
    apiCall("POST", "/documents", {
    file: "fixtures/doc.pdf",
    fields: { source: "e2e" },
    });
    // after
    apiCall(
    "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 in sandbox.uploadDir and 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 by makeRunId(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.
    • init re-syncs tool-owned skill copies. .claude/skills/* and .claude/hooks/* shipped by the package are overwritten on a plain init re-run when they diverge from the installed version (status updated) — stale skills after a package upgrade no longer require --force. User-scaffolded files keep the skip-unless---force behaviour.
    • The MCP agent-test-author prompt caught up with the runtime: multipart via upload(...) and the obj.prop[i] workaround are now documented there too, and both facts are enforced by check-skill-prompt-sync.
  • Screenshots are no longer saved as blank white PNGs. goto() resolves on the load event, 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 on goto(), or a screenshot() 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 a unotest/.env variable holding an http(s) base URL. Default behavior (path against sandbox.apiBaseUrl) is unchanged; URLs still never appear in scenarios.
    • apiCall multipart upload: a body of the exact shape {file, field?, fields?} posts multipart/form-data via fetch+FormData. The path is relative and confined to sandbox.uploadDir (new optional config field; defaults to the project root) — absolute paths and ../ escapes throw, and a manual Content-Type header with a multipart body is rejected.
    • DSL validator now allows break inside loop bodies (break-outside-loop error elsewhere) and index access on data variables (docs[0].objectsCount); indexing a Locator is rejected as locator-index-access, and while / do…while / continue / arr[i] = v stay unsupported. Parser and executor already supported these forms; only validation changed.
  • Assert steps ground as whole units. Intent.prefer gains "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) declare intentTarget: "unit" on their plugins, and the intent resolver materializes a DOM ref for unit resolutions via the new materializeRefScript(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 a find_element detour. 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 — so RefResolver gains 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. Fixes assert_visible on a grounded tree item failing with no stable identifier.

  • assertValue on a checkbox / radio / switch now asserts its checked state instead of the DOM value. 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 the assert_value MCP 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 use getAttribute(loc, 'value').

    The WebDriver page contract gains checkedState(loc) (boolean | null, null for non-checkable elements) — relevant to custom driver implementations.

    assert_value also 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 with Node 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):

    • waitForText now 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 and wait_for_count explore action: polls until the locator matches at least n elements (exactly n with {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 by composeInjectedScript — replaces four hand-maintained copies of the same in-page logic.

    Breaking (@unotest/grounder-client): captureRawTree takes the helpers object as its first parameter, so passing it straight to page.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/web behavior deltas from unifying on the walker’s ARIA mapping: get_aria_snapshot now maps dl to list (dt/dd stay name-only) and tbody/thead/tfoot to rowgroup, and never names structural containers from their text content; find_element matches 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 in explore_step, explore_steps and explore_record; they execute live through the same poll-until-timeout cores as the runtime assertText(...) family (extracted to dsl/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 the NO_VERIFICATION save gate. New autoRun: true flag on explore_steps (requires explorationId): when every step succeeds, a verification step is recorded, the draft has no blocking warnings (FRAGILE_LOCATOR is informational — it passes and rides along in the reply; DYNAMIC_TEXT / NO_DSL_PRIMITIVE block, mirroring save’s policy) and the target file is new, the same call stops the session, saves unotest/e2e/<scenarioName>.js, resets the browser context and runs the test with run_test semantics (autoRun.run.next.outcome); any gate failure degrades to autoRun.status: "skipped" + reason with the session left active. The green happy-path shrinks to new_contextexplore_startexplore_steps {…, autoRun: true}. UNOTEST_TOOLSET=core unchanged — asserts are actions, not tools.

  • Two MCP round-trips removed from every authoring flow.

    explore_step / explore_steps now reply with url whenever the step CHANGED the page address — the same cure groundedTo gave intents, applied to navigation: after a batch the agent reads where it ended up instead of spending a call on get_active_context / get_url. Unchanged addresses print nothing, so a batch of ten clicks on one page adds no lines.

    explore_start opens the browser context itself when none is open, so the new_context prologue is gone from the happy path. new_context keeps its role as the explicit RESET (between the recording and the first run, and inside autoRun); 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 on explore_step, every step of an explore_steps batch, and explore_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 separate ground_element probe — 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 at groundedTo instead.

  • New screenshot(name?, {fullPage?}) DSL command — on-demand PNG capture of the active page into unotest/.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 a screenshot RunArtifact event, so the viewer’s event stream links to it. In runtimes with no artifact dir (exploration) the call logs a warning and returns null instead of failing the run.

  • The write-e2e-test-ground skill now records batch-first: when the task brief already spells out the step sequence, Phase 2 sends it as one explore_steps call (goto included, intent locators ground at execution time) instead of one explore_step per 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) — the UNOTEST_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 behind validateCollectionName / validateSegmentName and a shared env-file assignment iterator; core’s RuntimeInspection becomes a Pick of RuntimeStateFile; dsl’s ExecutionWalker threads one WalkCtx object 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_MODE is local or remote (/api/tags for local ollama, /health for a remote grounding server) — non-blocking, warning-only. Network-level failures on the intent / ground_element path are wrapped into a typed GrounderUnreachableError citing the backend URL, a mode-specific fix hint and the boot-time health verdict, instead of surfacing undici’s raw fetch failed. The ground-mode skill now spells out the exact fallback recipe: a single {kind:"intent"} step, the ref from find_element, or the ready-made locator object from ground_element — hand-assembled getByRole/css locators 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.zip bundle (events + captured artifacts) for a teammate to open in their own viewer. The runner now records console / network capture flags in the run manifest so a re-rendered snapshot shows “capture was off” rather than an empty console, and unotest/.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_locator MCP 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 --debug auto-attaches the MCP session to the paused run’s shared browser, so get_page_snapshot / find_element / check_locator hit the page being debugged. Collaborative probe command (the human’s check_locator twin) is handled by the runner.
  • Agent tool calls in the viewer’s System console. A new StepsToolEventSink publishes each MCP tool call + result (secret-redacted, capped) into the attached run’s steps.jsonl, so the human sees what the agent is doing.
  • Run breakpoints published to runtime.json (.debugger.json seed + --breakpoints override), 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

  • abort now 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 terminal run:finished and closing the browser.

0.9.0 — 2026-06-11

Added

  • Opt-in capture artifacts for the viewer inspector. UNOTEST_NETWORK=1 records each request/response to the run’s network.json; UNOTEST_CONSOLE=1 records the browser console to console.json. Written per run (any outcome), redacted of secrets. Off by default — zero overhead.
  • runtime.json for 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-goto error. goto('/path') with no baseUrl configured now throws a typed NavigationError that names the URL and the fix, instead of Playwright’s “Cannot navigate to invalid URL”. When baseUrl is set, Playwright resolves the relative path as before.

Changed

  • MCP collaboration is discoverable. get_active_context now reports an attachable live debug session (attachable + a hint); the no-active-context error points at attach_debug_session; the server instructions mention joining a human’s live session. findShareableRun is shared between attach_debug_session and get_active_context.
  • init defaults. Generated unotest/.env sets APP_BASE_URL to the unotest playground and the example scenarios use relative goto('/...'); API_BASE_URL and PROJECT_ROOT are dropped; UNOTEST_NETWORK / UNOTEST_CONSOLE are added under Runner config.

0.6.2 — 2026-06-05

Added

  • MCP server instructions. The server now ships start-of-session instructions (injected by the MCP host into the agent’s context the moment the server is approved), pointing the agent at the write-e2e-test skill and the canonical MCP tools before it free-researches the repo.
  • init writes/merges Claude Code config. init now copies the write-e2e-test skill as a real skill directory (.claude/skills/write-e2e-test/SKILL.md, so /write-e2e-test and auto-invoke work) plus an agent-trace hook (.claude/hooks/log-agent.sh, a no-op unless UNOTEST_DEBUG is set), and merges managed blocks into the consumer’s CLAUDE.md and .claude/settings.json (pre-approve MCP tools, register the trace hook) without clobbering user content.

Changed

  • CLI help uses examples/01-smoke as the e2e example (runnable example scenarios live under unotest/e2e/examples/).

Added

  • External variables and secrets. Scenarios reference external values by bare UPPER_SNAKE identifiers (APP_BASE_URL, TEST_USER_EMAIL) instead of hard-coding them. Non-secret values live in unotest/.env, secrets in unotest/.secrets (gitignored); both are created by init and 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 flow group and extracted on save into a flow_<name>() helper under unotest/e2e/_helpers/; the generated test calls the helper instead of inlining the steps. The new explore_run_flow MCP tool replays an existing flow_* helper without re-recording it, and explore_start now 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’s RunSources.)
  • New scenario linter rules: scenario-in-root (scenarios must live in a feature subfolder) and mustache-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 — use unotest/e2e/<feature>/<name>.js (the e2e/ root is reserved for _helpers/). The MCP scenarioName argument (explore_start / save_exploration_as_test) likewise requires <feature>/<name> form, and init scaffolds the welcome smoke test at unotest/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.json keys, step file events) 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 through explore_step, which both executes the action and records it into a session log. When done, save_exploration_as_test generates a DSL scenario and runs it via run_test. Session data persists as JSONL under unotest/.explorations/ (override via EXPLORATIONS_DIR).
  • Ref resolve-on-record. Wire-only {kind:"ref",ref:"eN"} locators emitted by get_page_snapshot are 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_url are replaced by explore_step { action, locator, … }. Without an explorationId, explore_step runs 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 directory unotest/.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 / =full all artifacts; =summary tool + snapshot only, no HTML; unset — no I/O.
  • audit_last_run MCP tool. Deterministic rule-based audit of the last debug session: catches ref-before-snapshot, resolve-loop, missing section descriptions, and selector downgrades.
  • get_last_mcp_log filters. errors, layer:N, tool:N, exploration:<id> — narrow the JSONL log to relevant events.
  • Resolver: data-* stable-id hints. data-id, data-guid, data-key and 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-title and similar attributes provide display names for icon-only controls.
  • Resolver: clickable-row detection. Non-control elements with cursor:pointer or onclick and a stable data-* 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_DEBUG logging. Set UNOTEST_DEBUG=1 to write a structured JSONL log to unotest/.debug/mcp-latest.jsonl. Captures tool calls, resolver decisions, and driver events. Readable via the get_last_mcp_log MCP tool.
  • get_last_mcp_log MCP 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.html artifacts pierce shadow roots via getComputedStyle — plain outerHTML misses shadow-hosted content.

Fixed

  • Outline renderer truncated role names longer than 32 chars; now preserves full name.
  • RefResolver emitted duplicate getByText selectors 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_viewer MCP 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 in unotest/e2e/_collections/<name>.yaml under a shared parent run-id. Supports --workers=N for parallel execution and --bail to stop on first failure.
  • install-chromium command. Downloads Playwright’s bundled Chromium (~150 MB) for environments without a system browser.
  • list_runtimes MCP tool. Returns all active scenario runtimes in the current session (run-id, scenario name, status, step count).

Fixed

  • unotest-web init failed silently when the target directory existed but was missing unotest/; now creates missing subdirectories.
  • run_test returned 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 .js in unotest/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, and agent_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, and screenshot.png to unotest/.runs/<runId>/. Accessible via list_failures, get_failure_trace, and related MCP tools.
  • agent_fix workflow. mcp__unotest-web__agent_fix builds 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, explicit pause() calls, disambiguate-by-index, and discouraged evaluate() patterns. Severity is config-driven via unotest.config.*.
  • Project-agnostic sandbox primitives. shell(cmd, …args) (execFile, no shell interpolation), dbQuery / dbExec (lazy peer dependency — pg / mysql2 / better-sqlite3 resolved from sandbox.database URL), apiCall(method, path, body?, headers?) (against sandbox.apiBaseUrl). Consumer-specific seeders live in unotest/e2e/_helpers/.
  • Cross-browser support. Chromium, Firefox, WebKit via Playwright.
  • unotest-web init. Bootstraps unotest.config.mjs, unotest/e2e/smoke-welcome.js, unotest/e2e/_helpers/, unotest/.env, MCP config, and Claude Code skill into any project.