The packages are pre-1.0 and move fast. This page is the changelog of @unotest/web@0.55.0 — the version this site documents — taken straight from the package you install.
0.55.0 — 2026-10-25
-
New:
npx @unotest/web box repo add [url]maps a repository to its project on a box that already exists, sobundle pushstops warning that the repository “maps to no project on this box”.box createhas mapped the project’s git origin since 0.54.0; a box rented earlier gets the same mapping from this one command, with no operator. The repository defaults to the checkout’s git origin, credentials in a URL are never sent, and running it again changes nothing. It uses the project’s push token, the onebundle pushuses: the token decides the project, and a repository another project on the box already holds is refused with that project’s name. Thebundle pushwarning now ends with this command. The MCP toolbox_repo_addtakes{url?}and does the same. The box needs this release’s image: an older box answers with the next step, Update in the dashboard.replaces: asking the box’s operator to add the repository to the box config by hand
Terminal window npx @unotest/web box repo add# ✓ github.com/acme/app maps to project shop on this box -
BREAKING: a box rented without a lifetime is now deleted by itself after 24 hours, and charging for it stops then. This is the cloud’s default for every client,
@unotest/web0.54.0 included:box createwithout--ttl, the MCP toolcloud_box_createwithoutttl, and the dashboard’s create form with its default all rent a box that ends a day later. The owner gets a letter when a box’s lifetime ends it. Boxes rented before this change have no lifetime and keep running while they are paid for.Migration: for a box that has to keep running — one CI pushes to — rent it with
npx @unotest/web box create <name> --ttl none(MCP:ttl: "none"; dashboard: Lifetime “No lifetime”), or take the lifetime off one you already rented withnpx @unotest/web box extend <name> --ttl none. -
New: a rented box has a lifetime.
box create --ttl 8h(or2d, up to30d) rents a box that is deleted by itself at the end of it, and charging stops there; without--ttlthe cloud gives a new box 24 hours, and--ttl nonekeeps it until you delete it.box extend <name> --ttl 4hmoves the end later (--ttl nonetakes the lifetime away); nothing is charged at once, the box keeps being charged by the hour while it runs.box statusandbox listshow when a box ends, and within the hour before the endbox statusprints the command that extends it. A key that may not spend by itself asks a person for an extension, as it does for a create, and the request shows the lifetime next to the price. The MCP toolcloud_box_createtakesttl, and the newcloud_box_extendtakes{name, ttl}. In the dashboard, the create form has a Lifetime select, each box shows when it ends next to its price, and Extend moves the end. Update every machine that uses the account: while an extension waits for approval,box approvalsand the MCP list of waiting requests fail on 0.54.0, which cannot read that kind of request. From this version on, a kind of request the client does not know is listed by name instead.Terminal window npx @unotest/web box create shop --ttl 8hnpx @unotest/web box extend shop --ttl 4hnpx @unotest/web box create ci-box --ttl nonereplaces: remembering to run
box destroywhen you are done -
BREAKING: a scenario reads a project variable only by its bare name,
APP_URL, never as a member,globalThis.APP_URL. A test read the variable throughglobalThiswhile the list of variables a bundle reads (readVariables, the gapspushreports) did not name it, so a push let through a suite whose variable the box did not have.globalThis.APP_URLis nowundefinedunless the test also readsAPP_URLitself;lintgives no error on the member form.Migration: read the variable directly:
goto(APP_URL)instead ofgoto(globalThis.APP_URL). For a name only known at run time, useenv(name). -
New:
waitForResponse(glob, {status?, method?, since?, timeoutMs?})waits for the answer to a request and returns{url, method, status, ok, headers, json, text}, so a test can check what an answer draws, or must not, without apause:click(load); waitForResponse('**/api/items', {status: 200}); assertHidden(item). It looks at requests sent from the start of the last call that acted on the page (a click, fill, press, goto, evaluate and the like): a log or an assertion in between moves nothing, the same GET the page made as it loaded is not taken after a later click, and an answer that came before the call is found. Each answer is returned once.{status}and{method}pass over other answers, and a request that failed is no answer.{since}(fromnowMs()) sets the start instead, for when another action came between the one that sent the request and the wait. A timeout fails the test and names the answers it passed over and why. The answer is in but not yet drawn: to show an element never appears, follow withassertNeverAppears. The lint hint onpausenow nameswaitForResponse.networkEntries()entries carry the responseheaderstoo. replaces:pauseafter a click, andwaitUntilovernetworkEntries -
Fixed:
click,doubleClickandpresson a control that closes its own page (a button that runswindow.close()) no longer fail with “Target page, context or browser has been closed” when the page goes away before the action returns. The action did its work, so the step goes on. An action on a page that was already closed is still an error. -
Fixed:
box create --helpno longer says the baseunotest/.envis an environment of its own. Box environments are theunotest/.env.<name>overlays; the base file is only the layer they share, and it is the one environment,default, only when there is no overlay. When no environment has a public address,box createnow says what to do: with a public address inunotest/.envnext to a localhost overlay it explains the layering, and it names where the publicAPP_BASE_URLgoes (e.g.unotest/.env.staging).env setrefusing a loopback or private-network target andcloud_box_create(itsnext) give the same step. -
Fixed: the way to a CI box now says the box needs
--ttl none. The hintinitends with readsbox create --ttl none,box createwithout--ttlnames the lifetime it rents for (for 24 hours by default; --ttl none keeps it for CI), and the docs on running on every push say to rent the box CI pushes to withbox create <name> --ttl none, or to take the lifetime off one you already rented withbox extend <name> --ttl none. Before, a box rented as the hint said was deleted a day later and the workflow’s pushes failed. -
Changed: the workflow
init --ci githubwrites gives its token read access only (permissions: contents: read) and pinsactions/checkoutandactions/setup-nodeby commit, with the version in a comment; Dependabot’sgithub-actionskeeps the pins current. It still runs on every branch, and the file now says why and how to narrow it. A workflow you already have is not changed;init --ci github --forcewrites the new one and keeps yours as.bak. -
Fixed: a scenario that catches a stack overflow (deep recursion, or
JSON.stringifyof a deeply nested value) no longer gets the wrong answers afterwards. An overflow that landed inside a DSL call could leave that call’s reply behind, so the next calls silently read the previous call’s result, or the run failed with “the main thread raised its reply flag with no reply on the port”. Each call now reads its own reply. -
Fixed: a value nested very deep (a loop that wraps a value in itself,
v = [v], fifty thousand times) no longer ends the wholee2ecommand withMaximum call stack size exceededand no verdict. A DSL function handed a value nested deeper than 1000 levels (log,note,assertEqual, any other) refuses it by name,log: value nested deeper than 1000 levels, and the test is errored with that reason; the run prints its verdict and summary as for any other error. The Vars view andinspect_runtimeshow such a value cut at 1000 levels, marked….JSON.stringifyis the engine’s own and throws its ownRangeError, as in any JavaScript.@unotest/dsl/valueexports the bound and the check,MAX_VALUE_DEPTH,nestedDeeperThanandValueTooDeepError, and its snapshot cuts a value at that depth. -
Fixed:
dsl_lookupno longer answers a task the DSL has no function for with functions that share one word with it: “sort table rows” gottextsandopenTerminal, “run the suite in GitHub Actions on every push” gotclockRunandvmRun. When the functions found hold too little of what the task’s words say, the reply has no function, and the pointer to the CLI commands comes first, right after the task. -
Fixed: three hints
initgives right after install. The.bakthatinit --ci github --forcekeeps of.github/workflows/unotest.ymlis now in the.gitignorelinesinitwrites, sogit statusno longer lists it. The hint to download the bundled Chromium readscd unotest && npx @unotest/web install-chromium, like the other next steps: from the project root, npx takes the npm package, not the version your suite pins. Andunotest/.env.example, like thedbQueryerror for a missing driver, installs the database driver into the suite with npm (cd unotest && npm i -D pg @types/pg) instead ofpnpm add -D, which needed pnpm and put the driver in your application. -
Fixed: with
UNOTEST_JUDGE_MODE=local, the judge now finds its provider key (GEMINI_API_KEY,OPENAI_API_KEY,ANTHROPIC_API_KEY) inunotest/.secretsorunotest/.secrets.<env>, the files the run header already reported as loaded; an exported variable still wins. Before, a key kept there was ignored and every run that reachedassertJudgewas refused with “requires GEMINI_API_KEY”. The same holds for a collection’s preflight. The key is handed to the judge only: it is not added to the environment a scenario’sshell()or a child process inherits. When no key is found, the refusal names every place it looked. -
Fixed:
loginon a machine whose key was granted less than a sign-in asks for today — a key from 0.51.0 or earlier, or one withbilling:readunticked on the approval page — no longer answers “already signed in” and leaves the balance invisible for good. It says what the key is missing, runs the sign-in again, and revokes the old key once the new one is saved; whatever is granted this time is kept as granted. A key set throughUNOTEST_CLOUD_TOKENis left alone as before.whoamiand the price line ofbox create/box picker onnow say what to do when a key cannot see the money: runnpx @unotest/web login, or, for a key from the environment, create one withbilling:readin the cabinet. -
Updated dependencies [4c34192]
-
Updated dependencies [e8b257d]
-
Updated dependencies [e8d814f]
- @unotest/protocol@0.55.0
- @unotest/dsl@0.55.0
- @unotest/viewer@0.55.0
- @unotest/core@0.55.0
- @unotest/grounder-client@0.55.0
0.54.0 — 2026-10-24
-
New:
box create --env <name>sends only the environments named; repeat it for several, e.g.npx @unotest/web box create --env staging --env prod. replaces: deleting aunotest/.env.<name>overlay before renting so it would not be sent.Changed: a box runs only your tests and reaches your app at a public address, and the CLI now says so before anything is rented.
box createleaves out an environment whoseAPP_BASE_URLislocalhost, a loopback or private-network address (127.x,10.x,172.16-31.x,192.168.x,100.64.x), a link-local address or a name only a local network resolves (*.local,*.localhost, a name without a dot such as a compose service), and prints a note naming the environment and the reason. When no environment is left, it refuses with exit 2 and nothing is created or charged. A named--envthat cannot be served is refused the same way rather than dropped.env pushsends the values of such an environment but not its target, with the same note.BREAKING:
env set <env> APP_BASE_URL <url>and the MCP toolcloud_box_createnow refuse a target of that kind instead of storing it.cloud_box_createanswers{state: 'unreachable-target'}with each environment and reason, before the cloud is asked for anything.Migration: give the environment a public address (a staging deploy, a tunnel with a public hostname) and set that as its
APP_BASE_URL, or leave the environment out ofenvsand--env. -
New:
npx @unotest/web init --ci githubwrites.github/workflows/unotest.ymlat the root of your git repository. Its steps run inunotest/: on every push it runsnpm ci, so the version pinned inunotest/package.jsonis used, and thennpx @unotest/web bundle push --run --collection examples --json, with the box address and push token read from the repository secretsUNOTEST_BOX_URLandUNOTEST_BOX_TOKEN. In a monorepo the steps run in the project’sunotest/. Outside a git repository no workflow is written. A re-run leaves the file as it is;--forcetakes the template again and keeps yours as.bak. replaces: copying the minimal CI job from the docs and adapting it by hand. -
Fixed: the first
bundle pushafterbox createno longer warns that the repository “maps to no project on this box”.box create(and thecloud_box_createMCP tool) now maps the project’s gitorigin, without credentials, to the box’s project; with no git or no origin the create is unchanged. The warning itself no longer sends a box’s owner to an operator. -
Fixed:
lintno longer reportslint:external-variable-undeclaredfor the own name of a class or function expression used inside it (const Q = class R { m() { return R } },const f = function G(n) { return n ? G(n - 1) : 0 }). The same name outside the expression is still checked. -
Fixed: the line
lintprints when it skips the type check now says wheretypescriptis looked for — next to@unotest/web, in the suite — and how to install it there:cd unotest && npm i -D typescript@5. A box installs the suite alone, so that is wheretypescriptbelongs.initalso listsunotest/unotest.config.mjsunder “Files:” with the rest of what it writes; the config’s outcome is still the line printed after the browser question. -
Fixed:
npx @unotest/web api <word>and the MCP tooldsl_lookupfind a word only where a word begins.api lintno longer printsscrollIntoView(“scrollintoView”), and “sort table rows” no longer suggestsdragAndDrop. A task without a negation no longer finds a check by the words it negates: “wait until the success toast appears” givesassertVisible,waitFor,waitUntil, notassertNeverAppears; “an error toast never shows” still finds it.dsl_lookupnow returns up to four functions instead of three, and every reply carriescommands: where the CLI commands are listed, since a task such as “a typo in a variable name” is whatlintreports.apisays the same when no function matches. -
Fixed: a run of a whole file plays its
test_*functions only. Aflow_*function next to them is no longer played as a run of its own with no arguments, which failed it and added a failure the file did not have. A file with no tests still plays its flows.--test <name>picks a test as before. -
Fixed:
init --force(includinginit --ci github --force) no longer rewritesunotest/package.jsonfrom the template, which dropped every dev dependency except@unotest/webso the nextnpm installremoved them. The existing file is now only completed with what the template has and the file lacks; your dependencies and your@unotest/webpin stay, and nothing changes when nothing is missing. A file that is not valid JSON is refused with its reason instead of being replaced. -
Fixed:
initrun from inside the suite (cd unotest && npx @unotest/web init --ci github) no longer creates a second suite inunotest/unotest/with a workflow pointing at it. Like every other command, it now works on the project the suite belongs to; in a directory with no suite above it,initsets the project up in place, as before. An explicit[target]is unchanged. -
Fixed: the MCP server now sees a
loginmade after it started. Signing in, signing out or switching accounts in another terminal takes effect on the next cloud tool call, without restarting the agent session. Until then the tools answernot-logged-inwith the login command. -
Fixed: a
linter.rulesentry forvalidator:implicit-declarationorvalidator:unsupported-statementno longer loses its level.lintnow says which rule replaced it (validator:implicit-global,validator:top-level-statement) and asks to rename the entry. Until then the old entry’s level applies to the new rule, unless the config sets the new rule too. A removed rule with no successor still gets the old “reports nothing” warning. -
Fixed: the hint
schedulesprints for running a test entry now readsnpx @unotest/web e2e <feature>/<name> --scheduled. The olde2e <name>pointed at a file in the root ofunotest/e2e/, which lint rejects. -
Fixed: a
teardown(…)orstep(…)written without its label or its function, such asteardown(() => { … }), stops the test at that line withteardown is written teardown("label", () => { … }), as it did before 0.52.0. It used to show the function’s internal code as the step’s name and fail later with… is not a function. -
Fixed: with
displayModeset, WebKit pages seenavigator.standaloneon Linux too, as the reference says —trueforstandalone,falsefor the other modes. Before, it was set only where the engine already had the property, which Playwright’s WebKit has on macOS and not on Linux, so runs on a box got nonavigator.standalone. Chromium still has none; withoutdisplayModethe browser’s own is left as is. -
Updated dependencies [eec637d]
-
Updated dependencies [83b2473]
- @unotest/protocol@0.54.0
- @unotest/dsl@0.54.0
- @unotest/viewer@0.54.0
- @unotest/core@0.54.0
- @unotest/grounder-client@0.54.0
0.53.0 — 2026-10-23
-
Changed: the key that created a box now deletes it at once, without waiting for a person in the cabinet. Before, every delete by an agent’s key waited for an approval, and one nobody answered within ten minutes left the box running and charging. Deleting a box created by another key still waits for a person, as do boxes made in the dashboard and boxes created before this change. A new
loginmints a new key, so after logging in again a delete waits too. The account’s owner gets an email that names the key that deleted the box.box destroyprints when billing stopped (destroyedAtin--jsonand in thecloud_box_destroyanswer). When a delete has to wait, the CLI and the MCP tool say why. -
New:
newContext({cdpEndpoint})joins a Chrome that is already running with remote debugging — the browser inside a VM, for example — as another context of the test. The test then drives it with the same steps as its own browser, and the end of the test only disconnects:function test_vm_browser() {step("Join the VM's browser", () => {const vm = vmClone("macos-base");const ip = vmRun(vm);newContext({ cdpEndpoint: `http://${ip}:9222` });goto("https://app.example.com");});}An endpoint nothing answers on fails within 10 seconds with one line that names it. It cannot be combined with
profile.replaces: nothing
-
Changed: a test that never uses a page no longer starts a browser. The browser opens at the first step that needs one, so a test that only drives a terminal, works with values or files, calls an API or an MCP server, or runs a VM starts and ends without Chromium. A browser that is not installed is now reported at that first browser step, with the same install command. The failure bundle of a test that never opened a page holds the error and the terminal, and says in
screenshot-error.txtandpage-html-error.txtthat there was no page to capture. -
New:
networkEntries({ since?, glob? })returns what the active page requested and was answered, so a scenario can check the status and the body of the response a click caused. It is recorded from the moment the page opens, 500 requests per context;fetchandxhrresponses of a text or JSON type come withresponseJsonandresponseText(up to 64 KB), andrequestJsonis the request body read as JSON. A mask starting with/is read on the site’s origin.click(getByRole("button", { name: "Verify" }));waitUntil(() => networkEntries({ glob: "/api/contacts/verify*" }).length > 0);const answer = networkEntries({ glob: "/api/contacts/verify*" })[0];assertEqual(answer.status, 200);assertEqual(answer.responseJson.kind, "mobile");Changed: with
UNOTEST_NETWORKon,network.jsonnow holds the last 500 requests of a context instead of 2000.replaces: evaluate with a fetch wrapper
-
New:
openTerminal()with no app opens theshellterminal — your shell on a pseudo-terminal, its output read through a terminal emulator, with no app window. Nothing takes the keyboard or the screen, so terminal tests run on Linux, in CI and in the Playwright Docker image as well as on a Mac. Every terminal step works on it:terminalTypeandterminalPresssend what an xterm sends (the arrows follow the program’s cursor mode,Altis ESC before the key;Metais refused),terminalResizereaches the program,terminalScreenshotdraws the screen in its colours with the headless Chromium, andterminalClipboardRead/terminalClipboardWriteuse the terminal’s own OSC 52 clipboard.openTerminal('Terminal')andopenTerminal('iTerm2')open a macOS window as before. Theshellterminal needsnode-pty, now an optional dependency with prebuilt binaries for macOS, Linux (glibc, x64 and arm64) and Windows; it is loaded only when a test opens one, and where it did not install the step names the command that installs it. The viewer’s docked terminal moves to the samenode-ptyversion and loader.openTerminal();terminalType("my-cli --version");terminalPress("Enter");terminalWaitForText(/my-cli \d+\.\d+/);replaces: a Terminal.app or iTerm2 window on a Mac that holds the keyboard; nothing on Linux or in CI
-
New:
openTerminal('ssh', {host: 'user@address'})opens a terminal session on a Linux host with no GUI — a VM, a server — overssh -tt, and the sameterminal*steps drive it. A[Y/n], a device code or a password prompt of the host reaches the scenario as it reaches a person. The key comes from your ssh agent or~/.ssh; a password is never passed, and a login that fails shows on the screen.openTerminal("ssh", { host: "admin@10.0.0.5", cols: 120 });terminalType("apt-get install -y htop");terminalWaitForText(/Do you want to continue\? \[Y\/n\]/);terminalType("y");replaces: nothing
-
New:
terminalClipboardWrite(text)andterminalClipboardRead()reach the clipboard of the machine the active terminal window is on, this Mac or the VM of{host}. A token printed in the VM’s terminal goes into a field of the page under test, and a value from the page goes into the VM’s shell. The text is passed as an argument, marked concealed and transient, and never logged.openTerminal("Terminal", { host: "admin@192.168.64.38" });terminalType("my-cli token | pbcopy");terminalPress("Enter");fill(getByLabel("Token"), terminalClipboardRead());replaces: nothing
-
New:
terminalWaitForExit({code?, timeoutMs?})waits for the shell of a terminal to end and returns how:{exitCode, signal}, wheresignalis the signal’s name such asSIGTERM, ornull. A test of a command-line tool can now check its exit code, not only what it printed:function test_cli_rejects_a_bad_flag() {step("The tool exits with 2", () => {openTerminal();terminalType("my-cli --nope; exit $?");terminalPress("Enter");terminalWaitForExit({ code: 2 });});}With
code, a different exit code fails with both codes and the end of the screen. After the exit, typing into that terminal is refused with a message that says to open another one. A Terminal or iTerm2 window cannot report the exit, and the call says so.replaces: nothing
-
New:
openTerminal()takes the keys that set up how its shell starts —env,cwd,shell,cols,rows,term,localeandcleanProfile— so a run does not depend on the machine it is on.cleanProfile: truestarts the shell without your rc files, with onlyPATH,HOME,USERandTMPDIRof the runner’s environment, and a$prompt:openTerminal("shell", {cleanProfile: true,cwd: "unotest",env: { GREETING: "hi" },cols: 100,rows: 30,});terminalType("echo $GREETING $COLUMNS");A GUI terminal window takes none of these keys and says so. A misspelt key is refused with the keys the call reads.
replaces: setting up the shell by hand with terminalType (cd, export, stty) before the test
-
New:
terminalWaitForTextreturns what it saw andterminalText()reads the whole terminal screen, so a value shown in a terminal can go into the test. A regex gives the match array ([0]the match, then the groups), a string gives the first screen row that holds it, and{not: true}givesnull. For example, a device code printed by a CLI goes into a field on the page:const m = terminalWaitForText(/code: ([A-Z]{4}-\d{4})/);fill(getByLabel("Device code"), m[1]);replaces: terminalScreenshot + eyes
-
New:
newContext({profile: '<dir>'})opens a second browser context on a browser profile of its own, in the same test. Two devices of one app can now run side by side, each keeping its own cookies and storage:function test_two_devices() {let a;step("Device A opens the pairing page", () => {a = newContext({ profile: ".profiles/device-a" });goto("/pair");});step("Device B opens it too", () => {newContext({ profile: ".profiles/device-b" });goto("/pair");});step("Back to A, which kept what it stored", () => {setContext(a);});}A relative directory starts at the project root, like
launch.userDataDir; it is made if it is missing and is never removed. It works in a run onlaunch.userDataDirand in a run without one. The same directory twice in a run is refused with a message that names it.newContext()without options behaves as before.replaces: UNOTEST_NO_QUEUE=1 and two e2e processes
-
Fixed:
lint:wait-exceeds-test-budgetnow reports in the viewer’s editor and in the ESLint plugin as well, not only inlint,e2eand the MCP tools. The editor takes the budget from the project’s config when the viewer starts, so a changedtestTimeoutMsshows after the viewer is restarted. -
Fixed: with
UNOTEST_CONSOLE=1a run’sconsole.jsonnow holds the console of every browser context of the test, each line with itscontextIndex, not only the active one.box_run_downloadandbox run --downloadalso unpack a passing run’sconsole.jsonand the full texts of its long notes into.unotest/box/<runId>/, and the reply names that directory ascapturesDir. -
Fixed:
note()andlog()no longer lose the part of a long value past 4 KB. The journal still keeps the first 4 KB, and the whole value (secrets masked, up to 1 MB) is now written to a file in the run directory. A cut value in--notesoutput and in a failed step’s notes ends with… (+N chars, full: <path>), and the event carriesfullandfullLength. -
Fixed: a terminal step in a VM (
openTerminal(app, {host})) no longer fails with “no answer from the terminal controller” and then runs later. A step the controller does not answer in time is withdrawn, and the error says what holds it (“busy withosascriptfor 40 s — Terminal in the VM may be held by a dialog or a sheet; close it, or restart the VM”). A program that runs past its step’s time is killed in the VM, so it does not hold the next steps, and a controller that died is started again, with the waiting step sent once more. -
Fixed: a local terminal step no longer fails with “Terminal is not in front” when macOS refuses to activate Terminal for a process that is not the current app. After a second the run asks System Events to bring the window to the front, and if neither works in 2 s the error names both attempts and the app that kept the focus. A window frame that macOS refuses because Screen Recording is not allowed now says so, naming the app to allow under System Settings → Privacy & Security → Screen & System Audio Recording, and that the other terminal steps work without it.
-
Fixed: a terminal frame no longer carries a secret out of the run. When a value registered in
unotest/.secretsis on the terminal screen,terminalScreenshot()saves the screen text with the secret replaced by‹secret:NAME›(a.txt, returned as the step’s path) instead of the picture, and the viewer shows that text in place of the image. A failed terminal test keeps noterminal.pngin that case and says why interminal-frame-error.txt. A secret the terminal wrapped onto the next row is found and masked too, interminal.txtas well, and in whatterminalText()andterminalWaitForTexthand to the scenario, so anote()orlog()of the screen shows no part of it. Whether a wait orterminalAssertTextsees the text is still decided on the screen as it is: an assertion that a token is{not: true}shown fails while it is, wrapped onto the next row or not. Only registered values are masked: a token that merely looks like one is not known to be secret and stays visible, so register every credential a terminal test may print. -
Fixed: when a test ends, its terminal windows no longer leave their programs running. The processes on a window’s tty and everything they started get HUP, then KILL if they ignore it (a
trap '' HUP, a program with its own handler), before the window closes; a process nothing stops fails the test’s end with its pid and command.closeTerminal()does the same. The step’s record notes what was stopped, e.g.closed window 0 on admin@192.168.64.38; stopped 4312 sh -c … (HUP), 4320 unopipe host install (KILL).New:
openTerminal(app, {leaveOpen: true})keeps the window and its processes after the test, to look at them by hand. -
Fixed: when
terminalWaitForTextorterminalAssertTextruns out of time, the error now quotes the end of the screen (the last 10 rows that are not empty) instead of only pointing at the failure bundle, so a prompt the program is stuck at, such as[Y/n], is in the message and in the test output. A value registered from.secretsis masked in that excerpt, also when the terminal wrapped it, and in the screen excerpt of aterminalTypeecho failure, which was not masked before. -
Fixed: a test that joined a browser over
newContext({cdpEndpoint})now lets go of it before the VM it runs in is deleted. Before, the browser was closed after the VM was already gone, and a browser that had vanished with its VM kept the test waiting for up to half a minute on every failure. The browser’s contexts are closed and the connection dropped first, the terminal window next, the VM last. The DSL reference has a new section, “Three surfaces in one test”, with a worked example. -
Fixed: a scenario that reads a variable which
.env.exampledeclares but nothing gives a value now fails with the same message the pre-run lint prints (“declared in … but has no value here: set it in unotest/.env, the active overlay, or the shell”), instead ofReferenceError: <NAME> is not defined. A variable declared nowhere now fails with “external variable … not found — add it to unotest/.env” and the files it looked in, instead of the sameReferenceError. -
Fixed:
terminalWaitForTextandterminalAssertTextno longer outlast the test. A wait that asked for more time than the test had left used to end as a bare “budget exhausted” with no screen and no name of the wait. It now stops 2 s short of the budget and fails with the end of the screen and a line saying how far it was cut and why.New: the lint rule
lint:wait-exceeds-test-budget(a warning) reports a literaltimeoutMsin a test that is as long as the test’s whole budget, whether the onesetTestTimeout()sets or the project’s. -
Changed:
assertScreenshotnow warns, once per run, when the browser is WebKit and the page has an element withbackdrop-filter. Playwright’s WebKit does not draw that property, so such a design only matches its baseline in Chromium; the docs say so too. -
Updated dependencies [c565c0b]
-
Updated dependencies [12db2ae]
-
Updated dependencies [5578824]
- @unotest/protocol@0.53.0
- @unotest/core@0.53.0
- @unotest/viewer@0.53.0
- @unotest/dsl@0.53.0
- @unotest/grounder-client@0.53.0
0.52.1 — 2026-10-18
- Fixed:
e2ewith no scenario name, and--help, now showe2e <feature>/<name>, the pathlintaccepts. They offeredunotest/e2e/<name>.jsfirst, a file directly inunotest/e2e/, whichlintrefuses aslint:scenario-in-rootandrun_testwill not run. - Fixed: with TypeScript 7 installed, the line
lintprints when it skips the type check now saysInstall a supported one (npm i -D typescript@5)instead ofInstall it, which read as if TypeScript were missing. The README runsschedules,collection … --scheduledandviewerfromunotest/, like the other commands, so they use the version the suite pins. - Fixed: a class declared inside a test, a flow or a helper (
class PAGE { … }in a step) is a local name again.lintandrun_testno longer refuse it aslint:external-variable-undeclared(“PAGEis not declared in unotest/.env”), and the name of a class expression (const Q = class PAGE { … }) is not read as an external variable either. A name nothing declares is still reported as before. A class at the top level of a file remainsvalidator:top-level-statement. - Fixed:
lintno longer fails as a whole when the project has TypeScript 7 installed (Cannot read properties of undefined (reading 'ES2022')). TypeScript 7 has no compiler API for the type check to use, solintnow prints one line, for exampletype check skipped — typescript 7.0.2 is installed: 5.x or 6.x is supported, another major skips this layer, and reports the validator’s findings as before. The optional peer dependency is nowtypescript >=5.0.0 <7. The advice to install it names the version,npm i -D typescript@5, so following it no longer gets TypeScript 7.- @unotest/core@0.52.1
- @unotest/dsl@0.52.1
- @unotest/grounder-client@0.52.1
- @unotest/protocol@0.52.1
- @unotest/viewer@0.52.1
0.52.0 — 2026-10-17
-
BREAKING: scenarios run on V8, the engine of Node and Chrome, as the JavaScript they are. Classes, getters, generators,
Date,Symbol,instanceofand the built-in objects now work, with the semantics of the language; the white list of the old library is gone. A scenario still has no APIs of Node or of the browser (require,process,fetch,window, timers):apiCall,shell,evaluate,pauseandwaitUntilstand in for them, andconsoleprints nowhere. A scenario is not a sandbox: it runs with the rights of the user who started it, as a Playwright test does.What changes for a suite, beyond the
letand Continue entries of this release:keys(),values()andentries()of a Map or a Set, andmatchAll, were arrays and are now iterators.for…ofand a spread read them as before; an array method or a DSL function needs the spread first:[...m.keys()].map(…).- A test was also cut after a fixed count of statements (
maxSteps budget (100000) exhausted). That count is gone: only the time budget (setTestTimeout(ms), elsetestTimeoutMsorUNOTEST_MAX_DURATION_MS) cuts a test, and an endless loop fails on it, naming its line. A run that relied on the count to stop an endless loop sooner now runs until the time budget.
Migration: run
npx @unotest/web lint --fix, commit theletlines it writes, then run the suite. Add the spread where a test reads keys, values or entries with an array method. The upgrade guide on docs.unotest.com lists every case. -
BREAKING: scenarios now run on the V8 engine only, as the JavaScript they are; the interpreter is gone from
@unotest/web, and so is its export:AstExecutoris no longer exported from the package root. TheUNOTEST_ENGINEswitch is gone too; a value left set is ignored.What a run reports changes where JavaScript says it differently: a name nothing declares fails as
ReferenceError: <name> is not defined(lintstill names it, with the advice forwait/sleep/waitForTimeout), and a member read onnullorundefinedfails with JavaScript’sTypeError. An object handed to a DSL function with a key it only inherits, written under__proto__:or made byObject.create(…), fails the step instead of being sent without that key.arr.sortBy(…)is gone with the interpreter’s library: JavaScript’s arrays have no such method.Migration: drop
UNOTEST_ENGINEfrom your environment. Replacerows.sortBy('order')withrows.sort((a, b) => a.order - b.order)(strings:a.name.localeCompare(b.name);{desc: true}: swapaandb). A test rig that builtAstExecutorruns scenarios throughnpx @unotest/web e2eor the MCPrun_testtool instead. -
BREAKING: a variable assigned without a declaration (
i = 0with nolet) is now an error,validator:implicit-global: in JavaScript such a name is a global, shared by every test of the run. It was an info,validator:implicit-declaration.run_testrefuses the scenario on it, as on any lint error;e2eprints it and runs. The diagnostic carries a fix —let i;at the start of thetest_*,flow_*or helper, where every step sees the name — andlint --fixoreslint --fixwith the plugin writes it.The validator no longer refuses JavaScript the scenario language left out, nor members the old library lacked:
validator:out-of-language,validator:library-memberandvalidator:unsupported-string-memberreport nothing. A member a string, an array, a number, a Map or a Set does not have in JavaScript isvalidator:unknown-member, now checked against JavaScript itself, so'a'.charCodeAt(0)and/a/.exec(s)lint clean. A statement at the top level of a file isvalidator:top-level-statement(it wasvalidator:unsupported-statement).Migration: run
npx @unotest/web lint --fixonce and commit theletlines it adds. Inlinter.rules, renamevalidator:unsupported-statementtovalidator:top-level-statementandvalidator:implicit-declarationtovalidator:implicit-global, and dropvalidator:out-of-language,validator:library-memberandvalidator:unsupported-string-member. A config that still names one of the five loads: the entry is ignored, andlintwarns that it does nothing.@unotest/protocol:DslDiagnostic.fix—{range: [start, end], text}, the edit that removes the finding, with 0-based offsets into the source.@unotest/dsl:ScenarioDiagnostic.fix, andapplyFixes(source, fixes)from@unotest/dsl/validator. -
New:
npx @unotest/web linttype-checks each scenario as the JavaScript it is — the file, the helpers of_helpers/, the DSL declarations and the external variable names. It reports, as warnings underts:<code>, what the validator leaves to JavaScript: a name nothing declares, a member a value does not have (Math.tau), an argument of the wrong type (n.toFixed('two')). Where the validator reports at the same position, its line stands alone. The check needstypescriptin the project (npm i -D typescript, an optional peer dependency); without itlintsays once that the layer was skipped. Andlint --fixwrites the fixes the diagnostics offer before it reports. replaces: reading a scenario by eye for a typo of a name or a key -
BREAKING:
asyncandawaitin a scenario are a lint error,validator:async-await. Before,awaitwas allowed in anasync functionand changed nothing. Now that scenarios run as JavaScript, an async function returns at its firstawait, and the rest of it runs after the test is over: its checks fail nowhere, and the test passes. DSL calls return their result, so a scenario has nothing to await.run_testande2erefuse to start on it, and a run that gets there anyway fails: a test, a step, a rollback block, awaitUntilor anassertNeverbody that returns a promise fails the test with… returned a promise: … drop async and await. Async code handed to the page,evaluate(async () => …), is the page’s and is not reported.Migration: run
npx @unotest/web lint --fix. Each finding carries a fix that drops the word, solint --fixandeslint --fixwith the plugin remove everyasyncandawaitthe scenarios hold. -
BREAKING: Continue on a failure pause now finishes the test as failed. No statement after the failure runs; the
teardown(...)blocks play and the failure bundle taken at the pause stays the run’s. Before, the run went on with the next statement and still ended failed, so a debug run could pause again at a later failure. The CLI says it at the pause:⏸ paused on failure at <file>:<line>:<col> — Continue finishes this test as failed. The viewer now offers Continue on a failure pause, labelled Finish as failed; Step is not offered there. MCPresumeon apaused-failureruntime ends it the same way. A failure pause the runner resumes as a retry (resumeRetriesFailedStep) is unchanged.Migration: to see what follows a failure, fix the scenario and run the test again; a
resumeper failure no longer walks the test to its end. -
Changed:
vieweropens a browser tab only when a person is at the terminal — stdin and stdout are TTYs andCIis unset (or0/false). Launched by an agent, a script or CI, it prints the URL and opens nothing.viewer --openopens the tab anyway;UNOTEST_VIEWER_NO_OPEN=1still turns it off and beats--open.loginfollows the same rule, so underCIit no longer opens a browser even on a pseudo-terminal, and the commands that ask a question no longer ask it there:inituses the system browser when it finds one (as in a pipe),box createleaves the Picker off unless--pickeris given, andbox destroyneeds--yes.viewernow refuses an argument it does not know (exit 2) instead of ignoring it. -
Fixed:
loginnow also asks forbilling:read, sowhoamishows the balance on the documented path instead of “not visible to this key”. The approval page still lets you untick it. A key from an earlierloginkeeps the permissions it was given: runloginagain (afterlogout) to get one that sees the balance. -
Changed: a test that never ends is cut by its time budget (
setTestTimeout(ms), elsetestTimeoutMs), not by a count of statements: the test fails naming the budget and the statement it was on, and its teardown blocks still play. Code that never returns to the runner, such as a regular expression that backtracks for minutes, is stopped a second after the budget, and its teardown blocks cannot play. A run that keeps too much in memory ends on a fixed memory limit, and the runner keeps going.@unotest/dsl/executorexports the budget readers a second engine shares with the interpreter:durationBudget,timeLeft,withinDeadline,runHeapLimitBytesand theBudgetOptions/BudgetSitetypes. -
Changed:
@unotest/dsl/executorexports what a second scenario engine needs to behave like the interpreter: thewaitUntilandassertNeveroption readers and verdicts (readWaitUntilOptions,waitUntilVerdict,readAssertNeverOptions,assertNeverVerdict), a synchronous polling loop (pollWindowSync),tagText,toExecutionErrorandsoftFailuresError. A rollback frame is nowTeardownFrame<B>, generic in what the engine that registered it plays it with (ScenarioTeardownFrameisTeardownFrame<Closure>); an engine handed a frame another engine registered refuses it withForeignTeardownFrameError. Scenarios run exactly as before. -
Changed: debugging works on V8 as it did before: breakpoints, Step, the viewer’s Pause, the pause on a failure nobody catches, Vars while paused, and Stop on a pause.
-
Changed:
@unotest/dslexportspageCodeArgumentOf(call, pageCodeArg), the argument in a call’s page-code position as written, and@unotest/dsl/executorexports the declaration readers the interpreter hoists with (lexicalDeclarations,patternNames,hoistedVarNames). Scenarios run exactly as before. -
Fixed: time a test spends held on a pause (a breakpoint, a Step, a failure being looked at) no longer counts toward its time budget (
setTestTimeout(ms), elsetestTimeoutMsorUNOTEST_MAX_DURATION_MS). After a long look at a breakpoint or a Step, Continue goes on with the budget the test had left when it stopped, instead of failing it withbudget exhaustedat the next line. On a failure pause, Continue finishes the test as failed. -
Changed:
keys(),values()andentries()of a Map or a Set, andmatchAll, give iterators, as in JavaScript.for…of, a spread andArray.fromread them; array methods need the spread first:[...m.keys()].map(…). A DSL function given an iterator fails, naming the line and the spread to write; it takes[...m.keys()]or the Map itself. The editor typings say the same, and lint warns on an array method called on an iterator.upload()keeps its meaning wherever the scenario passes it: as the body ofapiCallit is sent as multipart, nested in a JSON body it is refused as before. -
Changed: the V8 thread a scenario runs on starts without the runner’s own credentials — the cloud sign-in token, the box tokens and the judge’s and grounder’s keys — on the CLI and over MCP alike. A scenario already could not read them through
getVar,{{NAME}}orshell(); this keeps them out of the thread itself. Nothing to check when upgrading from 0.51.0: no scenario there could reach them. Variables a suite declares, or a CI job exports, read as before.The docs now say plainly what this is: a guard against a slip, not a sandbox. A scenario runs with the rights of the account that starts it.
-
Fixed: the viewer on a box starts again. Since 0.50.0 it created the breakpoint folder in the project at startup; a box mounts the project read-only, so the viewer exited and was restarted every few seconds, and the box showed “the viewer for this environment is not running”.
The viewer now creates nothing at startup: the breakpoint folder appears with the first breakpoint. On a box the breakpoints live in the environment’s run history, where both the viewer and the runs can write. Locally nothing changes: the store is still
.unotest/debugger.json.A breakpoint that cannot be saved because the folder is read-only is no longer lost silently: the viewer shows the reason above the steps.
@unotest/protocol:localStateDirFrom(env, projectRoot)names the directory of the tool’s local state, anddebuggerFileNameFor(suffix)the store’s file name inside it.@unotest/core:debuggerFileIn(stateDir, projectRoot, suffix)takes that directory explicitly;debuggerFileandreadDebuggerBreakpointstake an optional environment;isNotWritable(error)tells a read-only refusal apart. -
Updated dependencies [82ca2ac]
-
Updated dependencies [440bdcb]
-
Updated dependencies [dd454ed]
-
Updated dependencies [843b58e]
-
Updated dependencies [9159456]
-
Updated dependencies [1946b74]
-
Updated dependencies [423d119]
-
Updated dependencies [223f143]
- @unotest/dsl@0.52.0
- @unotest/protocol@0.52.0
- @unotest/viewer@0.52.0
- @unotest/core@0.52.0
- @unotest/grounder-client@0.52.0
0.51.0 — 2026-10-15
-
New:
animations(getByTestId('status'))returns every animation of the element and its descendants as[{name, kind, playState, currentTime?, infinite}], the typed read of what is moving and how. replaces: evaluateOnLocator with getAnimations.nameis the keyframes name, the property a transition moves, or a script animation’s id. Infinite and paused animations are listed too, marked byinfiniteandplayState;hasAnimationandwaitForAnimationscount only the running ones that end by themselves, as before. -
New:
npx @unotest/web api [word...]finds the DSL function for what you mean. replaces: searching the reference by a name you do not know yet. It prints one line per function: signature, the keys its options take, the release that added it, and when you need it. Search by the habit you know:api wait animationgives waitForAnimations,api not appeargives assertNeverAppears,api sleepgives pause; every word must match the name, a synonym or the description. Without a word it lists every function by section. The same index ships asllms.txtat the package root and as the first screen of the README, “What unotest can do”. -
New:
apiCall('POST', '/live/start', body, null, {timeoutMs: 150000})gives one call a budget of its own, the reply body included. replaces: nothing (a call waited as long as the step and the test allowed, and{timeout}was refused). Past the budget the request is aborted and the step fails as a timeout naming the call by method and path, without the base or the query. Without{timeoutMs}a call behaves as before. It combines with{base}in the same options object. -
New:
assertFileLacks('logs', API_TOKEN)andassertFileHas('tmp/dump.sql', /v1:[A-Za-z0-9+\/=]{20,}/)check that a string or a regex is absent from, or present in, a file or a whole directory tree. replaces: shell with grep after the run, outside the report. A string is searched as bytes, so binary files count; a regex reads files as UTF-8.assertFileHaswaits up to{timeoutMs}(default 20 s).assertFileLackssearches once, or watches{withinMs}, and a path that does not exist fails rather than passes. A hit names the file and the line, with the matched bytes replaced by‹match, N bytes›, so a regex that catches an unregistered secret does not copy it into the report. Paths follow the same rules asreadFile; symlinks inside a directory are not followed. -
New:
assertNever(() => apiCall('GET', '/me').status == 200, {withinMs: 5000}), a negative window for a condition, the pair ofassertNeverAppearsfor anything that is not a locator. replaces: pause. The body (an expression, or a block thatreturns its value) is polled for the whole window; the step fails the moment it is truthy, naming the value and the time into the window, and passes when the window elapsed clean.pause(ms)followed by one assertion misses an event that came and went during the pause.withinMsis required,intervalMsdefaults to 250. A malformed call is reported under the new validator rulevalidator:assert-never-shape. A suite function of its own namedassertNeverkeeps running as before. -
New:
assertClipboard(/\/invite\/\d+$/)waits until the clipboard holds the text a “copy” button wrote, andclipboardRead()returns that text. replaces: evaluate replacing navigator.clipboard.writeText in the page. A string is the whole text, a regex a match;{timeoutMs}defaults to 5 s. Works in Chromium and Firefox on an https or localhost page. WebKit does not let a page read the clipboard, and the call fails naming the engine instead of returning an empty string. In Chromium every new browser context now has clipboard read and write access, so a page’s own copy button succeeds in a headless run, as it does for a person in desktop Chrome. The clipboard belongs to the browser, so tests running in parallel in one browser share it. -
BREAKING: a DSL call now checks, when it runs, the kind of every argument lint could not see: a variable, a field of an API reply, a value built in a loop. One of another kind stops the step on its line with the sentence lint shows for a literal:
fill(): argument 2 (text) must be a string value, got number 42,click(): option timeoutMs must be a number, got a string "5000". Before, some functions refused such a value in words of their own and some coerced it:getByText('Total', {exact: e})witheholding the string"no"matched exactly and failed seconds later aslocator not visible within timeout. A condition is still read by truthiness, so only a number, a string or a locator passed as one is refused. Where a client’s suite relied on a coercion, the call still runs with a warning ending inan error from 0.52.0.Migration: convert the value where it is made,
String(total)for text,Number(env('WAIT_MS'))for a time limit, a comparison for a condition. Run the suite once and fix every line the run stops on or warns about.Changed: lint now checks
setViewportandemulateMediaarguments before the run, as the run already did: a misspelt key ({heigth}), a valueemulateMediadoes not take ({colorScheme: 'blue'}) or anything but a size ornullforsetViewportis named on its line. The editor types follow:setViewport(size: {width, height} | null), andemulateMediaoffers its values.Signatures, the API index (
npx @unotest/web api,llms.txt) and the DSL reference now name the time limittimeoutMs, the key the option lists and refusals already used.timeoutstill works as an alias; the index marks it once per function:options {state?, timeoutMs?} (alias: timeout → timeoutMs). Signatures that missed keys their function takes now list them (waitUntilon goBack, goForward and waitForUrl;forceon selectOption;positionandmodifierson doubleClick), andwaitForTextno longer offersfirst, which has been refused since 0.50.0.The DSL reference has a Signatures section: every function with the types it takes, generated from the same contract lint and the run check against, so it cannot drift from them.
Changed:
waitUntil(goto, reload, goBack, goForward, waitForUrl) takes'load','domcontentloaded','networkidle'or'commit', and lint and the run name a misspelt one on its line:goto(): option waitUntil must be one of: load, domcontentloaded, networkidle, commit. Before, it reached the browser, which failed the step with its own message. The reference now lists'commit'and the editor offers the four values.@unotest/dsl: the argument kind'null', for a slot that takesnullas one member of a{oneOf};hinton an argument, a sentence lint and the run append to a refused value (round(): argument 1 (n) must be a number, got a string "2.5" — read text with toNumber(text) first), and option notes appended the same way to a key’s refused value;canonicalOptionKeys, the keys of an options schema without their aliases. -
New: a
collectionrun says how long it took. The summary line ends with the wall clock of the whole run, every worker at once —collection regress: failed — pass=21, fail=1, time=252.3s— and each test of a file of several gets its own time beside its name, as the file’s line already had:✓ test_checkout (812ms). replaces: wrapping the run intimeand adding up the per-file lines. The collection’s journal carries the same total on its last line (durationMs), and each test’s time in the file’s entry, for whatever reads the run afterwards. -
New: MCP tool
dsl_lookup({task: 'wait until the dialog stops animating'})answers “how do I” in plain words with the 1-3 DSL functions for it, best first: signature, option keys, the release that added it, one example line and where the shipped guide describes it. replaces: guessing a function name, or writing pause, page code or shell for a step the language already has. Words no function holds return none and a hint. It is part of the core toolset too. The answers come from the same index asnpx @unotest/web api, which now also matches a longer form of a word (animatingfinds waitForAnimations). -
New:
lint:external-variable-no-valuereports a variable that onlyunotest/.env.exampleor.secrets.exampledeclares while no file, overlay or the shell gives it a value. replaces:lint:external-variable-undeclaredfor that case, whose text sent the author to add a name that was already declared. The message names the template and where to set the value:unotest/.env, the active overlay, or the shell. A run reports it (run_test, the pre-run check ofe2e); thelintcommand reads files alone and counts a template name as declared, so a value a CI runner exports does not turn it red.lint:external-variable-undeclarednow means a name no file declares at all.Fixed: the run header lists only the files values come from, in order, and names
.env.example/.secrets.exampleapart asdeclared names only; a placeholder value in a template never reaches the run. -
New: config key
profile: "ios"runs the suite in WebKit as an iPhone (the"iPhone 15"preset: size, scale, touch, mobile mode, user agent) launched from the home screen, in one line;browsersordevicenext to it is refused. replaces:browsers: ["webkit"]withdeviceand a page-sidenavigator.standalonestub. Config keydisplayMode: 'browser' | 'standalone' | 'minimal-ui' | 'fullscreen'sets the launch mode the page’s scripts see from the first document on:matchMedia('(display-mode: standalone)')answers for it, and in WebKitnavigator.standaloneistrue. No engine emulates the feature itself, so CSS@media (display-mode: …)still sees a browser tab. Playwright WebKit is not Safari on iOS: no zoom on focus, no on-screen keyboard. -
New: linter rule
lint:typed-step-available(info) names the typed step for code a scenario wrote by hand, e.g.pause(5000); assertHidden(getByText('Error'))→assertNeverAppears(getByText('Error'), {withinMs: 5000}). replaces: pause before an absence check, pause to let an animation settle, evaluate with getComputedStyle, evaluate with naturalWidth.It reports
getComputedStylein page code (usecomputedStyle(locator, property)),naturalWidth/complete(usegetImageSize(locator)), apausebeforeassertHiddenorassertCount(…, 0)(useassertNeverAppears(locator, {withinMs})), and apausewhose step label or comment mentions a fade, an animation or a control being enabled (usewaitForAnimations(locator)orwaitFor(locator, {state: 'enabled'})). Each message names the section ofnode_modules/@unotest/web/guides/dsl-reference.mdthat describes the step. Set the rule tooffinlinter.rulesto silence it. -
New: MCP client steps — a scenario starts a stdio MCP server, calls its tools and waits for its notifications:
const srv = mcpStart('node', ['server.js']); mcpCall(srv, 'echo', {text: 'hi'}); mcpWaitNotification(srv, 'notifications/message', {match: {'data.echoed': 'hi'}}). Every call, result and notification is a note in the run’s report; a failing step (JSON-RPC error, timeout, a result markedisErrorunless{allowError: true}, the server exiting) quotes the server’s last MCP message, so the failure bundle shows it.mcpStop(srv)is optional: a server the test leaves running is stopped when the test ends, passed or failed.envpassed tomcpStartis named in the report, never its values. replaces: bun scripts next to the suite. -
BREAKING: page code given to
evaluate,evaluateOnLocatororevaluateInServiceWorkeras a string is now parsed by the validator. A string that is not one JavaScript expression is refused before the run asvalidator:page-code-syntax, with the line and column inside the string, e.g.evaluateOnLocator(locator('body'), '(b) => b.querySelectorAll("li").'). Such a step used to fail only when the run reached it.A string that parses gets an info,
validator:page-code-string, with the rewrite to a function literal: a function in quotes loses the quotes, an expression becomes() => …. A function literal is checked with the file. Text built with+or held in a variable is not parsed.Migration: fix the syntax the message points at, or write the page code as a function literal:
evaluateOnLocator(loc, (el) => el.textContent). A regular expression inside a backtick template is a common case:\/there reads as/, so`… /^a\/(b)/ …`reaches the page as an unterminated group — the step failed at run time already, now it is named before the run. Write\\/or useString.raw`…`. Setvalidator:page-code-stringtooffinlinter.rulesto silence the info. -
New:
setPageHidden(hidden, index?)sends a tab to the background as the page’s scripts see it, and brings it back.setPageHidden(true)makesdocument.hiddentrue andvisibilityState'hidden',document.hasFocus()false, and firesblurthenvisibilitychange;setPageHidden(false)firesvisibilitychangethenfocus. The state holds across navigations and in frames until the tab is shown again. replaces: overridingdocument.hiddenby hand inevaluate, or handing the check to a person.It emulates visibility for the page’s scripts in Chromium, Firefox and WebKit alike — no engine hides a tab of a headless browser. What the browser itself does to a background tab is not emulated: timers are not throttled, animation frames keep running, the page is not frozen, and a service worker’s
clients.matchAll()still reports it visible.setPageandbringToFrontdo not show a hidden tab. -
New:
TestRuntimeManager.inspectState(id)returns a runtime’s snapshot withoutvars— state, last event, failure, result, rollback — and does not walk the scenario’s variables. replaces:inspect(id)for callers that never readvars. Example:manager.inspectState(id).lastResult.inspect(id)is unchanged and still returns the whole snapshot. The runner’s own polls now use it, so a paused or running test builds its variables once per tick instead of three times. -
New:
selectOption(getByLabel('Folder'), {label: 'Archive'})picks an option by its visible text, and{index: 0}by its position, next to the value forms (a string,{value}, or an array for a multi-select). replaces: evaluateOnLocator reading an option’s value by its text. The call waits for the option; a label or index no option has fails naming the labels the element holds instead of a bare timeout. The validator now checks the value: a misspelt key such as{lable}isvalidator:options, before the run and during it, and a number isvalidator:arg-kind. Theexplore_stepMCP tool takes the same forms forselect_option. -
New: virtual machine steps over Tart — a scenario clones a ready image, boots it and reaches it by IP:
skip('no tart', !vmAvailable()); const vm = vmClone('clean-mac'); const ip = vmRun(vm);. Each VM is namedunotest-<id>-<n>, the id random per test, so parallel runs never meet; every VM the test cloned is stopped and deleted when it ends, passed or failed.vmIp,vmStopandvmDeletetake only a namevmClonereturned in the same test — other VMs on the machine are refused, never touched. A boot that fails errorsvmRunat once with Tart’s output instead of waiting out the timeout (default 180 s). Clone and boot are notes in the run’s report. replaces: tart by hand. -
New: terminal steps drive a real Terminal.app or iTerm2 window on macOS: real key events, the screen read as text, the window framed —
openTerminal('Terminal'); terminalType('my-cli'); terminalPress('Shift+Enter'); terminalWaitForText(/ready/). replaces: osascript and expect outside unotest.openTerminal(app, {host})opens the window on this Mac, or on another Mac over ssh ({host: 'admin@192.168.64.38'}, a Tart VM) through a controller it starts in that Mac’s logged-in session; the key comes from your ssh agent or~/.ssh. The steps:openTerminal,setTerminal,closeTerminal,terminalType,terminalPress,terminalWaitForText,terminalAssertText,terminalResize,terminalScreenshot. Each lands in the run report like a page step, and a failed test keeps the window’s last frame, its screen text and its host in the failure bundle (terminal.png,terminal.txt,terminal.json). Every window a test opened is closed when it ends. A local run takes the keyboard while it types; keys are sent only while the terminal window is in front. -
New:
npx @unotest/web upgrade-hintsfinds the places inunotest/e2e/that do by hand what a typed step does. replaces: readinglintinfo lines one file at a time. It prints the count per pattern, the step to write instead with its section in the DSL guide, and every file and line: apausebefore checking that an element is absent (assertNeverAppears), apauseto let an animation settle (waitForAnimations) or a control become enabled (waitFor {state: 'enabled'}),getComputedStyleornaturalWidthin page code (computedStyle,getImageSize), and page code written as a string (a function literal). It shows these rows even whenlinter.rulesturns their rule off; a line marked// lint-okis not counted. The first run on a new version shows the counts once:e2eprints them, and the MCPrun_testreply carries them asupgradeHints{state: “typed-steps-available”, version, hints, next}. -
BREAKING:
waitForRequestwith aws://orwss://mask is refused before the run asvalidator:arg-kind, and by the run, withWebSockets are not requests: use waitForWebSocket. A socket’s upgrade is no request and the page’s request events never see it, so such a wait used to spend its whole timeout and fail.Migration:
waitForRequest('ws://…')→waitForWebSocket('ws://…'). -
New:
waitForWebSocket(glob, {timeoutMs?}) → {url}waits until the active page has opened a WebSocket whose URL matches the mask and returns its URL. A socket the page opened as it loaded counts, so the wait may come after it; a new document starts the list again. A timeout names the mask and the sockets the page did open. replaces:waitForRequeston a socket mask, which waited its whole timeout and never saw the socket.Changed:
mockRoutewith aws://orwss://mask runs as before and is named with a warning, since it has never had an effect. The reference no longer says a mask reaches WebSockets.@unotest/dsl:refuseon an argument, strings of the right kind the slot still turns down by how they start, with a sentence and an error or warning level;refusalMessage, the text lint and the run give for one. -
New:
addVirtualAuthenticator()gives the page a passkey with no person at a device: a virtual WebAuthn authenticator answersnavigator.credentials.create()and.get().virtualCredentials(id)lists what it holds, by base64url id, rpId and sign count, andremoveVirtualAuthenticator(id)takes it off. replaces: signing in with a passkey by hand in your own browser. Chromium only; Firefox and WebKit fail the call naming the engine. New:grantPermissions(['notifications'], {origin?})grants browser permissions to the context; a name the engine does not know fails naming the engine, and earlier grants stay.setOffline(true)cuts the context off the network andsetOffline(false)connects it again. replaces: nothing. Authenticators, permissions and offline are all undone when the context closes. -
New:
@unotest/dsl/validatorexportschainReceiverIsArgument(contract): whether a chained call of a vocab method gets the receiver as its first argument. replaces: repeating the rule next to the validator. A chain step such asfilterdeclares the receiver in its signature. A chainable builder such asgetByRolekeeps the arguments as written. The validator and tools that read a scenario line arguments up by this one rule. -
Changed:
dsl_lookupandnpx @unotest/web apiweigh each word of a query by how few functions hold it, so a rare word decides over common ones: “send the tab to the background and back” now findssetPageHiddenfirst instead ofnewPage,goBackanddragAndDrop. A word that names an option key exactly (hasNot,waitUntil) counts for the functions that take it: “locator that contains a child element (has, hasNot)” findsfilterandlocator. New search words:background,visibilityandtab hiddenforsetPageHidden,faded inforwaitForAnimations. -
Fixed: the refusal of
new X(…)names the constructors the library has —new X(…) is not part of the scenario language — use new Map / new Set, the constructors the library has; for anything else …— sonew Set([1])running beside a refusednew Error('x')reads as one rule. The DSL reference and the language page list the constructors from the library itself, and the check that keeps the editor typings fresh keeps that list fresh too. -
Fixed:
validator:template-escape-changedno longer warns on an escape already migrated. It now reports only a backslash escape JavaScript drops —\sreads ass,\.as.— the regex text a backtick template used to keep and now silently loses.\\s,\n,Aand the other escapes JavaScript reads as meant stay quiet, so doubling the backslash clears the warning withoutString.rawor switching the rule off. -
Fixed:
openTerminalon a freshly booted macOS VM no longer fails now and then with “Terminal.app cannot be opened … is someone logged in?”. The VM answers ssh before its automatic login has made the GUI session; the step now waits for that session (up to 90 s) instead of a fixed 15 s for a single attempt. An image that logs no one in fails the step with what was waited for and how long.Fixed: when
openTerminal(app, {host})cannot log in to a VM with a key, the hint now gives the commands for the image the VM was cloned from (tart run <image> &,ssh-copy-id admin@$(tart ip --wait 120 <image>),tart stop <image>) instead of anssh-copy-idto the clone’s address, which the run has already deleted, and says thatssh-copy-idasks for the image’s password. -
Fixed:
terminalTypetyped the letters of the Mac’s current keyboard layout, so under a Russian or another non-US layout the terminal got different text (cleararrived as the Cyrillic letters on the same keys). Text now goes in as a paste, line by line with Return between the lines, and the clipboard is restored after. Each line must show on the screen within 2 s, or the step fails quoting the screen’s last rows instead of typing on blind;terminalType(password, {echo: false})types input a program does not echo.terminalPresswith a modifier (Control+c) presses that key of a US keyboard whatever the layout; a single character alone is pasted, and a character with a modifier but no US key of its own (Control++) is refused, naming the key to press (Control+Shift+=). -
Fixed:
openTerminal(app, {host})on a VM whose image does not hold your ssh key failed with ssh’s barePermission denied. It now says that ssh could not log in with a key, and how to put the key in the image once (ssh-copy-id admin@<ip>on a booted clone, then stop it) or load it into the ssh agent. The DSL reference shows that one-time step under “Virtual machines”: a clean Tart image takes a password only, and the run never passes one. -
Fixed:
throw new Error('x')in a scenario is refused with the throw the language has —throw 'x'orthrow {message: 'x'}— instead of the generalnew X(…)advice about object literals and regex literals, inlintand in the run alike. -
Fixed:
lint:typed-step-availablereads page code written as a string too.evaluate("getComputedStyle(document.body).color")now gets the hint forcomputedStyle(locator, property)in the same pass as thevalidator:page-code-stringinfo, instead of only after the string was rewritten as a function. Text that does not parse, or that is built at run time, is not read. -
New:
e2eandlintin a project withoutunotest/jsconfig.jsonsay once, in one line, to runnpx @unotest/web types, which writes it so the editor completes the scenario functions. replaces: finding thetypescommand on your own. A run still writes no jsconfig itself; the hint is not shown under CI or on a box (UNOTEST_EDITOR_TYPES=0), and the project’s.unotest/remembers it was shown. -
Changed: the
waitUntilreference and the Playwright migration guide say where state between tries lives. replaces: a counter declared at the top of the file, which never runs. A counter or the last value seen is aletinside thetest_function or the step, and the body writes it; a top-levelletis refused by the validator (validator:unsupported-statement) and the body then finds no such variable. -
Updated dependencies [6ea4085]
-
Updated dependencies [ac8f70a]
-
Updated dependencies [1c16eba]
-
Updated dependencies [85c9f78]
-
Updated dependencies [f136ad0]
-
Updated dependencies [355f512]
-
Updated dependencies [c63e688]
-
Updated dependencies [8d7770d]
-
Updated dependencies [78ece21]
-
Updated dependencies [5cfd9b1]
-
Updated dependencies [52cf309]
- @unotest/dsl@0.51.0
- @unotest/protocol@0.51.0
- @unotest/viewer@0.51.0
- @unotest/core@0.51.0
- @unotest/grounder-client@0.51.0
0.50.0 — 2026-10-14
-
Breaking, added after the release:
apiCalltakes at most five arguments: method, path, body, headers and options. 0.49.2 dropped a sixth one without a word, so a call such asapiCall('GET', '/status', null, {}, {base: 'API_BASE'}, {timeout: 120000})ran as if the sixth were not there. 0.50.0 refuses it:apiCall() takes at most 5 arguments, got 6 — remove the extra argument, and the test ends errored.Migration: remove the sixth argument. The call never read it, so it behaves as it did in 0.49.2. A per-call timeout for
apiCallis a separate feature of a later release. -
Breaking, added after the release: a backslash escape in a backtick template now means what it means in JavaScript. Before 0.50.0 it was kept as written. The change is silent for page code:
evaluateOnLocator(loc, `(el) => /\s+/.test(el.textContent)`)reaches the page as/s+/, the regex looks for the letters, and the check gives a wrong answer with no error. The lint warningtemplate-escape-changedmarks every such template.Migration: in a template, write a backslash meant for the page as
\\(/\\s+/), or wrap the template inString.rawto keep the text as written. Better, pass the page code as a function,evaluateOnLocator(loc, (el) => /\s+/.test(el.textContent)): the validator reads it with the file and no escape is involved. -
Scenarios are read by the new JavaScript parser and run by the new interpreter:
run,e2e,lint,explore_run_flow, the viewer’s editor and the bundle checks all go through it.evaluateandevaluateOnLocatortake a function as the page code:evaluate((n) => document.querySelectorAll('li').length > n, 3),evaluateOnLocator(loc, (el) => el.dataset.state). The page gets the function as it is written, like Playwright’sfn.toString(), so it sees none of the scenario’s variables: values go in as the arguments after it. The string form works as before, and the advice of 0.49.2 to pass the function as a quoted string no longer applies. A function anywhere else in a host call is still refused.apiCall(…, {base: "API_BASE_X"})readsAPI_BASE_Xfrom the project’s environment only (unotest/.env,.secretsand the active overlay). A scenario variable of that name is no longer read as the base. When the environment has no such variable, the call is refused and says where to put it.run_testchecks a scenario with the same diagnostics aslintbefore the browser starts, the vocabulary checks (validator:*) included, and answers any error withlint_failedbefore anything runs. Info-only diagnostics never hold a run back. Whate2estops on is told in its own entry.Migration:
- A scenario that assigned the base URL itself (
API_BASE_X = "https://…") and passed{base: "API_BASE_X"}: putAPI_BASE_X=https://…inunotest/.env, or in the overlay of the environment that needs it. - Code that imports the engine from
@unotest/webdirectly:lintAsttakes an ESTree node (parsed.program);AstExecutorruns a parsed scenario,run(parsed, helpers, testName, runtime, options); thenodeof anExecutionEventis an ESTree node;TestRuntimeManager.registertakesscope, the executor’sinspectScope. Parse withAcornScenarioParserfrom@unotest/dsl. @unotest/web/dsl:createWebDslLanguageService()also hasvalidateParsed(parsed, context), the same diagnostics for a file already parsed.lint:filter-unknown-optionandlint:role-unknown-optionare deprecated and report nothing:validator:optionsreports a key thatfilter,locator,getByRoleor any other call does not read, with the sentence the run refuses it with.lint:regex-invalidis deprecated too: a regex that does not compile is a syntax error of the file. The three ids still load fromlinter.rules; drop them from your config, they go away in the next major.
@unotest/dslgainswalkandchildNodes, a walk over a scenario’s ESTree in source order,scenarioFromProgram(program, source, comments, file), a parsed scenario from a tree another parser built (an ESLint rule’sProgram), and the host hookpageCodeArg(name)that names the argument a function may be handed to as page code.@unotest/corereads a run’s last event by its shape, so it takes the events of either executor. - A scenario that assigned the base URL itself (
-
The step budget now fits scenarios that work with large arrays, and still stops an endless loop quickly.
- A run allows 100,000 steps by default, up from 10,000. A step is a statement or a loop pass. An endless
whileorforstops withmaxSteps budget (100000) exhaustedwithin about 20 seconds. - A call of an arrow function with an expression body spends no step.
items.map((x) => x.id)orrows.filter((r) => r.ok)over an array of up to 1,000,000 elements runs to the end. Each call still checks the test’s time (setTestTimeout,testTimeoutMs,UNOTEST_MAX_DURATION_MS), so a long run of callbacks is cut by it. - An arrow function with a block body spends a step per call, as any statement does.
- A run allows 100,000 steps by default, up from 10,000. A step is a statement or a loop pass. An endless
-
Breakpoints set in the viewer no longer dirty the work tree. The store moves from
unotest/.debugger.jsonto.unotest/debugger.json(.unotest/debugger-mobile.jsonfor mobile).initalready lists.unotest/in.gitignore. The viewer, the CLI (e2e --debug) and the MCPrun_testread and write the new file.Migration: nothing to do by hand. The first viewer start or debug run moves an existing
unotest/.debugger.jsonto the new place, keeping its breakpoints. If both files exist, the new one is used and the old one is left alone. Git then showsunotest/.debugger.jsonas deleted: commit the deletion.@unotest/protocol:debuggerFileFor(suffix)returns the new path;legacyDebuggerFileFor(suffix)names the old one andLOCAL_STATE_DIRis.unotest.@unotest/core:debuggerFile(projectRoot, suffix)returns the store’s absolute path and performs the move. -
A test that spends its step or memory budget now ends as
errored ‼, notfailed ✗. A wrong value in an options slot now stops the run before the browser starts.maxSteps budget (100000) exhaustedandthe run holds … MB more than at its startcount what the scenario itself did: its statements, the values it keeps. No slow application spends them, so the test is broken, not the application. They now count as errored in thee2eandcollectionsummary. A spent time budget (testTimeoutMs,setTestTimeout,UNOTEST_MAX_DURATION_MS) is stillfailed ✗: a slow application spends it too. The exit code is 1 either way.- A call that gives a non-object where options go, such as
assertUrl('/', 'oops'), is now reported as[validator:options]instead of[validator:arg-kind], and the run stops before it starts withrun stopped, as it does for an unknown option key. Before, the run started and the test ended as errored on that line, after the steps before it had run. Otherarg-kindfindings still do not stop the run.
Migration: a CI step that counted a spent step budget as a failure now sees it as an error. A lint config that set the level of
validator:arg-kindto quiet this finding should setvalidator:optionsinstead.@unotest/dsl/executor:BudgetExhaustedErroris now an abstract base, andinstanceofit still finds every spent budget. A spent step budget throwsStepBudgetExhaustedError, a spent time budget throwsDurationBudgetExhaustedError, which is the only one marked as a failed check (isCheckFailure).MemoryBudgetExhaustedErroris no longer a failed check either. -
e2estops before the browser on the scenario errors the run would refuse anyway. It still prints every diagnostic of the pre-run lint, aslintdoes, and now refuses to start on avalidator:*error the run cannot get past — an unknown function, JavaScript outside the scenario language, an option the call does not read (click(loc, {forse: true})), aconstassigned again, a member a value does not have, a scenario variable read inside the function handed toevaluate(the page has no such variable) — on page code forevaluatebuilt from scenario values (a${…}in it, or a string put together at run time), and on a rule the project raised toerrorinlinter.rules. The two page-code checks — values spliced into page code, scenario variables read inside the function handed to the page — cover the helpers of_helpers/the run loads as well, and the line printed names the helper file; any other finding in a helper is left tolint. The refusal exits 1 after the linerun stopped: N error(s); no browser starts and nothing is written underunotest/.runs/. Errors of shape —validator:arity,validator:step-shape, a call outsidestep(...)— andlint:*errors at their default severity are printed and the run goes on, as before.Migration: a suite with one of those errors used to start, run the steps before it and fail there; its CI now goes red before the run, without those side effects. Run
npx @unotest/web lintto see the same list and fix the call. Setting the rule towarninlinter.ruleslets the run start again, and it stops with an error where it did before.@unotest/dsl: aScenarioDiagnosticthe run would refuse as well carriesfailsRun: true. -
Four messages a new user meets on a first run now say what to do.
- A test’s result line in the
e2eandcollectionoutput starts with the run’s own word:failed ✗ test: …for a failed check,errored ‼ test: …for a script that could not do what it was asked. Before, both started witherror, and only the sign told them apart. The line still goes to stderr. A CI step that searched the output forerror ✗orerror ‼should search forfailed ✗/errored ‼instead. {timeout}and{timeoutMs}are one option:lintnow refuses a call that gives both, with the sentence the run already gave (assertUrl: {timeout} and {timeoutMs} are the same option — give one of them.), ande2estops on it before the browser starts. A list of the keys a function takes names one key per option:Usage: {timeoutMs?}forassertUrl('/', 'oops'), where it used to suggest both keys with made-up values, a line that was then refused when copied.- A run that spends its step budget says where and why:
maxSteps budget (100000) exhausted at unotest/e2e/first/endless.js:5 — the test ran 100000 statements and loop passes: a loop or a callback that never ends? The limit is fixed — make the loop end, or do less in one test (filter earlier, split the test).A spent time budget names the setting that raises it:testTimeoutMsinunotest.config,UNOTEST_MAX_DURATION_MS, orsetTestTimeout(ms)in the test. - An unknown function that is a typo of one the scenario language has is named:
Unknown function: clik — did you mean click?, inlintand in a run alike. A name more than two edits from any function gets no guess.
@unotest/dsl: an options argument kind may carrysame(an older key to the name it stands for);validator:optionsrefuses a call that gives both, andoptionKeysLabel,sameOptionPairandsameOptionMessageare exported for a vocab’s own guards.BudgetExhaustedErrorcarriesat(file and line) for a spent step budget, and its message says what to do. - A test’s result line in the
-
collection,box runsandbox runnow use the same signs and words ase2eand the viewer:✗ failedfor a check that did not hold,‼ erroredfor a script that could not do what it was asked, and■ abortedfor a run that was stopped.What changes in the output. Update a CI step that searches the log for any of these:
- An errored scenario or test in a collection prints
‼ scenario: … — erroredand is labellederrored. Before, it printed✗ … — failed. - A stopped or interrupted scenario prints
■ … — aborted/■ … — interrupted. Before, it was marked‼. - The collection summary names the errored part of the failures:
pass=1, fail=3 (2 errored).fail=itself has not changed: it still counts every scenario that did not hold, errored ones included, so a check forfail=0reads the run as before. A collection with errored scenarios still fails and exits 1. - In a file of several tests, the file’s line counts each kind, for example
✗ scenario: shop (900ms) — 1 of 3 failed, 1 errored. If none of its tests failed, the line starts with‼and reads1 of 3 errored. box runsandbox runs --latestshow‼ errored,■ abortedand✓ failed on purpose(a test marked@expect-fail) in their status column. Before, all three were shown as✗with the stored status.box runnames the same word in brackets, for example‼ checkout (errored).
box run --followstill prints an errored scenario as✗ … — faileduntil the box itself reports errored runs. - An errored scenario or test in a collection prints
-
validator:if-shapechecks the condition ofwhile,do...while,forand the?:operator as it checksif: a condition known to be a number, a string or an array (while ("x"),for (;5;)) is a lint error. The message names the statement:while() condition must be a boolean expression.run_testdoes not start a scenario with a lint error, so such a scenario no longer runs there. What to do: write the comparison you mean,while (items.length > 0). -
Editor typings now match the scenario language exactly. The
unotest/jsconfig.jsonthatinitwrites setsnoLib: trueand adds a new declaration file,types/unotest-lib.d.ts: the scenario library (Math,JSON,Object,Map,Set, string, number and array methods) cut from TypeScript’s own declarations. The editor completes what a scenario has and no longer offersDate,setTimeout,documentors.big(), which lint refuses. What to do: an existing project runsnpx @unotest/web typesonce. A jsconfig written by an earlierinitand left untouched is brought up to date;lintand runs print a one-line reminder until then. A jsconfig you edited by hand is never rewritten: the reminder says to set"noLib": trueand add./node_modules/@unotest/web/types/unotest-lib.d.tstoinclude. -
A DSL function no longer drops an extra argument (#428). Before, the run ignored it:
assertEqual(1 + 1, 2, 'math works')was flagged bylint(validator:arity), yete2eran the test and passed it; a call whose arguments come from a spread, such asnote('n', ...values), was not flagged at all. Either call now fails its step before the function runs:assertEqual() takes at most 2 arguments, got 3 — remove the extra argument, and the test ends errored. Functions that take any number of arguments (log,evaluate,shell,dbQueryand the like) are unchanged.Migration: a suite whose CI runs
e2ewithoutlint, and that has a call with an extra argument, passed before and now goes red on that test. Runnpx @unotest/web lint— every such call written out is listed undervalidator:arity— and remove the extra argument, or pass the values as one array.goBack,goForward,selectOptionanddragAndDroptake their options in lint too. The calls always read{timeoutMs}and the browser’s own keys, as the DSL reference says, but lint calledgoBack({timeoutMs: 5000})an extra argument. Lint now accepts these options and names a misspelled key, as it does forclick.A DSL function called with a huge spread now fails with a message that names it.
log(...rows)with some 200,000 elements used to end the test withMaximum call stack size exceededand no function named. One call of a DSL function now takes at most 10,000 arguments:log() was given 200000 arguments; a DSL function takes at most 10000 — pass an array. What to do: pass the array itself,log(rows). -
A scenario can no longer reach the library’s internals through
Math,JSON,ObjectorArray.Object.keys(Math),Object.entries(Math),Object.assign(Math, …),Object.hasOwn(JSON, …),Array.from(Math)andfor (k in Math)are refused with “got a namespace such as Math”. A DSL function refuses one among its arguments (note('m', Math)).JSON.stringify(Math)writes{}, as JavaScript does. Before, a write throughObject.entries(Math)brokeMathfor every later test of the same process.typeof Mathis still"object", and every member works as before. -
A scenario can no longer take the runner down by building a huge string or array.
'x'.repeat(1e9),padStart(1e9)orArray.from({ length: 1e9 })used to exhaust the runner’s memory and end the run with no reason given; a string doubled in a loop (s = s + s) did the same in about thirty passes. Strings are now bounded at 10,000,000 characters and arrays at 1,000,000 elements:repeat,padStart/padEnd,replace/replaceAll,join,String(array),JSON.stringify,String.raw,Array.from,concat,flat,flatMap,+,+=and template literals fail with aRangeErrorthat names the call, the size it asked for and the bound, before the value is made. For a larger payload, use a file fixture.This is a behaviour change: a scenario that built a string of more than 10,000,000 characters, which used to pass while it fit in memory, now fails with that
RangeError.A spread now reaches the array bound:
[...items],push(...items),Math.max(...items)or a scenario function called with...itemsused to fail at about 120,000 elements withMaximum call stack size exceeded; a spread of up to 1,000,000 elements now works. -
Every DSL call that takes an options object checks its keys. A key the call does not read is refused, by
lintbefore the run and by the call itself during it, with the same sentence:click: unknown option "forse" — supported: {timeout?, force?, …}.The value of a known key is checked too:{timeout: "soon"}is a lint error.Migration: a few keys used to be accepted and silently dropped, and are now refused.
doubleClickno longer takesbuttonorclickCount: they never reached the browser.waitForTextno longer takesfirst: the wait always looks at the first visible match.waitForNavigationno longer takeswaitUntil: it waits for the navigation the page starts.scrollIntoViewtakes no options object: it never read one.
Remove the key; the call behaves as it did. The other calls refuse only keys they never read:
getByText,getByLabel,getByPlaceholder,getByAltText,getByTitle(they readexact),screenshot(fullPage),setCookie(domain,path,expires,httpOnly,secure,sameSite) andapiCall(base).In the scenario type declarations an options parameter is
Record<string, unknown>instead ofany.note(label)without a value is an arity error inlint.@unotest/dslgainsvalidateScenario, the validator over the new scenario parser. It reports the old rules with their ids and texts, and addsout-of-language(JavaScript the scenario language leaves out, with what to write instead),implicit-declaration(an info: a variable assigned withoutlet/const),evaluate-interpolation(${…}in code handed to the page) andtemplate-escape-changed(a backslash escape in a backtick template now reads as in JavaScript). An options slot in a function contract is the new argument kind{options: {key: kind}}. -
Page code built from scenario values is refused wherever the run would load it, not only in the test’s own file.
e2e,run_testandexplore_run_flownow check the helpers of_helpers/for it before anything runs:evaluate('1 + ' + t)orevaluate(() => document.title + t)inside a helper stops the run, and the diagnostic carries the helper’sfile(the CLI printsunotest/e2e/_helpers/h.js:2:10).explore_run_flowruns the same pre-run lint asrun_testbefore it replays a flow, and answers with the list of errors instead of replaying. Only the page-code rules are checked in helpers: a helper’s other findings stay withlint, so a run that passes today does not stop on them.evaluateInServiceWorkertakes a function as well as a string, asevaluatedoes —evaluateInServiceWorker((t) => chrome.storage.local.set({t}), 'dark')— under the same page-code rules.What to do if a run now stops on a helper: pass the value to the page as an argument,
evaluate((v) => 1 + v, t), and keep the code itself fixed text or a function that reads only its own parameters.npx @unotest/web lintshows the same lines.@unotest/dsl: aScenarioDiagnosticwhose finding the run refuses in any file it loads, helpers included, carriesharmsRun: true. -
validator:evaluate-interpolationnow judges the code handed toevaluate/evaluateOnLocator/evaluateInServiceWorkerby where it comes from, not only by a${...}written in the call. A string built at run time (evaluate('1 + ' + x), a variable that is ever given text read off the page, an external variable) is refused bylintand before the run starts, because a value in it would become code of the page. Text the source spells out passes: a string literal, a template without${...},+of those, or a variable every assignment of which is one — declared withletorconst, or assigned without a declaration (js = `...`; evaluate(js)), or the variable of afor…ofover an array literal of such texts (for (const js of ['document.title', 'document.URL']) evaluate(js)). What to do with a refused call: write the page code as a function and pass values as arguments,evaluate((n) => n + 1, x). -
Math.random()is part of the scenario language: each call draws a new number in [0, 1), as in JavaScript. A run that uses it is no longer the same run twice; for fresh test datarandomWord(n)andrandomEmail()still read better.new Map(entries?)andnew Set(items?)are part of the scenario language, with their members as in JavaScript:get,set,add,has,delete,clear,sizeandforEach.keys(),values()andentries()return arrays, som.keys().map(...)works directly;for...of, a spread andArray.fromiterate a Map or a Set as in JavaScript. Innote,logand the viewer’s Vars a Map reads asMap(1) {"a" => 1}instead of{}. The validator names a member a Map or a Set does not have (m.lengthism.size), and the editor typings declare the array results.newstays out of the language for anything else, including aMapthe scenario declares itself;WeakMap,WeakSetandWeakRefremain refused. -
A test that keeps too much in memory now ends as errored with the reason, instead of taking the runner down.
- A run may grow the heap by at most 512 MB over the heap at its start, and never by more than half the heap’s limit. Past it the test ends as
errored ‼withthe run holds 530 MB more than at its start; the limit is 512 MB, followed by what to do: keep fewer large values alive at once, or use a file fixture or read the data in parts. This holds fore2e,run_testandexplore_run_flow. Before, a statement such asArray.from({length: 1000000}).map(() => 'y'.repeat(10000))filled the runner’s memory until the process was killed, with no word about why. On a box, the run’s container was killed. - The size is what the run holds, not what it has dropped: past the limit, the runner collects garbage and reads the heap again, and the test ends only if it still holds more. A run that makes a gigabyte of strings and keeps only their lengths passes. The start of each run is measured after a collection as well.
- The memory is checked together with the test’s time: on every statement, loop pass and callback. Like the other budgets, it cannot be caught with
try/catch. Each run is measured from its own start, so an explore session does not carry memory over from one flow to the next. split,matchwith a global pattern,matchAll,Array.from,Object.keys,Object.valuesandObject.entriesof a string, and the arraysJSON.parsemakes now respect the 1,000,000-element array bound. Past it they fail with aRangeErrorsuch as.split() would make an array of more than 1000000 elements; the limit is 1000000. Before,'ab'.repeat(5000000).split('')made an array of 10,000,000 elements,Object.entriesof a 10,000,000-character string took 1.5 GB in one call,Array.fromof one made an array of 10,000,000, andJSON.parseof a text within the string bound made an array of 5,000,000.Array.frommakes one element per character of a string, an emoji included.JSON.parsechecks its arrays after parsing, so it still takes text nested as deeply as before.
Migration: a scenario that split or matched a text into more than 1,000,000 parts, or parsed JSON with an array of more than 1,000,000 elements, now fails. Pass
splita limit, or read the text or the data in parts.@unotest/dsl/executor:RunOptionsgains an optionalmemory, aMemoryProbe(heapUsedBytes(),heapLimitBytes(), and an optionalcollectGarbage(); without it the size is read as it stands and the message says it may count garbage). Without it, a run’s memory is not read. The package also exportsMemoryBudgetExhaustedErrorandMAX_RUN_HEAP_GROWTH_BYTES. - A run may grow the heap by at most 512 MB over the heap at its start, and never by more than half the heap’s limit. Past it the test ends as
-
A scenario no longer reads the runner’s own credentials, and a
shell()step’s process no longer inherits them. A bare identifier still resolves against the whole environment, soBASE_URLor a token a CI job exports reads as before. The names kept to the runner:UNOTEST_JUDGE_TOKEN,UNOTEST_JUDGE_ACCESS_TOKEN,UNOTEST_CLOUD_TOKEN,UNOTEST_BOX_TOKEN,UNOTEST_BOX_READ_TOKEN, the grounder’sUNOTEST_GROUNDER_REMOTE_TOKEN,_PROVIDER_KEY,_PICKER_KEY,_EMBED_KEY,_SHARED_TOKEN,_METRICS_TOKENand_REVOCATIONS_TOKEN, and the judge provider’s API key (ANTHROPIC_API_KEY,OPENAI_API_KEYorGEMINI_API_KEY), the last only whileUNOTEST_JUDGE_MODE=localand only for the active provider.Reading one is refused with whose it is, for example
ANTHROPIC_API_KEY is the judge's credential (UNOTEST_JUDGE_MODE=local) on this runner, not a scenario variable, rather than “not found”. The check a run makes before the browser starts,{{NAME}}inexplore_stepandsandbox.exportSecretsgive the same sentence.@unotest/protocolexportsJUDGE_API_KEY_ENVandjudgeSecretEnvNames(env).@unotest/grounder-clientexportsGROUNDER_SECRET_ENVandGROUNDER_SECRET_ENV_NAMES.Migration:
- A scenario or a
shell()command that read one of these names on purpose: give the value its own name inunotest/.secrets(APP_ANTHROPIC_API_KEY=…) and read that. With a remote judge, or a judge on another provider, a vendor API key is the application’s and reads as before.
- A scenario or a
-
A run is now counted
failedorerroredby the class of its error, not by the words in its message. A failed check isfailed; a script that could not do what it was asked iserrored. What changes in the count:- An action on a locator that matches several elements is
failed, and the message names the locator as the scenario wrote it:click: getByRole("button", {name: "Edit"}) matches 30 elements, and an action needs exactly one. Narrow it to the one you mean: a name, a parent locator, or .first() / .nth(i).It used to beerrored, with the browser library’s “strict mode violation” text. - A test that runs out of its budget (
setTestTimeout,testTimeoutMs,UNOTEST_MAX_DURATION_MS) isfailed. It used to beerrored. - A stale or unresolvable element reference is
failed. It used to beerrored. - A shell command that runs out of time is
errored, like any other shell command that does not finish. It used to befailed. - A wrong call of the script (a missing file, a path outside the project, two names of one option) stays
errored, and// @expect-faildoes not excuse it: the marker expects a failed check.
@unotest/dsl:ExecutionError.checkFailurecarries the error’s kind across the executor, with theCheckFailureinterface and theisCheckFailureguard for error classes of a host vocabulary. - An action on a locator that matches several elements is
-
The viewer now shows an errored run as errored, the way the CLI counts it, instead of as failed. A failed check is
failed; a script that could not do what it was asked — a wrong call, a browser that went away, a command that did not finish — iserrored.- Home has an
erroredcard and legend entry. An errored tile carries the‼sign as well as its own colour. The headline uses the CLI’s words:1 failed · 1 errored. - Failing now, the run history, the run’s tab and the Runs badge draw an errored test with
‼. It still counts as broken now. - On an errored run’s tab, the step it broke on, its block, the progress bar and the error card take the errored sign and colour.
- A run that failed on purpose is purple in the run history too, as it is on Home: one run, one colour everywhere.
- The red of a failed check is darker in light theme and lighter in dark theme, so its text badge meets WCAG AA contrast.
- A file of several tests reads as errored only when none of its tests failed a check.
@unotest/protocol:run:finished,file-run:test-finishedandcollection-run:scenario-finishedcarryerrored: true, andRunMetacarrieserrored. The outcome staysfailed, so a reader that predates the field still sees a failed run. The run index schema is now version 5 (lastErrored,recentErrored). An older index is rebuilt on first read. - Home has an
-
The viewer reads a scenario the way the engine runs it. Its step tree shows
try/catch/finallyandswitchas control rows: the header row carries its own status, andcatch (e),finallyand eachcase …:open a branch beneath it, aselsedoes forif. A bare{ … }block, an empty statement and a nested function are no rows of their own; their statements are. A body written without braces (if (i > 7) break;) belongs to its header’s row, as the run reports it, and ado … whilerow shows its condition. A finished step whoseelse,catchor othercasenever ran reads passed, not running.await step(…)in anasynctest is a step block like any other. A literal step tag ({tag: 'q1'}) shows as its text before the run; an expression tag shows as written. Scenario discovery and helper completion in the viewer read the full language: a test file with top-levelconst,asynctests,tryorswitchlists its tests, and a helper with a default, rest or destructured parameter offers one placeholder per parameter.The editor typings declare
teardown(label, body),waitUntil(condition, options?),Array.prototype.sortByand the array methods the scenario language has beyond ES2022 (findLast,findLastIndex,toReversed,toSorted,toSpliced,with), so the editor no longer marks them unknown.The ESLint rule
@unotest/dslchecks the tree ESLint already parsed instead of parsing the file a second time, with the same diagnostics asunotest-web lint. A file that defines one function twice gets the parse diagnosticlintprints rather than an exception out of ESLint, and the rule runs under ESLint’sRuleTester.@unotest/web/dslexports what such a frontend needs:scenarioFromProgram,ProgramComment,ScenarioSyntaxErrorandparseDiagnostic(error), the diagnosticlintreports for a file that does not parse.The function handed to
evaluate/evaluateOnLocatoris code of the page, andlintno longer checks it as the scenario:document,getComputedStyle(el),new RegExp(re)or an upper-case page global in it are not refused. Whatlintdoes check there is new,validator:page-code-closure(error): a variable of the scenario read inside that function, which the page does not see — pass it as an argument,evaluate((n) => …, n). -
A failed
assertText,assertTexts,assertAttributeorassertValuenow writes the characters a reader cannot see as escapes in its message. A page that joins an amount with a non-breaking space used to fail withexpected equal "S/ 500", got "S/ 500"— two lines that look the same. The message now readsgot "S/\u00A0500". The same goes for other unusual spaces and for zero-width and format characters (\u200B,\uFEFF, a soft hyphen). The comparison itself is unchanged. -
A debug run that paused on a failed check and was continued now ends as
1 failed, as the check did. It used to end as1 errored, the count reserved for a run that broke rather than a check that did not hold. -
The type declarations of
@unotest/web/configare the same in every build. Union members such as the retry reasons ("transient" | "network" | "crash") are printed in the order the schema declares them; before, a rebuild of the same version could print them in another order. The types themselves do not change. -
A DSL warning is now an ESLint warning, not an error.
@unotest/dslreports the engine’s errors, and the new rule@unotest/dsl-warningreports its warnings.recommended()sets them toerrorandwarn. A suite with only warnings (lint:pause-explicit, say) no longer failsnpx eslint. The file is still validated once.Migration: with
recommended()there is nothing to do. A config that names@unotest/dslby hand reports errors only until it adds"@unotest/dsl-warning": "warn". To keep warnings failing the build, set"@unotest/dsl-warning": "error". -
A run that refuses a
function*declared inside astep(...)names that step in its failure line (in step "…"), as it does for any other failed statement. The refusal is now a failed statement of its own, at the declaration: the viewer lists it under the step, and a debug run pauses on it. It still comes before anything else in the block runs. -
lintreports a function that a scenario and a helper in_helpers/both define, with the same words the run refuses it in:✗ error [load] duplicate function "name" — first defined at …, also at …. Before,lintpassed such a scenario and only the run stopped on it. A clash between two helpers is reported at each of the two helper files. -
Large variables no longer slow a run down. The live view of a run (the viewer’s Vars,
inspect_runtime) shows the first 4 KB of each variable and is refreshed every 50 ms. It used to read each value whole and then cut it, so a loop that kept adding to an array paid for everything the array held, again and again. A scenario that kept 200 strings of 1 MB took more than a minute instead of under a second, and one that kept 1 GB ran out of time instead of stopping at the memory limit. Each refresh now reads only what it shows, so a value of any size costs the same.A run paused by the debugger with a variable that is
undefinedsent its pause without any variables. It now sends the others and leaves that one out, as the live view does.@unotest/coreexportscappedJson(value, minBytes, replacer?): the text ofJSON.stringify, or a prefix of it longer thanminBytesbytes, read only that far. It also exportsserializeVars. ThestringifyVarhook ofbuildRuntimeInspectiongets the byte count as its second argument, and may returnundefinedfor a variable with no JSON.snapshotOfin@unotest/dsl/valuetakes an optionalmaxNodes, the most values it reads. It is unlimited by default. -
After a failed run, the viewer’s Vars tab shows the variables in scope where the run failed, including the
letandconstdeclared inside the failingstep(...). Before, it showed the scope after the step’s block had closed: an empty tab when the run ended there, the scope the run went on to after Continue from a failure pause, or a teardown’s own scope once the rollback blocks had run.@unotest/dsl:ScenarioInterpreter.inspectScope()answers for a finished failed run with the scope taken at its failure, and for the entry’s run again oncerunTeardownsis over. -
Viewer fixes:
- Run again tab. After Run again on an errored or
// @expect-failtest, the new run’s tab now shows the same mark as its history row (‼for errored, the expected colour for an expected failure). Before, it showed a plain red dot until the page was reloaded. - History after overlapping loads. The run history no longer goes back to an older list when an earlier refresh answers after a later one.
- One-line callbacks. In the Steps and Trace tabs, a callback written on one line, such as
items.forEach((k) => { seen.add(k); });, now opens into rows that show its own statement (seen.add(k);). Before, each row repeated the whole line. Runs recorded by earlier versions still show the whole line in the Trace tab. - Error card badge. The badge on a failed step now says what kind of failure it is, in the words the CLI uses:
failed checkwhen a check did not hold,script errorwhen the script could not do what it was asked. The error’s class name, such asNullMemberError, now appears on hover. Before, the badge showed only the class name.
@unotest/protocol:step:startedgains optionalendLineandendCol, the end of the statement.endColis the 1-based column just past its last character. - Run again tab. After Run again on an errored or
-
Viewer: a statement that hands a function to a call, such as
xs.forEach((x) => { ... }), now opens into the statements of that function, as anifdoes. Each of them shows its status and notes and takes a breakpoint. Page code passed toevaluate/evaluateOnLocatorstays closed: the page runs it, not the scenario. The DSL vocabulary entries carrypageCodeArgfor that, and@unotest/dslexportscalleeName. -
The viewer’s history counts a collection the way the CLI does. Errored tests are part of failed and marked apart, tests that failed on purpose are not failed at all: a run the CLI reports as
pass=1, fail=2 (1 errored), expected=1reads1 ✓ 2 ✗ (1 ‼)in the row with the failed-on-purpose count beside its purple dot, and1 passed, 2 failed (1 errored), 1 failed on purposeto a screen reader. An@expect-failtest that passed counts as failed, as everywhere else.The viewer’s Vars tab shows a
Mapand aSetas what they are: the type with its size, and a Map’s entries as key → value or a Set’s values, one per row. They used to arrive as a quoted string of typestring. A Map or a Set inside an object, an array or another Map is drawn the same way, at any depth. What an agent reads through the tools is unchanged:inspect_runtime,stepandrun_testprint each variable byte for byte as before. The run’sruntime.jsonand thepausedevents of its journal carry a Map or a Set in the tagged form below. Two values read differently there than before: a locator inside a Map or a Set prints as its description (<Locator …>), and a value cut at the 4 KB cap is cut in its tagged form.@unotest/dslgains the entry@unotest/dsl/value: a scenario value in the form a run snapshot carries it (snapshotOf; a Map or a Set as{"$type": "Map", "entries": […]}/{"$type": "Set", "values": […]}, a scenario object with its own$typekey wrapped as{"$type": "Object", "value": {…}}), as one line of text (snapshotText), and in the form written before the tags (untagged). The entry carries no parser. -
Viewer: a run tab opened as the run starts (Run, Run again, R in the tree) now shows how it ended without a reload. The failed step opens with its error card, and Vars shows the final values instead of “no vars in scope yet”. A step you open or close by hand keeps your choice.
-
Updated dependencies [e176cb4]
-
Updated dependencies [f83b1c5]
-
Updated dependencies [7f6d3d3]
-
Updated dependencies [5f1e0a9]
-
Updated dependencies [9413745]
-
Updated dependencies [eaa17ad]
-
Updated dependencies [5c9d2d5]
-
Updated dependencies [c95868e]
-
Updated dependencies [24c7e15]
-
Updated dependencies [ab05878]
-
Updated dependencies [70ef8a0]
-
Updated dependencies [b9907eb]
-
Updated dependencies [d3c62fa]
-
Updated dependencies [1c17d91]
-
Updated dependencies [ba95590]
-
Updated dependencies [99678b8]
-
Updated dependencies [1e15351]
-
Updated dependencies [7eed455]
-
Updated dependencies [91ede7d]
-
Updated dependencies [303b935]
-
Updated dependencies [ca28445]
-
Updated dependencies [2d1a70b]
-
Updated dependencies [385cbe0]
-
Updated dependencies [ff7bdae]
-
Updated dependencies [fe8540b]
-
Updated dependencies [9b834c7]
-
Updated dependencies [accf0d6]
-
Updated dependencies [decec09]
-
Updated dependencies [0ceae40]
- @unotest/dsl@0.50.0
- @unotest/core@0.50.0
- @unotest/viewer@0.50.0
- @unotest/protocol@0.50.0
- @unotest/grounder-client@0.50.0
0.49.2 — 2026-10-12
-
A test that fails on a page whose main thread is busy no longer spends a long wait collecting its failure bundle. The runner first asks the page a question that costs it nothing, with a short budget; a page that does not answer is not read at all. Each part of the bundle that was not read now holds the reason where it would be (
screenshot-error.txt,page-html-error.txt,outline-error.txt) instead of being missing without a word. Assertions keep their owntimeouton such a page: the page’s reads count against it, soassertText(loc, x, {timeout: 400})fails after about 400 ms and says the page gave no answer, instead of waiting out the action timeout.assertNeverAppearsno longer passes on a page that stops answering: absence is proven only by reads that answered for the whole window, so it fails and says the page gave no answer. -
A failure bundle no longer holds an empty
console.jsonwhen the browser console was not recorded. The console is recorded only withUNOTEST_CONSOLE=1; without it, the file is left out,get_failure_consoleanswersentries: nullwith a message naming the variable, and theprepare-fixprompt says the console was not recorded instead of “no console entries captured”. -
env(name, fallback)returnsfallbackwhen the variable is not set, a default in one line:api = env('API_URL', 'http://localhost:3000'). Before, it took anif/else: the DSL has no??, andenv('X') || 'default'givestrue, not the value. Without a fallbackenv(name)still returnsnullfor an unset name. A secret is refused as before, with a fallback or without. -
evaluate(js)on a page whose main thread is busy, a script loop or a long task, now fails the step after the action timeout with “the page gave no answer” instead of holding the test forever.evaluateOnLocatoralready did. A script that has started still runs as long as it needs.Inside an entered frame, the function form of
evaluate,evaluate('function(n) { … }', 21), now receives its argument. Before, it received the frame’s root element. -
A lambda passed to
evaluateorevaluateOnLocatoris now answered with what to write: the JavaScript goes in as a string, so quote the lambda —evaluate('() => new Date().toISOString()').lintand a run say the same. Before, both said to call a helper function by name, which pointed the wrong way. For targets built on@unotest/dsl: the contract registry and the dispatcher take an optionalsuggestForLambdaArgument(name)hook for this answer. -
A failed test in the CLI now prints where its failure bundle is, as an absolute path on stdout (
failure bundle: /…/.unotest/failures/<runId>/<test>), so it opens from any working directory. The run journal keepsbundlePathandscreenshotPathrelative to the project root (the directory holding.unotest/andunotest/), and the agent integration guide now says so. -
getByRole(role, options)now reads Playwright’s state options:checked,disabled,expanded,pressed,selected,levelandincludeHidden, next tonameandexact. Before, every key butnameandexactwas dropped without a word, socount(getByRole('button', {name: 'Save', disabled: false}))also counted a disabled Save button and the check went green. Any other key is now refused, at lint time (new rulelint:role-unknown-option, an error by default) and at run time, with the list of keys it reads.The MCP locator input takes the same options on a
getByRolestep and refuses any other key instead of stripping it. A recorded step keeps them, and the generated test writes them out. -
lint:env-host-from-basenow finds the host variable of agotoinside a ternary and in a longer sum:goto(ready ? APP_URL : '/x')andgoto(APP_URL + '/a' + '/b')were not checked before. A variable used only as the ternary’s condition is not taken for the host. -
unotest-web init --helpprints its usage once. It used to print it twice, while every other command printed its help once. -
A test run no longer creates
unotest/jsconfig.json.e2e,lintand MCPrun_testused to write it into your tree when it was missing; now onlyinitandtypeswrite it, andinitaddsunotest/jsconfig.jsonto.gitignore. A run still refreshes the gitignored.unotest/types/env.d.tswith your variable names.Migration: a checkout without the file (a new clone, a teammate’s machine) gets editor typings back with
npx @unotest/web types. Ajsconfig.jsonyou already commit stays as it is; remove the new.gitignoreline if you want to keep committing it. -
locator(selector, options)now reads its options:hasText,hasNotText,hasandhasNotnarrow the matches exactly asfilter()does, and any other key is refused at lint time and at run time. Before, the second argument was dropped without a word, socount(locator('option', {hasText: 'Beta'}))counted every option.New
isChecked(loc)reads the checked state of a checkbox, radio or switch, native or ARIA role. An element with no checked state fails the step instead of readingfalse.check/unchecknow click and wait until the element reads checked (unchecked) within the action timeout. A controlled switch the app flips a moment after the click, once the server confirms, no longer fails with “did not change its state”. An element already in the target state is not clicked, as before. A switch that never flips fails, naming the state it wanted and the last one it saw.check/uncheckon an element that is not a checkbox, radio or switch is refused; useclickfor it.A
<label>, or anything inside one, stands for the checkbox it names, as in Playwright:check,uncheckandisCheckedongetByText('Remember me')act on and read the labelled checkbox.assertValue(label, "true")on such a label now asserts the checkbox’s checked state too, instead of comparing against its submitted value"on". -
locator(selector, options)andfilter(loc, options)now refuse options that are not an object, naming the function:locator('option', 5)andfilter(loc, null)used to build a filter with no conditions and match everything, andlocator('option', null)failed with a bareTypeError.filter(loc)with no options is unchanged.getByRole(role, {exact})now takes onlytrueorfalse, like its state flags;{exact: 'yes'}used to count astrue. -
MCP locator input now refuses a regex
sourcethat does not compile, with “regex source does not compile” and the engine’s reason, instead of failing later when the locator runs. A recorded regex is written into the generated test with/and line breaks escaped, so a source holding/can no longer close the literal and add code to the test. -
e2e --notesandcollection --notesprint everynote()of every test under its line, a passing test’s too, asnote <label>: <value> — step "<step>". A failed test lists them in place of the failing step’s own notes, so none prints twice. Without the flag the output is unchanged. -
Notes printed under a test line (
--notes, and the notes of a failing step) now show line breaks and terminal escape sequences escaped, as\nand\x1b. A note’s value often comes from the page under test, and an escape sequence in it used to reach the terminal as an instruction, and a line break could forge a line of the report. -
A
__proto__key in an object literal is now an ordinary key of that object, as in JSON, instead of setting its prototype.getByRole('button', {__proto__: {name: 'Save'}})was refused by the lint, but the run readnamethrough the prototype and matched on it. The run now refuses__proto__as an unknown option too, with the lint’s message. -
A run on a browser profile (
launch.userDataDirorUNOTEST_USER_DATA_DIR) no longer prints a line about it on every run. The run journal records it instead: therun:startedevent insteps.jsonlcarriesbrowserProfile, the profile directory the run played on. Its presence means every scenario shared one browser context, with no isolation between them. Runs with a fresh context per test have no such field. -
Scenarios can use the ternary
cond ? a : b, as in JS: the lowest precedence, nested to the right, and allowed wherever a value goes — a variable, a call argument, an object field, areturn, a lambda body. Only the branch the condition picks runs, sox == null ? '' : x.nameis a safe guard.lintaccepts a ternary in a slot when both branches fit it.?.and??are still refused, and the parse error now points to the ternary orif/else. -
New
texts(loc)returns thetextContentof every element the locator matches, in page order, and[]when nothing matches. It is one read with no wait: the list to branch on, whereassertTextsis the waiting check. Before, the only way to get that list wascountplusnth(i)in a loop, sincetextContenton several matches is a strict-mode error. -
The viewer no longer paints a test marked
@expect-failas failed on purpose when it errored. The tile, the tree dot, the scenario header and the history strips now read the marker the runner recorded for each run, not the marker in the source today: a marked test that failed is purple, the same test erroring on a script error is red and counts as failing. The run index records that reading per run and is rebuilt once on the first start after the update. The scenario header of a one-test file now showsexpectedin the same purple as the tree, instead offailed. -
Updated dependencies [5df0f20]
-
Updated dependencies [98aeab8]
-
Updated dependencies [0447cec]
-
Updated dependencies [92242d3]
-
Updated dependencies [a1e8482]
-
Updated dependencies [65a81be]
- @unotest/dsl@0.49.2
- @unotest/core@0.49.2
- @unotest/protocol@0.49.2
- @unotest/viewer@0.49.2
- @unotest/grounder-client@0.49.2
0.49.1 — 2026-10-11
-
A run of one test on a box:
- A test the file does not have is refused when the run is ordered, with the file’s tests listed, the same refusal the viewer and
e2e --testgive. Before, the box accepted the run, it exited without a word and the notification said “tests failed”. The check needs a bundle pushed with this version, sincebundle pushnow lists each scenario file’s tests in the bundle. An older bundle is not checked. box runs,box runand notifications name such a runfile › test, asbundle push --followalready did. This also applies to a file with a single test, whose runs used to show only the file.box queuenames a waiting or running run of one test the same way.box run --jsonandbox queue --jsongain atestfield on those runs. A run of a whole file of several tests is named as before, and a file’s notification series is the same whether a run played one of its tests or all of them.
- A test the file does not have is refused when the run is ordered, with the file’s tests listed, the same refusal the viewer and
-
Box runs of one test: the box accepts a test name only as a scenario function is spelled (letters, digits,
_,$, up to 128 characters) and refuses anything else before it reaches the queue; the box and the run sidecar check it by the same rule, so a name with Cyrillic letters or$is played instead of lost.box queue,box runsandbox runprint run labels with control characters shown as escapes, so a name written by an older box cannot drive the terminal. -
A
constassigned again (K = 2orK++) now fails the run at that line with the same messagelintgives, as JavaScript does. Before,lintrefused it bute2eandrun_testran the assignment and passed.letreassigns as before, and aconstin a loop body is declared again on every pass. -
Scenarios can declare variables with
letandconst, and pass a lambda to four array methods.let x = …andconst x = …work like the bare assignment, in the function’s scope, andfor (let i = 0; …)declares the loop variable; assigning a const again is a lint error (const-assign). At the top of a file aconstholding a literal is visible in every function, as helper functions are.a.filter(x => …),a.map((x, i) => …),a.find(x => …)anda.some(x => …)take an expression or a block withreturn; the parameters live in their own scope and the body runs element by element. A lambda anywhere else is refused (lambda-position) — functions are not values — andfilterwith an options object is still the locator step. The viewer shows scenarios written with the new syntax. -
lintrefuses a statement at the top level of a scenario or helper file, and the DSL’s refusals of everyday JavaScript say what to write instead.- Only function definitions run at the top level of a file. A statement there (
BASE = 'x',const BASE = 'x', a call) was dropped without a word:lintpassed and the test failed withVariable "BASE" is not defined. It is nowvalidator:unsupported-statement, an error at its line, with the way out: a function that returns the value. let x = 1andconst x = 1get the same answer asvar(“use direct assignment”) instead ofunexpected WORD.- An arrow with parameters (
a.map(x => x * 2)), an arrow or a function as a value, a spread,for…of/for…in,letin aforloop,n += 1,o.a = 2,try/throw/switch,typeofand?(ternary,?.,??) each name the DSL way, at the token where parsing stops. a[0] = 2says to build a new array withpush..includes()on an array, at run time, says what an array answers.pop,shift,unshift,splice,sort,reverse,find,some,every,reduce,Object.keys,Math.max/min/floor/ceilandindexOfon an array are answered with what to write instead.
Migration: a file with a statement at its top level now fails
lint. That statement never ran; move it intotest_*or into a function. - Only function definitions run at the top level of a file. A statement there (
-
Scenarios can now write into arrays and sort them.
arr[i] = vreplaces an element (nested indexes and object keys too); an index past the end fails the step instead of leaving holes, andarr[arr.length] = vappends. The keys__proto__,constructorandprototypecannot be assigned, and an index read on an object returns its own keys only, so a scenario cannot reach a shared prototype.arr.sortBy(key?, {desc?})sorts an array in place by an object field, or by the values themselves without a key; the sort is stable and the values must be all numbers or all strings. The linter’s hint fora.sort()now points tosortBy, and its message for a non-listassertTextsargument names itexpected, as the docs do. -
&&and||in scenarios now short-circuit as in JavaScript: the right side is not evaluated once the left one decides the result. A null guard such asx != null && x.a == 1no longer fails with “Property access on non-object value” whenxis null, including insidewaitUntil. The result stays a boolean. -
The expected value of
assertTexts,assertTextand other assertions can now be any expression of the right kind: a variable holding a list, an index read such aslabels[i], or a helper’s result. Previously the linter accepted only an array literal forassertTextsand rejectedarr[i]as a text. A value known to be the wrong kind is still refused with the argument’s name; anything else is checked when the step runs. -
Strings answer
s.split(separator), which returns an array of strings as in JavaScript:"a, b".split(", ")gives["a", "b"], the parts keep their spaces, and a separator that is not there gives the whole string as the one part. The linter’s hint forsplitno longer points to a shell script. -
@expect-failcovers a test that fails, not a script that breaks: a marked test that errors (an unknown function, a typo) is reported as errored and turns the collection red, instead of counting as failed on purpose. -
A file of several tests whose only failure is marked
@expect-failnow reads as failed on purpose on its own line too, underbundle push --followand in the run index, instead of a redfailedabove the collection’s green verdict. -
// @expect-failnow reaches the runner, not only the viewer. A marked test that fails is reported as failed on purpose ine2eand in a collection (N of M failed on purpose,expected=Nin the summary), and everytest_*of a file whose leading comments carry the marker is marked. A collection whose only failures are on purpose exits 0;e2eof such a test still exits 1. A marked test that passes now fails with a message saying the failure it expects is gone. The run index recordsexpectFailon such runs. -
lintknows the functions a scenario defines, and checks a call wherever it stands.- A function defined at the top level of a scenario file is a helper of that file: the run always called it, and
lintnow does not report it asvalidator:unknown-function. Helpers inunotest/e2e/_helpers/work as before. - A call inside an options object or a helper’s argument (
apiCall('POST', '/pages', {html: page_hmtl()})) is checked like any other call, so a misspelt name there is an error at its line instead of a failure at run time.
Migration: a scenario with an unknown or misspelt function inside an object argument used to pass
lintand now fails it. That call would have failed the run. - A function defined at the top level of a scenario file is a helper of that file: the run always called it, and
-
A collection whose
preparefailed on a box is no longer justfailed (exit 94)underbundle push --follow: the line says the prepare failed and the suite did not run, and where its output is. The run itself is now written (a failed collection with no scenario), sobox run <id>finds it and gives the same reason instead of “no such run”. -
bundle push --follownow reads tests marked@expect-failthe way a local run does: a marked test that fails prints✓ <test> — failed on purpose (@expect-fail), and a marked test that passes prints✗ <test> — passed, but it is marked @expect-fail. Previously a control collection on a box streamed its expected failures as ordinary ones. The line that ends a failed scenario in--followalso uses the local run’s words:✗ login — failedinstead of✗ login (failed). In a local run, a test of a multi-test file that failed on purpose now carries the same(@expect-fail)suffix as a single test. Run lines from a box carry a new optionalexpectFailfield; older clients ignore it. -
env push/env set: a box that refuses a name of the run’s identity (UNOTEST_RUN_IDand the rest) no longer gets the advice aboutAPP_BASE_URL. The box’s refusal now says which kind of name it reserved, under the samereservedcode, so older CLIs still show the box’s own words. A refusal code this CLI does not know is shown as the box worded it, with exit 1, instead of “not a box’s answer”. -
lintnames a scenario the waye2edoes.lint values/string-escapeschecksunotest/e2e/values/string-escapes.js, ase2e values/string-escapesruns it. It used to fail withENOENTand exit 1.- A folder, which failed with
EISDIR, checks every.jsunder it — as a path or as a feature folder name (lint values). - A file path works as before; typed from inside the suite package (
cd unotest && lint e2e/values/numbers.js) it means what it says there. - A name that is not there exits 2 and prints where it was looked for, with the closest names, the same message
e2egives.
-
A run paused on a failure (
e2e --debug, an agent’srun_test) with nobody attached is now aborted after 30 minutes without a client command, and frees the queue — the timeout existed but was restarted by the run reading its own state every few seconds, so it never ran out. Any debug command restarts it; reading the run’s state does not.UNOTEST_PAUSE_TTL_MSsets the timeout, and the expiry line says how long it waited. -
The DSL reference shipped with the package now links
readJsonto the “App constants in scenarios” section on docs.unotest.com instead of naming a page the package does not contain. -
skip(reason)can be called anywhere in a test’s body, including inside a step or anifblock, so a test can skip itself on data an earlier step read. The rest of the test does not run, registered teardown blocks still play, and the run reports skipped with the reason.lint:skip-outside-testnow reports only askip()in a helper, a flow or at the top level of a file. -
The step a test skipped itself in now shows as skipped in the viewer, not as passed; the steps before it stay passed. The run journal marks that statement and its step block with an optional
skipped: truenext took: true, so readers that do not know the field see the same events as before. -
getImageSize(loc)reads an image’s natural size withoutevaluateOnLocator.- It returns
{width, height}of the<img>file, ornullwhen the image is broken or has nosrc. An image still loading is waited for up to the action timeout, so a good image is not read as broken. - An element that is not an
<img>fails with aLocatorErrornaming its tag; an inline<svg>is pointed toboundingBox. An image that never finishes loading fails with aTimeoutError; for aloading="lazy"one the message says toscrollIntoView()it first. - It works inside iframes, cross-origin ones included.
- The
lint:evaluate-discouragedhint now namesgetImageSizefor images, andcount/assertCountover.filter({...})for counting elements by text or by a child. - The DSL reference shows how to count by a condition:
.filter({...})for text or a child, a CSSlocator(...)for the element’s own class or attribute.
- It returns
-
Viewer tree:
- A collection file that does not parse no longer hides the others. The Collections section lists the valid collections and shows the broken file as its own row, with the parse error. Before, the whole section read “no collections match”. Runs of the valid collections are not affected by a broken file next to them.
- The section footers show only keys that do something.
Rnow runs the selected scenario headless, the same as “Run again”, and is shown only where the viewer may run.⌘Kfocuses the filter in every section. Hints that had no handler are removed: arrows,Nnew collection,Oopen .zip,⌃Cabort,⌥reveal secret.
-
A run no longer hangs after a failed step on a page that stopped answering. After a failure the runner reads the page for the failure bundle (screenshot, HTML, semantic DOM, outline), and a page whose main thread is busy, such as a script in an endless loop, never answered those reads, in WebKit and Chromium alike. Each read now gets 15 seconds. The first that runs out ends the reading: the test stays red with its own error, the output adds
the page gave no answer within 15s after the failed step, and the run’sfailure/screenshot-error.txtholds the same reason. Closing the browser context has the same limit.Killing
unotest-weboutright (kill -9) now stops the run it started too, with its browser. Before, only SIGTERM, SIGINT and SIGHUP reached it, and the run went on with nobody left to stop it. -
Updated dependencies [4897ede]
-
Updated dependencies [4897ede]
-
Updated dependencies [de529ab]
-
Updated dependencies [4897ede]
-
Updated dependencies [4897ede]
-
Updated dependencies [4897ede]
-
Updated dependencies [4897ede]
-
Updated dependencies [4897ede]
-
Updated dependencies [4897ede]
-
Updated dependencies [4897ede]
-
Updated dependencies [4897ede]
-
Updated dependencies [4897ede]
-
Updated dependencies [ab0fb14]
-
Updated dependencies [4897ede]
-
Updated dependencies [4897ede]
-
Updated dependencies [4897ede]
- @unotest/protocol@0.49.1
- @unotest/core@0.49.1
- @unotest/dsl@0.49.1
- @unotest/viewer@0.49.1
- @unotest/grounder-client@0.49.1
0.49.0 — 2026-10-09
-
A collection can set the browser window of its scenarios.
viewport:in a collection’s YAML:nullfor the real browser window,{width, height}for an emulated size. It replaces the project’sviewportfor every scenario the collection runs; the same scenario run on its own keeps the project’s window. It works with a browser profile, wheresetViewport(null)is refused.- A wrong value is refused with what to write instead.
viewport: nullover a projectdeviceis refused before the first scenario starts, as in the config. - The
setViewport(null)refusal on a browser profile now points at the collection field. - The viewer’s collection editor keeps a hand-written
viewport:andprepare:when it saves a collection.
-
A
generatecommand keeps the scenarios’ copy of application values in step with the source.generatein the config: a shell command that writesunotest/generated/from your application’s source (constants, label tables, schemas). The scenarios read the files withreadJson(). It runs from the project root before every locale2eandcollection(once for the suite) beforebundle push, and inbox createbefore the box is rented, so an edit of the source reaches the next run and the bundle carries a fresh snapshot.- A box never runs it: it has your suite, not your application, and the snapshot arrived in the bundle.
lintdoes not run it. - A failure refuses the run with exit 95 and names the command. A failure in
box createcreates no box.--no-generateone2e,collection,bundle pushandbox createskips it for one run. initaddsunotest/generated/to the project’s.gitignore, andbundle pushpacks the folder although git ignores it.
-
A box keeps the names it sets on every run to itself.
env setand the values page refuse a value namedUNOTEST_RUN_ID,UNOTEST_ENV,UNOTEST_BUNDLE_ID,UNOTEST_EDITOR_TYPES,UNOTEST_BOX_SECRET_NAMES,UNOTEST_QUEUE_LEASE,UNOTEST_RUN_TRIGGERorUNOTEST_RUN_ACTOR, naming it. Before, such a value was taken and won over the box’s own:UNOTEST_BUNDLE_ID=""made a box run look like a local one and let it rungenerate.- A value already stored under one of these names stays listed on the box, and no run receives it. Remove it with
env rm. - Other
UNOTEST_*names are set as before,UNOTEST_BROWSER_CHANNELincluded.
-
A box runs and reports each test of a file of several
test_*on its own.- Run again on one test in a box’s viewer runs that test only. A box older than this runs the whole file.
- Run notifications (Slack, Telegram, webhook) and GitHub checks count tests, not files:
smoke failed — 1 of 42 tests, and a failed test of a file of several is namedfile › test. The webhook fields keep their names:scenariosTotal,scenariosFailedandfailedScenariosnow count and name tests. bundle push --run --followprints each test of such a file between the file’s start and end, with its own failed steps,logandnotelines, underfile › test.- A test that skipped itself prints in
--followas it does in a local run,○ file › test — skipped: <reason>, not as passed. A client older than this still prints it as passed.
-
A scenario file can hold several
test_*functions, and each one is a test of its own: its own run, browser context, retry and verdict, with its own failure bundle, screenshots and console. Functions of the file are shared between its tests; state is not.npx @unotest/web e2e <file>plays them in order and exits 1 when any of them failed. A file with one test runs exactly as before.e2e <file> --test <name>plays one test, named as the function is or without itstest_prefix. A name the file does not have is refused with exit 2 and the list of its tests.// @expect-failcan mark one test: put it in the//comments directly above that test’sfunctionline, with no blank line between. A marker in the file’s leading comments, set apart from the first function by a blank line, still marks every test of the file.The MCP tool
run_testtakestesttoo. A debug session follows one test, so for a file that holds several,run_testwithoutteststarts nothing and answers with the tests and the call to make:{"status": "test_required","reason": "auth/login holds 3 tests; run_test plays one — name it in `test`.","tests": ["test_login_ok", "test_login_locked", "test_logout"],"next": "run_test {name: \"auth/login\", test: \"test_login_ok\"}"}An unknown
testanswersstatus: "test_not_found"with the same list.A collection counts tests, not files. A file of three tests is three verdicts: its line in the
collectionoutput says✗ <file> — 1 of 3 failedand lists each test under it, and the summary counts passed, failed and skipped tests. A file that never started (bailed or busy) is still counted as one file.The viewer shows each test on its own. A file of several tests is a node in the scenarios tree with one row per test, and the chevron opens it from the keyboard too (ArrowRight / ArrowLeft). The file’s tab shows the steps of every test under its name. The file’s dot and status sum its tests up: failed when one failed, skipped only when all of them skipped, failed on purpose only when every test that ran was expected to fail, otherwise passed. History shows each test’s run under the file’s run, and can be filtered to one test. A test’s run tab shows its own steps, screenshots, console and failure, titled
file › test, and Run again runs that test only. Home and a collection run’s summary count tests, and the Home card says “tests”. A link to a test’s run opens that test, with its name and status in the tab.lint:one-test-per-fileno longer reports anything: severaltest_*in one file is now the way to write a suite of related tests.A test that skips itself (
skip(reason)) now shows as skipped, not as passed. The run tree, the history strip, the run tab (its progress bar included) and the overview draw it grey with its reason, the overview gets a “skipped” card when any test’s last run skipped and no longer calls such a field “Everything is green”, and a collection row counts skipped tests apart from passed ones. A run still in progress that ends in a skip turns skipped at once, without a reload.box runprints(skipped)with the reason, and its--jsonanswer carriesskipReason. Like a locale2e, it exits 0 on a skip.Migration:
RunStatushas a new value,"skipped", andRunMetaand the scenario index entry carry the reason (skipReason,lastSkipReason). Code that switches overRunStatusexhaustively must handle the new value. Code that treated"passed"as “finished without failing” should accept"skipped"as well. The run index schema version moves to 3: the viewer discards an older index on start and rebuilds it from the runs on disk. Past skipped runs then show as skipped too.Migration: the run journal has two new event kinds,
file-run:test-startedandfile-run:test-finished, written by the run of a file with several tests. Code that switches over journal events exhaustively must handle them. Runs, manifests andrun:startedcarrytest(the function name). Its tests point at the file’s run throughparentRunId, as a collection’s scenarios point at the collection’s.Migration:
lint:one-test-per-fileis deprecated and does nothing. A config that sets it ("lint:one-test-per-file": "error") still loads; remove the entry when convenient. The rule id goes away in the next major version. -
A failed step now brings its notes and the whole diff along.
- A failed statement carries the
note(label, value)calls of itsstep(...)block: printed under the error in thee2eoutput asnote label: value, and returned aslastFailure.notes(theerrorof arun_testreply). Secrets are masked as in the run journal. A nested step reports its own notes; a step that noted nothing reports an empty list. assertEqualshows every line of its diff. The… N unchanged …folding is gone from its failure message.
- A failed statement carries the
-
filter({...})refuses a key it does not read.- It reads
hasText,hasNotText,hasandhasNot. Any other key (hasClass,visible, a typo ofhasText) used to be dropped without a word, socount(getByRole('row').filter({visible: true}))counted every row and the check passed. lintreports it aslint:filter-unknown-option(an error), and the run fails the step with the same message: the key, and the keysfiltertakes.visibleis not supported yet.- The MCP structured locator refuses such a
filterstep too, instead of stripping the key.
Migration: a scenario with an unknown or misspelled key in
filterthat used to pass now fails, at lint and at run time — on purpose: it was checking less than it said. Use one of the four keys, or a CSSlocator('tr.done')for a class or an attribute of the element itself. - It reads
-
Quieter linter on two rules.
lint:echo-assertno longer fires when the text you fill is only part of the text you wait for.fill(box, 'helper')followed bywaitForText('ann/helper')is not an echo: the page must show more than was typed. It still warns when the waited text is part of the filled value, equality included.lint:evaluate-discouragedgives one warning per file instead of one per call. The warning sits on the first call and says how many calls the file has. A call under// lint-ok: <reason>is left out of the count; with every call silenced, there is no warning. The argument hints (one value vs one array) name the lines they apply to.
-
Updated dependencies [3531ce6]
-
Updated dependencies [aac77aa]
-
Updated dependencies [78e783a]
-
Updated dependencies [970813c]
- @unotest/protocol@0.49.0
- @unotest/viewer@0.49.0
- @unotest/core@0.49.0
- @unotest/dsl@0.49.0
- @unotest/grounder-client@0.49.0
0.48.0 — 2026-10-08
-
The viewer’s status bar on a box now labels both versions —
suite 0.47.0 · box 0.48.0— instead of a barev0.47.0that read as the box’s version while it was the suite’s@unotest/web. When the suite runs an@unotest/webolder than the lowest version the box runs suites on, the bar shows a yellow “suite too old” mark; hovering it says which version to raise inunotest/package.jsonbefore pushing the suite again. A viewer outside a box shows its version as before.The box publishes its own version and that lowest supported
@unotest/webto each environment’s viewer. A box older than this change publishes neither, and the bar then labels the suite’s version alone. -
New command
bundle rm <bundle-id>: the project token that pushes a bundle can now take it back — one pushed with the wrong project’s token, or one nobody wants. The box looks for the bundle in the token’s project only and answers “not found in project …” the same way whether another project holds it or none does, so a token learns nothing about other projects.A bundle in use stays: one an environment runs, or one a waiting or running ticket needs (a queued run, or a switch on its way to it), is refused with the environments named.
--switch-to <bundle-id>first switches the environments that run it to another bundle of the same project, the way the viewer does; the box answers as the switches start, and the samebundle rmremoves the bundle once they have landed. An environment that follows its branch cannot be switched by hand: push a newer bundle to that branch first.A box older than bundle removal refuses and removes nothing: update the box, then retry.
unotest-web --helpnow also says thatenv pushmerges into the box’s values and lists--prune. -
env pushnow merges an environment’s values into the box instead of replacing them. Every name your files carry is set; a name the box holds that your files do not carry stays there and is listed as kept, so a value put on the box withenv setor on the admin page survives a push. A name that moved between your.env*and.secrets*files moves on the box too and is printed asNAME: variable → secret.--dry-runsays that it cannot know which names the box holds.New flag
--prune: also remove what the box holds and your files do not, the target override included, and list it.UNOTEST_*names on the box are never pruned, because a push never sends them.A box too old to merge refuses the push with “only replaces an environment’s values” and changes nothing: update the box, then retry. The CLI never falls back to a replace.
Migration: this changes what a published command does. A push used to remove every name its files did not carry; it now keeps them. To get the old behaviour, add
--prune:npx @unotest/web env push <env> --prune. With--json, the answer’sstatusis now"merged", and it carriesset,kept,removedandmoved. -
bundle push --run --follownow prints what each run printed while it goes, so a CI log says why a suite failed, not only that it did: every scenario’s start and end, each failed step with where it is and its error, and the run’slogandnotelines. With several collections each line starts with[collection]; with--jsonthe lines go to stderr and stdout stays the one JSON document. Each line is printed once, also when the box did not answer for a moment. Exit codes are unchanged.The box reads the lines off the run’s own journal and gives them only to the token that ordered the run, the same rule as the run’s state. Before they leave the box it masks the environment’s secrets again (
‹secret:NAME›), including a value the run itself did not know to mask: a CI log of a public repository is read by everybody.A box too old to give lines says so once — “this box does not stream run lines; the verdict and link follow” — and the follow ends as before.
-
Scenarios can negate with
!xand append to an array witharr.push(x).!xworks as in JavaScript: it returnstrueorfalse, treats0,"",nullandfalseas falsy, the same valuesiftreats as false, and binds tighter than&&and||. Negations no longer needx == false.arr.push(x, …)appends to the array in place and returns its new length, so a loop can collect values in one array. A variable holds the array itself, not a copy: afterb = a; a.push(1),bsees the new element, and a helper that pushes onto an array it was given changes the caller’s array. Callingpushon a value that is not an array fails the step with the value’s type in the message, and the linter flagspushon a string. Other array methods are still not available, and the “arrays are literals” hint forpushis gone. -
setViewport(null)runs a test on the real browser window, as Playwright’sviewport: nulldoes, without a separate run on its own config. It must come before the test does anything in its browser: the first navigation,mockRoute, a cookie or storage value, the page clock,emulateMedia, a second page or context. The browser context is opened again with the same options, so locale, storage state and the rest stay as configured. After any of those calls it is refused with that rule, because the new context would lose them. On a browser profile (launch.userDataDir,launch.profile) it is always refused;viewport: nullin the config remains the way to run a whole suite on the real window. -
assertCount,assertVisible,assertHiddenandassertValueaccept their options object in a scenario:assertCount(loc, 3, {timeout: 8000})(ortimeoutMs) now passeslintand therun_testcheck. The runner already honoured the option, but the validator refused the extra argument. A misspelled key is still refused with the list of supported ones. -
A box operator can now add an environment to a project without editing the box configuration and without restarting the daemon, from a shell on the box:
docker compose exec boxd node /boxd.mjs environment add <project> <name> --target <url>, with optional--default-branch,--no-switch,--concurrencyand--by.environment list(the same way) shows every environment and where it came from (the configuration, added on the box, or a pull request’s preview), andenvironment removetakes back one added this way — never one from the configuration, and not while a run waits or runs in it; its run history and values stay on disk. The box serves an added environment on its next sweep, or as soon as a push names it — but the box looks for new environments on a push at most once every 10 seconds, so a push refused for a missing environment in the last 10 seconds is refused again until they are up. A name the configuration also declares is refused, and if the configuration declares it later, the configuration wins.When
env pushorbundle push --runnames an environment the box does not have, the box now says which environments the token’s project has, and the CLI lists them with the command a box operator runs to add the missing one. -
The note
bundle pushprints when the box has no value for a name now lists only the names the suite actually reads — in its scenarios and in_helpers/— instead of every name declared inunotest/.env.example. A name read as a bare identifier or as anapiCallbase is said to stop a run at the first step that reads it; a name read withenv()is said to givenullthere. A suite that computes a name at run time (env(someVariable)) still gets the full declared list with the conditional wording, since its reads cannot be known before it runs. When the suite reads none of the missing names, there is no note. -
The
evaluatewarning now also states how a single extra argument arrives: as the value itself, so the body is written(x) => …, not([x]) => …(which fails with “is not iterable”). The DSL reference states the whole rule forevaluate,evaluateOnLocatorandevaluateInServiceWorker: no extra argument givesundefined, one gives the value itself, two or more give one array. Behaviour is unchanged. -
The lint that runs before a scenario now honours
linter.rulesfrom your config, the same asunotest-web lint. A rule set tooff(lint:disambig-by-indexis off by default) no longer prints one2eorrun_test, and a rule set towarnorerroris reported at that level.run_testrefuses to start on a rule your config raises toerror.e2eprints it as an error and still runs the scenario. -
The
lint:one-test-per-filewarning now shows the replacement: each former test becomes astep.soft(...)of onetest_*, or moves to a file of its own. It also links the new guide “Port a @playwright/test spec” (https://docs.unotest.com/guides/port-a-spec/), which ports a spec with several tests and runs one test over cases from a table or a JSONL file. -
unotest-webnow warns when it is not the copy your suite installed. Run asnpx @unotest/web lintfrom the project root, where no package sits, it used to take whatever version npx had cached without a word. It now prints one line with both versions and their paths, plus the command to run it from the suite instead (cd unotest && npx unotest-web …). When nothing is installed for the suite, the line asks you to runnpm iinunotest/. The run goes on in both cases. From insideunotest/, wherever the suite resolves to this same copy, or when the copy that runs is the same version the suite installed, nothing is printed. -
wait(ms)andwaitForTimeout(ms)in a scenario are now answered withuse pause(ms) for a fixed delay, or a waitFor* for a state, assleepalready was. The hint now appears on the run too, not only inlint: a scenario that reaches the runner with an unknown function name fails withUnknown DSL function "wait" at 3:5 — use pause(ms) …when the name is a known habit, and with the bare error otherwise. -
Updated dependencies [2c63fd2]
-
Updated dependencies [a160836]
-
Updated dependencies [f4e2a0c]
-
Updated dependencies [57beda5]
-
Updated dependencies [0368c9c]
-
Updated dependencies [5c9b1f1]
-
Updated dependencies [f9100af]
- @unotest/viewer@0.48.0
- @unotest/protocol@0.48.0
- @unotest/dsl@0.48.0
- @unotest/core@0.48.0
- @unotest/grounder-client@0.48.0
0.47.0 — 2026-10-07
-
New DSL getter
computedStyle(locator, property): the element’s computed CSS value as the browser resolved it —computedStyle(card, "opacity")→"0.5", custom properties too ("--accent"),""when the property is not set. A missing or ambiguous element fails asgetAttributedoes. Replaces reading styles throughevaluate. -
New DSL call
emulateMedia({ reducedMotion?, colorScheme? })changes the media features the current tab reports mid-scenario —prefers-reduced-motion('reduce'/'no-preference') andprefers-color-scheme('light'/'dark'/'no-preference'), asmatchMediaand CSS@mediasee them. It applies to the current tab only; a tab or popup opened later starts without it. Replaces overridingmatchMediathroughevaluate. -
New DSL functions for numbers, so a scenario no longer shells out to
node -eto compare one.toNumber(text, {decimal?})reads a number as a page writes it: spaces and NBSP group digits,1,234.56and1.234,56both read as 1234.56, and an ambiguous1,234fails the step naming both readings until{decimal}settles it; units and currency fail, quoting the input.round(n, digits?)rounds half away from zero on the decimal as written, soround(1.005, 2)is 1.01.formatNumber(n, {locale?, minFractionDigits?, maxFractionDigits?})formats throughIntl.NumberFormat, en-US unless a locale is given, whatever the machine’s language. The lint answer toparseInt,parseFloat,Number,toFixedandtoLocaleStringnow points at these functions. -
Quoted DSL strings now decode the ES5 escapes
\r,\f,\v,\0,\xHHand\uXXXX. Behaviour change:'\u00A0'used to be six characters and is now one non-breaking space, and a malformed\u12or\xG1is a parse error instead of being kept as text. In a string a regex is built from,\x2e/\u002enow give the character itself — a.that matches anything — not an escaped one; for a literal dot write\\.. Any other backslash pair stays as written,\bincluded (a word boundary in a regex string, never a backspace), so'\d+'and'\bword\b'work as before; backtick strings remain raw. -
New DSL calls for CSS animations:
waitForAnimations(locator, { timeout? })waits until the element and its descendants have no animation still running (default 5000 ms; a timeout names the element and how many were left), andhasAnimation(locator)says whether one is running. Infinite animations such as spinners, and paused ones, are not waited for. Replaces waiting ongetAnimations()throughevaluate. -
An environment on a box that somebody pointed at a bundle by hand can follow its branch again. Selecting a bundle in the viewer holds the environment on it, and later pushes to the branch did not arrive; nothing showed why, and nothing let go short of a restart. The bundle menu now says
pinned by <name>and offers “followagain”, which moves the environment to the branch’s newest bundle and keeps it following. The viewer’s workspace file gains an optional followfield (path,branch), present only while such a selection holds; older viewers ignore it, and an older box never writes it. -
A scenario that calls
assertJudgenow checks its judge before the browser starts, instead of failing at the first verdict after the steps before it had already run. The check follows calls into helpers, and a scenario that never judges is not held up by a broken judge setup.e2erefuses with exit code 78 and the message that names what to fix: the mode is unset, a provider variable is missing inlocalmode, or inremotemode the service does not answer its/health(start one withnpx @unotest/judge). A collection asks once, before any scenario starts. MCPrun_testanswers such a refusal with the newstatus: "config_error"rather thanspawn_failed, so an agent fixes the setup, not the launch. The viewer’s environment check gains an “LLM judge (assertJudge)” line that asks the same question. -
bundle pushnames the project the bundle landed in:✓ uploaded to <box> · project <name>. The token decides the project, not the repository, so when the box maps the bundle’s repository to no project the push still lands and is followed by a warning that says where it went and on which token; the box logs the same warning.--jsoncarriesproject,tokenLabeland, when it applies,unmappedRepo. A box older than these fields gets the previous line, without the project. -
bundle push --runprints the id of every run it queued and, when the box reports one, the run’s link in the box’s viewer.--jsoncarries the link asruns[].url. A box that does not know the address it is reached at, or one older than this field, sends no link: the id is printed alone. -
bundle push --run --followwaits for the runs it ordered and exits with their verdict:0when every suite passed,1when one failed,2when there is no verdict to give (a box too old to be followed, a box that restarted and forgot the run, or an hour without an ending), each with its reason and the run’s link. The box shows a run’s state only to the push token that ordered it, so a CI job needs no read token and no session.--jsonaddsfollow. The box’s guard lets a run’s state through on the project token without a session forGETonly; every other method on that path still needs a session. -
assertUrlandwaitForUrltake a/regex/as well as a string. A regex is tested against the whole URL, unanchored, soassertUrl(/\/cars\/\d+$/)checks a path with an id in it; a failedassertUrlnames the pattern as/source/flags. A string is still a substring match, so existing scenarios behave as before. The documented{timeout}option of both is no longer flagged as an extra argument by the editor andlint. -
A collection whose
webServersetsreuseExistingServer: falseno longer fails every scenario with “already serving”. The collection starts the server once for the whole suite, its scenarios run on that server, and it is stopped after the last one. A port held by another process still fails the collection before its first scenario. -
readFile,readFileInfo,readJson,upload()and the file waits (waitForFile,readJsonLine,waitForJsonLineand the rest) now read the file they checked. A process writing in the project could swap a file for a symlink out of it between the check and the read, and the scenario got the outside file. The file is opened once and read from that handle only when it is the file the path leads to; a one-off read that meets a file changing under it fails saying so, a wait reads it again on its next tick. -
The “follow again” row in the viewer’s bundle menu now shows its explanation in full:
pinned by <name> — the next push to <branch> will not arrive until thenwraps onto a second line instead of being cut off with an ellipsis. The bundle rows above it are unchanged. -
The
lint:evaluate-discouragedwarning now names the typed calls that replace the commonevaluateuses —computedStylefor styles,boundingBoxfor geometry,hasAnimation/waitForAnimationsfor animations — next to the attribute and text getters. -
A tool error from an MCP server that started before its code was last built now says so. Right after a package update,
run_teston a scenario using a new DSL feature answered a bare parse error from the old modules still loaded in the server; now any tool error on such a server comes back as JSON{error, serverStale: true, serverStaleWarning}— the original message plus the advice to restart the MCP server. On a server that is up to date, errors read exactly as before. -
An MCP server closed right after it started no longer leaves a browser running. The server starts its browser in the background as soon as the client connects; a client that closed within those first seconds made the server exit mid-launch, and the browser came up afterwards with nothing left to close it. The server now waits for that launch before it shuts down.
-
mockRoute('**/*', {abort: true})no longer kills a browser extension’s own pages and scripts. A mask reaches web requests only (http, https, ws, wss), so an extension panel keeps loading under a network allowlist instead of going dead with nothing in the console. To mock an extension resource, start the mask with its scheme, as inchrome-extension://<id>/panel.js; such a mask still answers even when a**/*mock was added after it. -
A link to a collection run now opens that run even when it is older than the first page of the viewer’s history. Such links — from
bundle push --run, CI statuses and notifications — opened the run’s tab and then showed “not found”. The tab now shows the collection’s scenarios as for a recent run, says “loading run…” while it is fetched, and reports “not found” only when the run really does not exist. -
A link to a run on a box — in a CI check, a run notification or the output of
bundle push --run— now opens that run in the box’s viewer. Links have the form<box>/_guard/runs/<project>/<environment>/<run id>; following one without a session signs in first and comes back to the same run. The viewer opens a run named by?run=<id>in its own tab, and ignores an id it does not have. -
A run on a box gets
TMPDIR=/tmpbesideHOME=/tmp, and an environment’s value of the same name does not replace it. Node already fell back to/tmp; a shell script or another tool in ashell()step did not, and wrote into the read-only sources instead. -
Switching an environment to another bundle from the viewer now needs an
Origin, like ordering a run and following the branch again. A request under a session cookie that carries noOriginis refused with 403origin requiredbefore it reaches the box daemon; the viewer’s own bundle menu sends one and works as before. -
Messages about a remote judge no longer print the user, password or query of
UNOTEST_JUDGE_URL— the places where gateways put keys. The check before the first step, the MCPconfig_errorreason, a refused collection and a failedassertJudgename the service by scheme, host and path only; requests still go to the URL as given. A URL with a user or password in it, which fetch never sent, is now refused up front with a pointer toUNOTEST_JUDGE_TOKEN, and a value that is not a URL is refused without being repeated. -
The viewer rebuilds its run history index again on its own after a failure, instead of waiting for a restart. A rebuild the disk refuses (permissions on the runs folder, a volume mounted late) is retried with a growing pause, from 5 seconds up to 5 minutes, until it succeeds.
/api/healthstill answers 200 and now carriesrunIndex:building,built, orfailedwith the error code, the path inside the project, the number of attempts and the time of the last one. -
Updated dependencies [832e6de]
-
Updated dependencies [783151c]
-
Updated dependencies [d7ab07d]
-
Updated dependencies [6f872c9]
-
Updated dependencies [0f1fca5]
-
Updated dependencies [af57556]
-
Updated dependencies [03e37b8]
-
Updated dependencies [87487b2]
-
Updated dependencies [2fb7385]
- @unotest/dsl@0.47.0
- @unotest/viewer@0.47.0
- @unotest/protocol@0.47.0
- @unotest/core@0.47.0
- @unotest/grounder-client@0.47.0
0.46.0 — 2026-10-02
-
Assertions take a regex and can wait for the opposite.
assertText(loc, expected, {not})accepts a/regex/as well as a string, and{not: true}passes once the text stops matching, for a spinner label that has to go away. NewassertTexts(loc, [...])checks a whole list in order, with the element count, andassertAttribute(loc, name, expected, {not})waits for an attribute value such asaria-expanded. A string still compares whole by default,{exact: false}for a substring, so existing scenarios behave as before. The “From @playwright/test” page mapstoHaveText,not.toHaveTextandtoHaveAttribute. -
New DSL function
assertSchema(value, schema): checks a value against a JSON Schema (draft 2020-12), such as one your app generates from its zod schemas withz.toJSONSchemaand the scenario reads withreadJson. When the value breaks the schema, the step fails naming every broken field by path with what is wrong there (/tags/1: must be string), not only the first one. A$refinside the schema (#/$defs/...) is followed; a$refto another file is refused, as is a schema that declares another draft or is itself invalid.formatis not checked.@unotest/webnow depends onajv. -
The page console in scenarios:
consoleEntries({level?, since?})returns what the active page logged as[{level, text, url}],pageErrors({since?})the errors it threw that nothing caught as[{message, stack}], andassertNoConsoleErrors({includePageErrors?, since?})fails on either, quoting the first three. Pages are recorded from the moment they open, extension pages (chrome-extension://) included, so a scenario acts first and asks afterwards;sincetakes a time fromnowMs().UNOTEST_CONSOLEstill decides alone whether a run writesconsole.jsoninto its artifacts and failure bundles. -
New DSL function
env(name): returns an environment value as a string, ornullwhen the name is not set, so a test can decide on it (skip a live-only check, pick a target) instead of failing on a missing variable. It reads what a bareUPPER_SNAKEname reads: the environment the run started in, thenunotest/.env. A secret, fromunotest/.secretsor injected by a box, is refused: write its bare name where the value is typed, and it stays masked. -
A new browser profile for every test:
launch: { profile: 'per-test' }starts eachtest_*in a new browser on a new, empty profile directory, so an extension loads again, its service worker starts from scratch and nothing inchrome.storage,localStorageor the tabs carries over; with one test per file, a suite of N files runs on N profiles. The directory is removed after a passing test and kept in the run folder after a failing one; the run log names it. Browser extensions and the service-worker functions (waitForServiceWorker,evaluateInServiceWorker,extensionId) now work on per-test profiles too, not only withlaunch.userDataDir, andmockRouteanswers the worker’s own requests in every test. It cannot be combined withlaunch.userDataDir, and it is refused withstorageState, with more than one worker and on a box;UNOTEST_USER_DATA_DIRoverrides it with one shared profile. An agent joined to a debug run follows it into each test’s browser. -
New DSL function
skip(reason, condition?): a test ends itself as skipped when it does not apply here, for exampleskip('no live API configured', env('LIVE_API') == null). Without a condition it always skips; with a false one the test goes on.skipis a test guard: it stands in thetest_*body next to the steps, likestep(...)andteardown(...), and the validator accepts it there. The run reports skipped with the reason, neither passed nor failed: the CLI prints○ test: … skipped: <reason>and counts1 skipped, a collection countsskipped=N(test=N), andrun_testanswersoutcome: "skipped"withskipReason. The viewer does not know skipped yet: it shows such a run as passed, without the reason. The exit code stays 0. Nothing after the call runs; teardown blocks registered before it still play. A skip after a failed soft step, or a skipped test whose teardown fails, is a failed test. A new lint rule,lint:skip-outside-test(error), reportsskip()anywhere else: inside a step or a block, in a helper or aflow_*. Validator contracts gain an optionaltestGuardflag: a function marked with it may stand as a direct child of atest_*body. On the wire a skipped run iscompletedwith a new optionalskipReasonfield onrun:finishedand in the runtime state, so readers that predate it still see a finished run. -
A test’s time budget, set where you expect it. The config key
testTimeoutMs(default 300000) is the deadline of one test: an action still running when it runs out is cut there, the test fails namingtestTimeoutMs, and its teardown blocks still play.30000gives every test 30 s. The new DSL functionsetTestTimeout(ms), the counterpart of Playwright’stest.setTimeout, gives one test its own budget, longer or shorter, counted from the test’s start; the error then namessetTestTimeout(ms).UNOTEST_MAX_DURATION_MSstill overrides the config for a run and keeps its name in the error. A new lint rule,lint:set-test-timeout-outside-test(error), reportssetTestTimeout()outside atest_*body. Running out of time fails the test; Stop in the viewer still aborts the run. -
Actions (
click,doubleClick,fill,press,check,uncheck,hover,selectOption,dragAndDrop), navigation (goto,reload,goBack,goForward,waitForUrl,waitForNavigation) waits (waitFor,waitForText,waitForCount) and assertions (assertText,assertTexts,assertAttribute,assertVisible,assertHidden,assertValue,assertCount,assertUrl,assertNeverAppears) now refuse an option they do not take, naming the ones they do:click(btn, {timout: 5000})used to wait the default 3 s without a word.Migration: a misspelled option in these functions now fails naming the supported ones — fix the spelling the message points at.
noWaitAfteronclickandfillnever had an effect; it is now refused, so remove it. -
The file oracles now read only where
readFiledoes.waitForFile,assertNoFile,waitForJsonLine,readJsonLine,assertNoJsonLine,waitForFileCountandassertFileCounttake a path relative tosandbox.shellCwd, or an absolute one inside the project, the run’s downloads or a directory listed in the newsandbox.readRoots. Anything else (../out of them, an absolute path elsewhere, a symlink that leads out) fails the step with a scenario error naming the path and the directories it left. The check repeats on every read of a wait, so a file that appears as a symlink out is refused when it appears.readFileandreadFileInfoare stricter too: a file that does not exist yet is judged by the nearest directory above it that does, so a path under a directory symlinked out of the project is refused instead of reported missing. They also read fromsandbox.readRoots.Migration: a scenario that waits on or reads a file by an absolute path outside the project, such as a background listener’s log in
/tmp, now fails with that error. List the directory in the config:sandbox: { readRoots: ['/tmp/my-listener'] }. -
webServerin the config takes one server or a list — a front and the fake backends it talks to, each with its own readinessurl. A list starts all at once, the first scenario runs once everyurlanswers, and the servers stop in reverse order; if one does not come up, the ones that did are stopped and the error names each that failed. Two entries on oneurlare refused.app-serverprints one event per server. Type change:UnotestConfig.webServeris now always a list once the config is parsed (an object in the file still works). -
Stopping a
webServerthe run started now waits until the server itself has exited, not only the shell that launched it. On Linux the shell exits at once on the stop signal while the server is still shutting down, so the next step could find its port still taken or its shutdown output unwritten. -
Updated dependencies [8241cfb]
-
Updated dependencies [0fa0343]
- @unotest/dsl@0.46.0
- @unotest/protocol@0.46.0
- @unotest/viewer@0.46.0
- @unotest/core@0.46.0
- @unotest/grounder-client@0.46.0
0.45.0 — 2026-10-01
-
Browser extensions in scenarios:
waitForServiceWorker({timeoutMs?, url?})returns the URL of an MV3 extension’s service worker,evaluateInServiceWorker(js, ...args)runs JS in it — the way tochrome.storage,chrome.tabsandchrome.runtime, with the same argument and return rules asevaluate— andextensionId()gives the id forchrome-extension://<id>/popup.html. Every call finds the worker anew, so a worker MV3 put to sleep does not go stale. Extensions load only into a browser started withlaunch.userDataDir; without it the calls fail at once and say so.mockRoutealso answers the worker’s own requests on a freshly opened profile. -
Two reads of one element:
boundingBox(loc)returns{x, y, width, height}in viewport CSS pixels, ornullwhen the element is not rendered;evaluateOnLocator(loc, js, ...args)runs JS on the matched element — the body gets the element first, then the extra arguments by the same rules asevaluate. -
Network mocks in scenarios:
mockRoute(glob, response)gives requests matching a Playwright URL glob a fixed answer for the rest of the scenario — a response ({status?, body?, headers?, contentType?}, an object body goes as JSON),{abort: true}or{continue: true}. Where several masks match, the one mocked last answers, somockRoute('**/*', {abort: true})followed bymockRoute('**/localhost:*/**', {continue: true})is a network allowlist.unmockRoute(glob?)takes mocks off.waitForRequest(glob, {timeoutMs?})returns the request withurl,method,headers,postDataandpostDataJson; on a mocked mask it also finds a request sent before the call.{abort: true, record: true}keeps what it stopped, andassertNoRequest(glob, {since?})fails naming those requests. A fixed response to**/*is refused, and the site’s own page is never aborted. Captured network entries of mocked requests carrymocked: true. -
New DSL functions
newPage(url?)andclosePage(index?): a scenario opens and closes its own tabs.newPageopens a tab in the active browser context, makes it active and returns its index; with a url the tab opens it, a relative path resolving against the base URL as ingoto.waitForPagenever returns a tabnewPageopened.closePagecloses the active tab, or tabindex. Closing the active tab makes the tab that was active before it active again, or the tab at the index below when that one is gone. Closing another tab keeps the steps where they are. The only open tab cannot be closed: the step fails withcannot close the only open tab. -
Four new DSL functions for reading and comparing values inside a scenario:
textMatches(text, /re/)returnsfalseon a miss, or[whole, group1, …]for the first match. A group that took no part isnull. A string pattern is refused with a pointer totextContains.parseJson(text)parses a JSON string into a value you walk with.and[]. Invalid JSON, including an empty string, fails the step and quotes the input.urlParam(url, key)returns a decoded query parameter:nullwhen the key is absent,''when it is empty. A path without a host works too.assertEqual(actual, expected)compares two values deeply, the way JSON does: key order does not count, and a missing key differs from anullone. A failure names the first differing path with both values (values differ at panel.devices[1]: expected "ipad", got "iphone"), followed by a line diff of the two sides.
-
New DSL functions
readFile(path, {encoding})andreadFileInfo(path).readFilereturns a file’s text, or its bytes as base64 with{encoding: 'base64'}.readFileInforeturns{size}and, for a PNG,png: {width, height}read from the header, so a test can check an exported image without reading it whole. Both take a path relative tosandbox.shellCwd(the project root by default), an absolute path inside it, or thepaththatwaitForDownload()returns, so a test can check the file a page downloaded. Any other path is refused, and so is a symlink inside the project that points out of it.readJsonanduploadnow refuse such a symlink too; before, they followed it. The docs gain a “Files” section in “From @playwright/test”. -
New DSL function
readJson(path): reads a whole JSON file once and returns the parsed value, so a scenario can walk it with.and[]. It is meant for constants your app keeps in source: a small script exports them to JSON, and the scenario reads that file instead of repeating the values by hand. The path is relative tosandbox.shellCwd(the project root by default), the same basereadJsonLineuses, and must stay inside it: an absolute path or one that climbs out with../is refused. A missing file or a file that is not valid JSON fails the step at once. The docs gain a “From @playwright/test” section on moving helpers and constants. -
setPage(index, {activate: false})switches the tab the steps act on without bringing it to front, so a scenario can drive an extension panel in the background while the site stays the visible tab. Without the optionsetPagebehaves as before and brings the tab to front. New DSL functionbringToFront(index?)shows a tab explicitly and leaves the steps on the active tab; with no index it shows the active tab. An unknown option or a non-booleanactivatefails the step instead of being ignored. -
viewport: nullin the config turns size emulation off: the page is as wide as the real browser window (headless: the size from--window-sizeinlaunch.args). Adevicenext to it is refused, and so issetViewport(null)in a scenario — Playwright keeps a size it set on an open page, so the real window is chosen in the config. -
New DSL function
waitForDownload(options?): returns the first file the page downloaded that the scenario has not taken yet, as{path, name}.pathis the absolute path of the saved copy; read it withwaitForFile.nameis the file name the site suggested. Downloads are recorded from the moment the browser opens, so clicking first and asking afterwards loses nothing.{timeoutMs}defaults to the navigation timeout.{name}takes a substring or regex of the file name; a download that doesn’t match stays for a later call. In a run the file is saved with the run and the viewer lists it under the step that took it, as a link to the file. Outside a run it is saved under~/.cache/unotest/web/downloads/. The run journal has a newdownloadevent kind. -
New DSL function
waitForPage(options?): waits for a tab the scenario has not received yet, makes it active and returns its index. Use it for a tab whose index you don’t know up front: one a browser extension opens, or one that opens next to tabs already there. A tab that opened before the call still counts, even when the click was a step earlier. No tab is returned twice: a tab counts as received when it was open at the start, whensetPageswitched to it, or when an earlierwaitForPagereturned it.{timeoutMs}defaults to the navigation timeout. When no new tab arrives in time, the step fails withno new tab within N ms, N tabs open. This replaces thepause(ms)workaround the docs used to recommend. -
waitUntil(() => condition, {timeoutMs?, intervalMs?, label?})polls a condition until it is truthy and returns that value — Playwright’sexpect.poll. The body is an expression or a block that returns its value; calls inside it (apiCall,evaluate,textContent) run on every try.timeoutMsdefaults to the run’s wait timeout,intervalMsto 250. Only a falsy value is retried: an error in the body fails the step at once. On timeout the step fails naming the label, the number of tries and the last value. -
Object literals in scenarios take a reserved word as a key, as in JavaScript:
mockRoute(glob, {continue: true})used to fail to parse withExpected property key (word or string), and so did any key such asif,for,returnorstep. Quoting the key ({"continue": true}) is no longer needed. Dot access by a reserved word (a.continue) still does not parse; usea["continue"]. -
timeoutMsis now accepted wherevertimeoutis: in actions (click,fill,press,hover,selectOption,dragAndDropand the rest), navigation (goto,reload,goBack,goForward,waitForUrl,waitForNavigation), waits (waitFor,waitForText,waitForCount) and assertions (assertText,assertVisible,assertHidden,assertValue,assertCount,assertUrl). Before,{timeoutMs: 5000}there — the name the newer functions use — was ignored without a word and the call waited its default. Giving bothtimeoutandtimeoutMsin one call is refused. -
Updated dependencies [a987021]
-
Updated dependencies [3bc4c8f]
-
Updated dependencies [a5a3825]
- @unotest/dsl@0.45.0
- @unotest/protocol@0.45.0
- @unotest/viewer@0.45.0
- @unotest/core@0.45.0
- @unotest/grounder-client@0.45.0
0.44.0 — 2026-09-30
-
Page clock, time zone and locale.
- New DSL functions
clockFreeze(iso),clockTick(ms),clockRun().clockFreezestops time on the page:Datereads the given instant, andsetTimeout,setIntervalandrequestAnimationFramestand still. A “typing…” indicator or a countdown then gives the same screenshot on every run.clockTickmoves frozen time forward and fires the timers that fall due.clockRunlets time run again.clockFreezeworks at any point, including aftergoto, and takes past dates as well as future ones. - When the clock is installed. A run puts the fake clock on the browser before the first page loads, and only for scenarios that can reach a clock call, directly or through a helper. Every other scenario keeps real time, as before. Playwright’s spelling (
clock.freeze(…),pauseAt,runFor,resume) is refused with the DSL name to use instead. - MCP.
new_contextandexplore_starttakeclock: true.explore_stepgainsclock_freeze {time},clock_tick {ms}andclock_run, recorded as the DSL calls. On a browser opened without the clock they are refused, and the refusal says how to open one with it. - Config
timezoneandlocale("Europe/Madrid","es-ES") for every page.UNOTEST_TIMEZONEandUNOTEST_LOCALEstill override them for one run. An unknown zone or a malformed locale is refused when the config loads. - Screenshots taken while time is frozen no longer wait for an animation frame that cannot come. Each used to cost up to two seconds.
- New DSL functions
-
A second browser context in one test: two clients of the same app.
- New DSL functions
newContext()andsetContext(index).newContext()opens another browser context with the same options as the first: base URL, viewport,device, time zone, locale and page clock. It has its own cookies, storage and tabs, makes it the active one and returns its number (the test’s own context is0).setContext(i)switches. Each context keeps its own tab and frame, so a switch back lands where the scenario left off. At the end of the test every context is closed. - Config
maxContexts(default 4) caps contexts per test. The nextnewContext()is refused and the refusal names the key. On a browser profile (launch.userDataDir)newContext()is refused: every page there shares one context. - Run journal and failure bundle. Once a second context exists, each step records the context it started in (
contextIndex), and the viewer’s Trace shows it as actx Nchip. A failure bundles the active context’s page, console and network, withcontextIndexinfailure.json. A one-context test writes exactly what it did before. Both fields are additive: an older viewer reads the new journal and a newer one reads the old. - MCP.
explore_stepgainsnewContextandsetContext {index}, recorded as the same DSL calls. Thenew_contexttool still resets the browser. Intent grounding follows the active context. An agent joining a paused debug run (take_wheel,attach_debug_session) lands in the context the run is on (Chromium). - New lint rule
lint:shadows-dsl-function(warning): a function or variable of your own that has a DSL function’s name.
If your scenarios define their own
newContextorsetContext: a call by that name still runs your function, so nothing changes at run time. The new DSL function is out of reach until you rename yours.lint:shadows-dsl-functionpoints at the definition. - New DSL functions
-
Strings, object literals and indexes in scenarios.
- A string value answers
s.length,s.trim(),s.includes(search)ands.startsWith(prefix)— in asserts, in conditions, on literals and on call results (assertTrue(getTitle().trim().length > 0)). Any other member (s.padStart(3)) is a lint error,validator:unsupported-string-member, that names it and lists the four. A member of the list used on a value that is not a string at run time fails the step with the value’s type in the message. - An object literal is a value:
h = {x: 1, y: "z"}can be kept in a variable, nested ({a: {b: [1]}}) and passed to a helper. A slot that wants a specific kind (a string, a locator) still rejects an object with itsarg-kinderror. - An index works anywhere in a chain:
res.body.items[0].id,dbQuery(sql)[0].id,a.b[1][0]— no temporary variable. Indexing a locator is still rejected. - The lint hints for JS string habits now point at the string members (
indexOf→use s.includes(needle)).
- A string value answers
-
teardown("label", () => { … })— give the stand back whatever happens.- A scenario registers a rollback block where it turns something on; after the test body every registered block runs, newest first, whether the test passed, failed, ran out of time or was stopped — including a run stopped while paused at its failure (the debugger,
run_test). One failing block does not stop the others. Flows and helpers can carry their own rollback. - A green test whose teardown failed is failed; a failed test keeps its own error and lists the teardown failures next to it (CLI,
run_test→next.teardownFailures,runtime.json). The failure bundle is taken before the rollback, so it shows the page the test broke on. - The CLI prints
↺ teardown "…"per block; the journal getsteardown:started/teardown:finished/teardown:skippedevents and ateardownflag on the block frame; the viewer marks those frames and shows “returning the stand…” while a stopped run rolls back. - Stop now rolls back first: the first Stop (viewer,
abort_runtime, SIGTERM, Ctrl-C) plays the teardown blocks and then ends the run; a second Stop skips what is left and says so.abort_runtimeanswersstatus: "returning the stand…"while that happens. - New config key
teardownTimeoutMs(default 60000): the ceiling for all teardown blocks of a run, separate from the run’s own time budget. A killed process, or blocks cut by this ceiling, leave the stand as the test set it — the run result names them. - Lint:
teardown-shape(string-literal label, noreturn/breakout of the block, no nested teardown) andteardown-name-conflict.
Breaking for scenarios that define their own
teardown. A function, parameter or variable namedteardownis now a lint error (teardown-name-conflict), and a statement-level callteardown(x)no longer parses as a call. Rename it, e.g. toundo_setup. - A scenario registers a rollback block where it turns something on; after the test body every registered block runs, newest first, whether the test passed, failed, ran out of time or was stopped — including a run stopped while paused at its failure (the debugger,
-
assertScreenshot— the page, or an element, against a PNG baseline in the repo.assertScreenshot(name, options?)/assertScreenshot(locator, name, options?)compares a snapshot withunotest/e2e/<scenario>.screenshots/<name>.png— a Figma export or a snapshot you accepted. A pixel differs when any channel differs by more thanthreshold(default 32); the assert fails when the share of differing pixels is more thanmaxDiffRatio(default 0) — exactly the limit passes.mask: [locator…]andignore: [{x, y, width, height}…]leave areas out of the comparison on both sides. A different size fails at once with both sizes; nothing is resampled.fullPage: truecompares the whole page.- No baseline yet: the assert fails and the snapshot is saved in the run’s artifacts as a candidate under the baseline’s file name.
e2e --update-screenshots(MCP:run_test {updateScreenshots: true}) writes the snapshots as baselines; it is refused on a box. - A failed assert keeps
expected,actualanddiffpictures (per retry attempt); the viewer shows them side by side on the failed step with a “Download as baseline” button, the failure bundle points at them, andrun_testreturns them innext.visual.box run --download/box_run_downloadunpack baseline candidates next to the zip. - A baseline is read as untrusted input: an image over 25 megapixels, or one whose data unpacks to more than its header allows, is refused before it is decoded.
- The journal gets a
visual-assertevent per assert;runtime.jsoncarries the last failed one asvisual. - A suite over 128 MB is warned about before the upload — boxes accept 128 MB by default — naming its biggest files and baselines.
New dependency of
@unotest/web:pngjs7.0.0 (MIT, no dependencies of its own), the PNG codec behind the comparison. -
Window size and scale: set them in the config, per MCP call, or mid-scenario.
viewport.deviceScaleFactorinunotest.config.{ width: 390, height: 844, deviceScaleFactor: 2 }makes every screenshot 780×1688, the scale design mockups are usually drawn at. Everyviewportfield is now optional: a missing one comes fromdeviceor from the 1280×720 default. A config withoutviewportopens at 1280×720, as before.devicein the config: a Playwright device preset by its exact name ("iPhone 14","Pixel 7"), applied whole — size, scale, touch, mobile mode, user agent.viewportnext to it overrides the preset field by field. An unknown name is refused with the nearest ones. A mobile preset on Firefox is refused: Firefox has no mobile mode. A preset’s viewport is the page area, smaller than the screen:"iPhone 14"is 390×664. To match a 390×844 mockup, setviewportexplicitly.- MCP
new_contexttakesviewportanddevice, and opens the context at that window.explore_starttakes them too when no browser is open yet. With a browser already open it refuses and points tonew_context. Before this,new_contextaccepted any argument, ignored it and answered success. Now an unknown argument is refused and the reply names the accepted fields. - New DSL function
setViewport({width, height}). It resizes every open tab in place, with no reload, and tabs opened later start at the same size.deviceScaleFactoris refused here: the scale is fixed when the browser opens.
-
A finished run opened from history keeps its rollback pass: the viewer no longer drops
teardown:started/teardown:finished/teardown:skippedfrom a stored journal, so the teardown frames of a stopped run show next to the step it was stopped in — not inside it — as they do while the run is live. -
The run budget (
UNOTEST_MAX_DURATION_MS,maxDurationMs) is now a deadline, not a check between statements.- Behaviour change: a run whose last action took it past the budget used to finish green, and so did a run whose single action outlasted the whole budget. Both are now red with
maxDurationMs budget (…ms) exhausted, reported on the action that spent it. - An action still running when the budget runs out is abandoned at that moment instead of being waited for. Its browser call is not killed: it ends at its own timeout or when the context closes, and its late failure is not reported. Teardown blocks still run afterwards, on their own
teardownTimeoutMs. - Every budget now fails with a typed
BudgetExhaustedError(setting: "maxSteps" | "maxDurationMs" | "teardownTimeoutMs",kind: "steps" | "duration"). The message names the setting that was spent: a teardown cut by its own ceiling saysteardownTimeoutMs (…ms) exhausted — the rollback did not finish, as before, not the run’s budget.
- Behaviour change: a run whose last action took it past the budget used to finish green, and so did a run whose single action outlasted the whole budget. Both are now red with
-
readBounded(handle, size, max)reads an open file into a buffer of its measured size plus one byte: a file overmaxis refused before anything is allocated, and one that grew after it was measured is refused rather than read in part (BoundedReadError). Reading a screenshot baseline no longer reserves memory for the largest file allowed — only for the file there is. -
resumeandstepnow answer with the stop that comes after the command, not the pause the run was already in.A paused run keeps refreshing its runtime file, and the tools took a fresh timestamp on a paused state for the next pause. An agent could be told the run had moved on while it still sat at the old breakpoint, and take the wheel there. The runner now counts its pauses in
runtime.json(pauseSeq, one more on every pause, the same line again included), andresume/stepwait for that count to grow or for the run to end. A run from an older runner, without the count, is waited on as before. -
A screenshot the browser refuses to capture is asked for once more. Chromium now and then refuses a frame on a busy machine, and
assertScreenshot,screenshot()and the MCP screenshot step used to fail on the first refusal with a raw protocol error. A second refusal is nowScreenCaptureError, which names the browser’s reason and what to do. A closed page or a timeout is not retried. -
A stopped run never ends silently: if the first Stop’s rollback — or what runs before it, such as framing a paused failure — is still going at
teardownTimeoutMsplus 15 seconds, the runner prints that it is exiting without finishing the rollback, then exits. A rollback block cut as hung is named in the output. The DSL reference now states both Stop limits: the rollback budget with its 15-second exit margin, and the viewer’s 5-minute force-kill as a safety net only. -
Updated dependencies [816e2ea]
-
Updated dependencies [565ef67]
-
Updated dependencies [5ac6c72]
-
Updated dependencies [d1605b2]
-
Updated dependencies [3780074]
-
Updated dependencies [99ba57f]
-
Updated dependencies [d66cf72]
-
Updated dependencies [832c101]
- @unotest/protocol@0.44.0
- @unotest/viewer@0.44.0
- @unotest/dsl@0.44.0
- @unotest/core@0.44.0
- @unotest/grounder-client@0.44.0
0.43.2 — 2026-09-30
-
abort_runtimenow takes a run that is still waiting in the queue out of it. It used to answer ok for such a run while its place in line stayed, and the run started later all the same. It now removes the run from the queue and answers{ok: true, state: "withdrawn"}; nothing is run. A run that was taken out of the queue, here or in the viewer’s Active panel, no longer ends itsunotest-web e2eprocess with a stack trace: it prints that it was taken out and that nothing was run, and exits with 130, as Ctrl-C does.What changes for you:
abort_runtimeon a runtime id with no run behind it at all now answersno-runner, marked as an error, astake_wheelandrelease_wheelalready do, instead ofok. A finished run still answersok. A queued run taken out of the queue exits with 130 instead of 1. -
When a person answered a request is now shown outside the cabinet too. The cloud API adds
decidedAtto an approved, declined or failed request. Thecloud_approval_waitMCP tool returns it, and the CLI prints it — “approved in the cabinet at 2026-09-27 09:14 UTC” — inapprovals <id>and whilebox create,box destroyandbox picker onwait; with--jsonit is anapprovalfield in the object the command already prints. A request that expired shows the deadline it was last given, marked as such, since the cloud’s answer for an expired request carries no time. A service that does not send the field yet is read as before, without a time. -
The times the CLI and the MCP tools show for an approval request (when a person answered it, when it expires) are now only taken from the service when they are a real date and time. Anything else is treated as if the service had sent no time: nothing is printed for it, and an expired request names no deadline it cannot vouch for.
-
box runsand the other box read commands now say when the token is of the wrong kind (a project push token instead of a read token) and how to get the right one, instead of reporting that the box did not list its environments. -
Text the cloud or a box sends can no longer send instructions to your terminal. The
login,logout,whoami,approvals,box,picker,bundleandenvcommands used to print box names, request ids, error messages and other text from the service as they came, so control characters in them, from a service with a bug or aUNOTEST_CLOUD_URLpointed elsewhere, could clear your screen or retitle the window. They are now shown escaped (\x1b), and line breaks stay line breaks. -
explore_run_flownow says where a failed replay broke. The reply used to carry the error message alone, so a flow that failed inside a helper gave no file and no line, and the agent had to work out the cause from a screenshot. A failed replay now answers JSON:error(the same text as before),flow,atwith the file, line and column of the statement that failed (the helper’s file when it failed inside one, project-relative), its step label and the call chain from the flow down, andartifactswith a capture of the page as the flow left it.What changes for you: a failed
explore_run_flowanswers JSON instead of plain text, with the old text undererror. Helpers now load the wayrun_testloads them, so a function defined in two helper files is refused, naming both files, inexplore_run_flowas it already was inrun_test. -
An agent without a grounder is now told how to get one. When intent locators have no grounder (ollama not running, grounding off),
explore_start’sgrounderand a failed intent step carry{state: "no-grounder", next, orElse?, why}:nextis the Picker command (box picker onfor a project with a box,login && picker onwithout one),orElseisollama servein local mode, andwhysays the MCP server needs a reconnect after the Picker is turned on. A remote grounder that is configured but down gets the reason only, since turning a Picker on would not fix it. WithUNOTEST_SNAPSHOT=0,explore_startnow says the page snapshot tools are not served, how to bring them back, and how to locate elements in the meantime. The server instructions describe the new state.What changes for you: an intent step that fails for want of a grounder now answers
{status: "failed", error, grounder}like other steps that fail before they act, instead of an error reply carrying the text alone; theerrortext is unchanged. -
A
run_testthat waits in the queue now says who holds the machine. Thequeuedreply used to say only that the machine was busy, so an agent could not tell whether to wait or to stop a run of its own it no longer needed. It now carriesheldBy: each run holding the machine with its run id, kind, scenario or collection name, how long it has been running (left out while that is not known yet) and, for a scenario run, theabort_runtimecall that stops it. A blockingrun_test(the default) used to wait for its turn for up to three minutes, longer than an MCP client waits for any answer, so the agent was told only that the request timed out. It now waits up to 30 seconds for a place and then answersqueuedwithheldBy, the same aswait: false; the run stays in the queue and starts on its own.abort_runtimetakes a queued run back out.What changes for you: a blocking
run_testbehind a busy machine may now answerstatus: "queued"after about 30 seconds instead of waiting for the run to start; callinspect_runtimeon itsruntimeIdto follow it. -
A tool that throws something other than an
Error, such as a plain string, now has registered secrets masked in the message the agent reads, as anErroralready did. -
resumeandstepnow answer with the stop that comes after the command, not the pause the run was already in.A paused run keeps refreshing its runtime file, and the tools took a fresh timestamp on a paused state for the next pause. An agent could be told the run had moved on while it still sat at the old breakpoint, and take the wheel there. The runner now counts its pauses in
runtime.json(pauseSeq, one more on every pause, the same line again included), andresume/stepwait for that count to grow or for the run to end. A run from an older runner, without the count, is waited on as before.This ships as a patch: it fixes how the existing
resumeandstepwait, and adds no new tool, flag or input.pauseSeqis an optional field that only ever appears in addition: an older reader ignores it, and a newer reader facing a file without it keeps the previous timestamp check. -
Screenshots and saved page HTML leave out what a page keeps secret.
- Password and one-time-code fields are painted over on every screenshot: the agent’s screenshots, per-step captures, and the screenshot taken when a run fails.
- The
page.htmlsaved when a run fails no longer contains the content of<script>elements or the values of hidden form fields; each is replaced by a label with its length. The same applies to the element HTML written in debug mode. - Text in elements that are not rendered, and in
noembedandnoframes, no longer reaches element names.
What changes for you: if you read
page.htmlfor script data or hidden field values, setfailureBundle.pageHtml: "full"in your config to keep them. Password fields stay hidden either way. -
An element’s text, wherever the page is read for it — the locator a ref becomes in a recorded scenario,
check_locator’s matches and the failure’s semantic snapshot — no longer includes the content of<noscript>,<script>,<style>or<template>: with JavaScript on, a page could repeat a secret inside<noscript>and it went into the customer’s scenario (or into the refusal the agent read) in a locator that, since Playwright’s own text matching skips<noscript>, did not even find the element.The same goes for a failed
assertText(andassert_text): it still compares against the element’s full text, as before, but the “got” it quotes — in the MCP reply, the run’s output,runtime.json,steps.jsonlandfailure.json— leaves those blocks out, and when they alone made the comparison fail it says so. What changes for you: a failure message may now readgot "API key"where it used to show the text of a<noscript>as well, followed by a note that the comparison includes hidden text; very long texts are quoted up to 10,000 characters, with a note when cut. -
Updated dependencies [665da8f]
-
Updated dependencies [56c899c]
-
Updated dependencies [70c37be]
-
Updated dependencies [0e15c53]
- @unotest/core@0.43.2
- @unotest/protocol@0.43.2
- @unotest/grounder-client@0.43.2
- @unotest/dsl@0.43.2
- @unotest/viewer@0.43.2
0.43.1 — 2026-09-29
-
A debug command written a moment after another is no longer lost. The runner read
commands.jsonlonly on file-change events, and two appends a millisecond apart (take_wheel, thenabort_runtime) could arrive as one event that fired before the second write landed: the second command waited, unread, until the file changed again. The paused run was never stopped, and it kept holding the project. The runner now keeps reading while the file grows, and it also checks the file size every 250 ms. -
abort_runtimeno longer answers ok for a stop the runner never took. It used to answer ok as soon as the command was written, so an agent was told a paused run had stopped while the run went on holding the project, and the nextrun_testcould not start. It now answers ok once the runner has acted on the stop. A runner that is alive but does not act within ten seconds gets astop-not-acceptederror naming the state it is stuck in and what to do next.What changes for you:
abort_runtimemay now answerstop-not-acceptedinstead ofok; when it does, do what itsnextsays. -
box runs,box run,box queue,box screenshotandbundle push --runno longer need--envwhen the box serves a single environment: that one is used. With several, the refusal lists them and asks for--env <project>/<environment>; an explicit--envworks as before. The MCP toolsbox_runs,box_run,box_run_download,box_queueandbox_screenshotfollow the same rule:environmentmay be left out on a box with one environment, and calls that pass it work unchanged. -
The page captured on a failure no longer holds what a
<noscript>element contains. With JavaScript on, a browser keeps that content as plain text, so a value a page repeated there for readers without JavaScript — a secret field’s included — reached the capture past the rule that hides secret fields. The element itself is kept, empty, so the page’s structure is still visible. -
After
run_testpauses and auto-attaches,explore_startnow opens the agent’s own browser instead of recording into the run’s (which answered every step withnot_your_turn), and afterrelease_wheelthe agent can still take screenshots, assert and wait on the run’s page. -
Secret field values no longer leak into failures and replies. An
assertValuefailure on a field marked as a credential (type="password", orautocompleteofcurrent-password,new-passwordorone-time-code) reports only the lengths of the two values, for exampleexpected ‹hidden, 12 chars›, got ‹hidden, 32 chars›. The same fields are no longer read by the grounding capture, and show as‹hidden, N chars›in a failure’s page HTML and in a fill recorded by the viewer. MCP replies now mask registered secrets as‹secret:NAME›, as the session journal already did: text, structured content and tool error messages.What to check if you ran 0.43.0 or earlier. The value of a password or one-time-code field, and the value of a secret from
unotest/.secrets, could appear in plain text in MCP tool replies (for exampleassert_text,check_locator,assert_valueand theget_failure_*tools), in the text of anassertValuefailure, and in thepage.htmlandfailure.jsonsaved with a failed run. Search your MCP client’s transcripts and your saved run failures for your secrets and credentials; if one is there, rotate it. -
take_wheelandrelease_wheelnow answerokonly once the paused run has actually handed the wheel over; before, they answered as soon as the request was written, and on a busy machine an agent could still click in a run it had just given back. When the run does not apply it, the tools answerwheel-not-acceptedwith what to do next. From the momentrelease_wheelis called, the agent’s page-changing steps answernot_your_turnuntiltake_wheelsucceeds again.What changes for you: an agent that waited for
okfromtake_wheelorrelease_wheelmay now getwheel-not-acceptedinstead, and should do what itsnextsays — the wheel may simply have gone back to the human, whichinspect_runtimeshows in itswheelfield. A run with no runner left now answersno-runnerinstead ofok. Both are marked as errors, likeabort_runtime’sstop-not-accepted. -
Updated dependencies [10e03e9]
-
Updated dependencies [5433bab]
- @unotest/core@0.43.1
- @unotest/grounder-client@0.43.1
- @unotest/dsl@0.43.1
- @unotest/protocol@0.43.1
- @unotest/viewer@0.43.1
0.43.0 — 2026-09-27
-
A run on an environment no longer inherits a host from the base
.envEnvironment files layer:
unotest/.envfirst, then theunotest/.env.<name>overlay an active environment selects, the later file winning. That is the right rule for a password or a user name, and the wrong one for a host. A project whose base file pointsAPI_BASE_CHATat production, and whosedevoverlay does not mention it, ran--env devand sent its API calls to production. Nothing in the output said so: the reply reported thedevenvironment, and the requests went elsewhere.From this release a variable that becomes a HOST has to come from the active environment’s overlay, or from the process environment. Three variables count as hosts:
APP_BASE_URL, which every relativegotoresolves against; the name passed asapiCall(..., {base: "NAME"}); and a variable used as the URL ingoto(NAME). When the value still comes from the base file, the run is refused before the browser starts, andlint,run_testandexplore_startrefuse identically — with a message that names the variable, both files, and the two ways forward. Every other variable layers exactly as before.A base value that really is meant for every environment is declared once, in
unotest.config.{js,mjs,ts}:export default { env: { shared: ["API_BASE_CHAT"] } };A value the process environment supplies — a shell variable, or the values a box injects into a run — is never affected by this rule: it is a value somebody stated for this run, not one silently inherited from a file the overlay was supposed to replace.
Migration: a suite that runs with
--env <name>and keeps its host in the base file will now refuse, naming the variable. Either add the variable to the overlay, which is what most projects meant, or list its name underenv: { shared: [...] }. A suite that never uses--envis unaffected. -
A run held at its failure has a screenshot, and the tools answer while it stands
A run paused on a failure left nothing on disk: no failure bundle, no screenshot, and
get_failure_screenshotreplied “failure bundle not found”. The frame only appeared once the run was over — which is the one moment it is least useful, because the browser has closed and the page that broke is gone. Meanwhile the run was sitting right there, still showing it.The bundle is now written at the pause, from that page, so
list_failures,get_failure_screenshot,get_failure_a11yand the rest answer on the first call. Each record carriesrunState:pausedwhile the run is still held — the browser shows the failure, and a resumed run that fails again elsewhere re-takes the frame at the new failure — thenfinishedorabortedonce the run is over. The frame of a finished run stays the one taken at the failure: resuming continues with the next statement, so the page moves on while the verdict does not, and re-taking it at the end would show a page the failure never happened on.Also fixed: aborting a run while it was paused left its process alive until the 30-minute inspection TTL expired. The run now ends when it is aborted.
-
Spending and deleting wait for a person
A key of a customer account — an agent’s key, a signed-in machine’s, and a CI key issued before this release — can no longer rent a box, delete one or switch the Picker on by itself. The cloud records what was asked for and answers with a request instead; somebody approves or declines it in the cabinet, on the Approvals tab, and only a signed-in browser can — not the key that asked, and not the link in the email about it. Nothing is created and nothing is charged until that happens.
For the CLI this is a wait, not a failure.
box create,box destroyandbox picker onprint where to answer and how long the request lives, then carry on with what the cloud finally did and print exactly what they would have printed anyway. Ten minutes with no answer is exit code 75 — nothing was created and nothing was charged, run the command again. A decline is exit code 2.--yesdoes not skip it: the flag says the terminal need not ask, not that the account need not.Only the CI key of an operator account, one that unotest runs itself, acts with nobody watching. A pipeline that rents or deletes boxes with a customer’s key now waits for somebody to press Approve, or has to rent its box ahead of time.
-
box createwith a short balance points to the dashboard instead of starting a top-upThe cloud’s API for agents has no top-up: credit is added by a person, by card, under “Add credit” on the dashboard’s home page.
box createused to start a payment from the terminal when the balance did not cover the first hour, against an endpoint the service does not have, so on the real cloud it failed with a 404.Now it stops before renting anything and says what is short: the first hour’s price, the balance and the amount missing, and the dashboard page to add it on. It exits 75 — nothing created, nothing charged — and is run again once the credit is there. A key that cannot read the balance (the one
loginissues cannot) gets the price and the page, and no invented shortfall.--jsonprints{ "needs": "top-up", "firstHourCents", "balanceCents", "missingCents", "currency", "url" }, withnullfor a figure the key may not read.The
cloud_box_createMCP tool answers the same case as{ state: "top-up-needed" }with the same figures, instead of an error.Migration: the
--topup <eur>flag ofbox createis removed — top-up moved to the dashboard. A script that passes it is refused with the usage and exit 2; drop the flag, and add credit on the dashboard’s home page before renting. -
The cloud account as commands and as MCP tools:
box list,box logs,approvals, and ninecloud_*toolsAn agent could read a box it had been pointed at and nothing more: which boxes the account had, renting one and deleting one lived in a terminal, and the list of boxes appeared only inside an error message. The account is now reachable from both sides through one implementation, so a box reads the same whether a person or an agent asked.
New commands:
box list— every box on the account with its state, version and projects.--jsonprints the same objects thecloud_boxes_listtool returns.box logs [name] [--bootstrap|--updater]— the end of a box’s own log, without logging in to it. A box sends it on its next five-minute check, so the first call says it has been asked and when to run again, with exit 0.approvals [id]— what is waiting for a person on this account, or what became of one request. Answering stays in the cabinet: a terminal holding the key cannot approve its own request.
New MCP tools, outside the
coretoolset:cloud_boxes_list,cloud_box_status,cloud_box_create,cloud_box_destroy,cloud_box_access,cloud_box_update,cloud_box_logs,cloud_balance,cloud_approval_wait.cloud_box_accesswrites the fresh pair into the project the waybox accessdoes and answers with the names it wrote, never the values. They answer states an agent can act on instead of errors:not-logged-inwith the command that fixes it,approval-pendingwith the request to wait on,insufficient-scope, anddeclined,expiredorstill-waitingfrom the wait.While a rent or a delete waits for approval, the terminal now names it in the words the cabinet and the email use —
Rent a box "acme", size s (€0.09 per hour)— and prints a line when it is approved, not only when it is declined. The sentences live in@unotest/protocol(approvalActionText,approvalPrice), shared by every surface that shows a request.unotest-web --helpandbox --helplist the new commands, say which commands wait for approval, and give one table of exit codes.box <command> --helpnow prints that command’s own flags wherever--helpsits on the line, andapprovalsrefuses an option it does not know with exit 2.Compatibility: every call to the cloud now carries an
X-Unotest-Clientheader. The cloud refuses a call that would wait for approval when the header is missing — an older CLI could not read the request it would be handed — with426 client-too-oldand a message to update. Update@unotest/webbefore renting or deleting boxes with any key of a customer account, a CI key included. -
A config key the package does not know is refused, with the key you probably meant
chanel: "chrome"used to be dropped in silence: the run went ahead on the default channel, printed1 passedand exited 0, so the one setting you changed was the one setting that did not apply. Every section ofunotest/unotest.config.*is now checked by name, before the browser starts:e2e: config validation failed:unknown key "chanel" — did you mean "channel"?retry: unknown key "cuont" — did you mean "count"?A key nothing is close to gets the other answer, because it usually comes from a config written for a newer release than the installed package:
unknown key "telemetryEndpoint" — not a config key; check your@unotest/web version if it comes from a newer releaseThe config
unotest-web initwrites no longer offers aviewersection: the viewer’s port has always come fromUNOTEST_VIEWER_PORT, and the section was a suggestion the schema never had. A config generated by an earlier version needs that block removed.Migration. A config that carries a key this version does not know now fails instead of running: fix the spelling, remove the key, or install the release the key belongs to. Nothing else about the config changes — every known field keeps its name, its default and its merge behaviour, and a partially written section is still merged with the defaults key by key.
-
A step that takes minutes no longer disappears into the client’s background
Waiting on a slow page — a chat answer that takes four minutes to come back, a report that builds itself — used to be a call your agent never got an answer to: MCP clients stop waiting at about two minutes and carry on, and whatever the step replied after that was lost. The way round it was to cut every wait to under a hundred seconds and repeat the step.
Now a step still running after 90 seconds answers
{ status: "accepted", taskId }and keeps going: same timeout, same recording into the session. Collect it with the newexplore_step_result({ taskId })—running,donewith the reply the step would have given, orfailed. A batch of steps goes to the background as one task, for one id. ThedurationMsof a collected step is the whole step, from its start — there is no threshold to add back by hand.While a step is out there, the browser context says so instead of being damaged: another step,
explore_stopandclose_contextanswerbusyand name the task to wait for, andexplore_statelists what is pending. -
Signing in to a box with one environment opens its viewer straight away
A box configured with a single environment used to answer a sign-in with a screen holding one option and a button. There was nothing to choose, so the screen only stood between you and the viewer behind it.
Now that sign-in lands in the viewer. The Environments screen still appears whenever it has something to say — more than one environment to pick from, a box that has not been sent a test suite yet, or a licence in its grace period, whose warning lives there.
So that skipping the screen does not hide what it carried, the viewer’s session menu now offers box pages: one link back to the box’s own pages — environments, read tokens, and administration for those who have it. It appears when the box tells the viewer where they are, so a viewer and a box on different versions simply do without it.
-
take_wheel now points the session at the run it takes the wheel of
An agent debugging a paused run would call
take_wheel, take a screenshot, and get a picture of a completely different stand — whatever page the session had been recording on before. Nothing was wrong with the screenshot: the wheel only ever took the control token, while the browser stayed where it was, and the reply saidokeither way.take_wheelnow also attaches this session to the run’s browser, so the next screenshot, snapshot or step reads the page being debugged. Everyexplore_stepreply carriescontext—{kind: "run", runtimeId}or{kind: "own"}— so the source of a frame is stated rather than guessed. A session has exactly one browser:take_wheelandattach_debug_sessionpoint it at a run,explore_startandnew_contextat your own, andexplorationIdchooses recording and where a file is filed, never which browser a step runs on.release_wheelhands the token back and deliberately leaves the browser on the run.Behaviour change for anything parsing the reply:
take_wheelno longer always answersok. When it cannot join the browser it answersrecording_open(a live recording would be dropped — nothing is changed, not even the wheel),no_browser(the run has not published one yet) orno_page, each naming what to do next. -
Opening a box’s viewer while a suite is being installed no longer looks like a failure
Sending a new suite to a box replaces that environment’s viewer, and for the second or two that takes, anyone holding the page open got a bare
502— the status that tells a browser nothing and tells a script the box is broken.The box now says, while it is happening, that it is replacing that viewer. A browser gets a short page that comes back by itself once the new viewer answers; an agent reading the box with a token gets
503withRetry-Afterandunavailable, which is the box’s existing word for “wait and ask again”.A viewer that stopped for any other reason answers the same way but promises nothing, and the page does not reload itself: nobody is bringing it back, and saying otherwise would be worse than saying nothing.
-
A cloud that will not show the price list is quoted as such, not as free
box createandbox picker onquote the hourly price inside the question they ask. When the cloud refused the price list, the question was asked with no price in it at all — and silence there reads as “this is free”, with no way for the reader to learn that a price exists and they were not shown it.A refusal for want of a permission now says so:
price not visible to this key: it needs billing:read. On a current cloud the list is underread, which every key fromlogincarries — so this is what an OLDER cloud says to a newer client, which is exactly when a client must not go quiet about money. Every other reason stays silent, because a cloud older still has no price list at all and a command must not fail for want of a courtesy. -
API keys carry permissions
A key used to be all or nothing: any live key could do everything the API offers. Now every key carries a list of permissions — see the boxes, manage them, read the balance — and every route states the one it needs. A key without it gets
insufficient-scope, which is a 403 and not a 401: the credential is good, so signing in again would change nothing.Where the list comes from depends on the key. In the cabinet you tick the permissions when issuing a key for an agent. Signing a machine in asks for enough to see your boxes and work with them, never your money, and the approval page in the browser lets you narrow that before you approve — the key then has exactly what was left ticked.
loginandwhoamiprint what the key may do.Keys that already exist keep the permissions they had, with one exception: spending money and deleting with nobody watching. Only the CI key of an operator account, one that unotest runs itself, may do that. A CI key a customer holds from before this release works like an agent’s key: renting, deleting and switching the Picker on wait for a person to approve.
One visible change for a key that cannot read the balance:
whoamisays so on the balance line instead of failing, and signing in with such a key is still recognised as being signed in. -
A rental the account cannot pay is refused before anybody is asked to approve it
A key that may not spend by itself used to get an approval request for a box even when the balance could not cover the first hour: a person was asked to approve it, and the rental then failed at their yes. The money is now checked first, and such a create is answered
402 insufficient-balancewith nothing recorded and nobody written to. The check at approval stays — a balance can move while a request waits — and a request that fails there says so in words: the price of the hour and where to add credit.Every
insufficient-balancecarriestopupUrl, the dashboard page where a person adds credit. A key that may read the account’s money also getsfirstHourCents,balanceCents,missingCentsandcurrency; any other key gets no figures.box createprints the cloud’s own figures and link when the refusal carries them, instead of counting them again. -
A refused config says which field is wrong, and
channeltakes the same words everywhereAny bad value in
unotest/unotest.config.mjsused to come back as one line —config validation failed— with no field, no value and no list of what would have been accepted, though all three were known. Now the refusal names them:config validation failed:channel: expected one of "chrome" / "msedge" / "chrome-beta" / "chromium" / "bundled" / null, got "bundeld"defaultTimeoutMs: expected number, got stringAnd
channel: "bundled"is accepted.UNOTEST_BROWSER_CHANNELhas always taken that word — the.envtemplate says the variable merely overrideschannel— while the config refused it. Both inputs now share one vocabulary, andbundledmeans what it has always meant: Playwright’s own Chromium, the default. -
An empty id is refused instead of quietly changing what a tool does
explore_stepandexplore_stepstreatedexplorationId: ""as “run this without recording”: the step went through, the reply said it had succeeded, and nothing reached the session log. The agent found out atexplore_stop, with an empty recording and the work already done. The same held forexplore_record, and forruntimeId: ""in the debugger tools, where an empty value silently meant “the session you attached to”.An empty id is now an input error naming the field, exactly as the neighbouring tools have always answered. Leaving the key out is still how you ask for the ad-hoc mode — absence is the mode, emptiness is a typo.
-
The MCP server reports the version it actually is
initializeansweredserverInfowith a hard-coded0.1.0whatever version of the package was running, while--versionprinted the real number. An editor showed the wrong version in its server list, and nobody checking a session could tell a server started from npm apart from one started out of a working copy.Both surfaces now read the package’s own manifest through one helper, so they cannot drift apart again. A manifest that cannot be read is an error naming the fault, not a silent
0.0.0— an invented version reads as a real answer in a protocol reply and in a support transcript. -
Existing boxes install the five-minute update check themselves, and report a finished update in the tick that applied it
A box rented before the five-minute check existed only had the hourly timer: pressing Update in the cabinet could take up to an hour and a half to reach it. The updater now installs that pair of systemd units itself when the box does not have them — exactly those two, only when the file is absent, and never replacing a unit an operator edited.
A finished update is also reported in the tick that applied it, instead of in the next one, so the cabinet card and
box statusstop showing “updating” for a box that is already done.Both start working with the update after this one. The move onto this version is still carried out by the updater a box runs today: it arrives on the hourly timer — up to an hour and a half — and that first “applied” still comes with the next hourly tick. From there on the new updater is in charge, a pressed Update lands within minutes, and the cabinet shows it as done as soon as it is.
-
The viewer finishes writing its run index before it exits
On boot, and whenever a run finishes or the environment switches, the viewer rebuilds the derived index it serves history from. Those writes are deliberately not waited for while the server runs — a history months deep must not hold the UI back — but they were not waited for on the way down either. Stopping the viewer returned while files were still landing under the run directory: a directory the caller believed had been released kept growing for a fraction of a second, and anything that removed or archived it right after the viewer stopped could fail or capture a half-written index.
Shutdown now waits for the index writes that are still in flight, so a stopped viewer is a viewer that has finished writing. A rebuild that takes longer makes the stop take longer, which is the honest cost of not leaving half a file behind.
-
Updated dependencies [6fc8140]
-
Updated dependencies [4582091]
-
Updated dependencies [dae29a2]
-
Updated dependencies [82d5920]
-
Updated dependencies [74a1f9d]
- @unotest/core@0.43.0
- @unotest/protocol@0.43.0
- @unotest/viewer@0.43.0
- @unotest/dsl@0.43.0
- @unotest/grounder-client@0.43.0
0.42.0 — 2026-09-24
-
box statusnow reports the box’s version: what it runs, what its channel offers and where it is between the two.A box never updates itself. A new release is offered, not installed — the box keeps running the version it is on until somebody presses Update in the cabinet, or an agent calls
POST /api/v1/boxes/<name>/updatewith the version it was offered.box statusprints the offer and says where the decision is made; a cloud that reports no versions leaves the CLI silent about them rather than guessing.Migration: boxes that used to install a new release on their own stop doing so with this release. To move a box to a newer version, press Update on its card in the cabinet, or call the endpoint above.
-
Start the browser with a profile and extra arguments — what testing a browser extension needs
A new
launchblock inunotest.config:launch.args— extra browser command-line arguments, e.g.--load-extension=./ext(extensions load in Chromium only);launch.userDataDir— a browser profile directory. The browser then starts persistent: cookies, storage and installed extensions live in the profile.UNOTEST_USER_DATA_DIRoverrides it for a machine.
On a profile there is no isolation between scenarios: the browser has one context, and every scenario of a run gets that same context. Each such run says so in its log.
Refused with a message instead of being ignored: a profile together with
storageState, a profile in a collection of more than one worker,launchon a box (its runs take no launch settings from the suite), and an extension (--load-extension) on a headless run of bundled Chromium, whose headless shell ignores extensions without an error. For headless runs with an extension — CI — set the newchannel: "chromium"(Chromium’s new headless mode, which loads them), orUNOTEST_BROWSER_CHANNEL=chromium; locally, run headed. Withoutlaunchnothing changes. -
box envsandbox runshow how a box installed the suite: how long it took, and whether the packages came from the registry or the box’s own npm cache.acme/test main@1a2b3c4installed in 12.3s from this box's cache (252 of 252 from cache)The source is measured by the box, not read out of npm’s output: before an install it compares what the lockfile names against what its cache already holds. On a fast registry a cold install and a warm one take the same number of seconds, so the duration alone never said which had happened — the counts are printed with the word so it can be checked.
box run <id>prints the same line for the bundle that run came from, while the environment still serves it.A box older than this contract says nothing about installs, and the output is exactly what it was.
-
Recording tools refuse a misspelled field instead of silently not recording
explore_stepwithexploration_id(instead ofexplorationId) used to run the step ad hoc, answer success and record nothing — the empty session only showed up atexplore_stop. Everyexplore_*tool,generate_dsl_from_explorationandsave_exploration_as_testnow refuse a field they do not declare, including inside anexplore_stepsstep, and the error names the field they accept (exploration_id (did you mean explorationId?)).Ad-hoc execution is unchanged: omit
explorationId, and the step’s reply saysrecorded: false.Migration: a client that sent extra, ignored fields to these tools now gets an input error listing the accepted fields — drop the extra ones.
-
box statussays until when the balance keeps a box, not when its paid hour endsbox statusused to printpaid until— the end of the hour already bought. With credit left that read as “switched off within the hour”. It now printsfunded until <date> UTC: the moment the box is deleted if nobody tops up, computed by the cloud from the balance and every box on the account, each at its own rate. A failed box prints neither.The cloud API adds
fundedUntilto each billed box and toGET /balance(absent when nothing is billed);paidUntilstays, and means only the end of the paid hour. The cabinet’s box card and balance show the same date, and the low-balance letter names it.Migration:
box statusno longer prints apaid untilline. A script that read it should takefundedUntilfrombox status --jsonfor the deadline, orpaidUntilthere if it meant the end of the paid hour. -
The viewer on a box has no terminal panel instead of a dead one
A box’s viewer could never start its docked terminal — the PTY addon is not in the box image — yet it showed the panel and the button. A host that cannot give the terminal a PTY now says so with
UNOTEST_VIEWER_TERMINAL=off, and the viewer then leaves the terminal out entirely: no panel, no button, no terminal socket, for every session. A box sets it for its viewers.Nothing changes locally: the terminal is on by default, and a machine missing the addon still reports it when the panel starts.
-
A pressed Update reaches the box within minutes, and a failed one is not retried on its own
A box now checks every five minutes whether it has been asked to move to another version, instead of once an hour, so pressing Update in the cabinet (or calling the update endpoint) takes effect within minutes.
The box also reports back what it runs and how its last update ended — applied, rolled back, refused or failed, and why — which is what the cabinet and
box statusshow.An update that did not succeed is not tried again by the box itself: rolling back takes the box’s stack down and up again, and repeating that unasked would be an automatic update. It is tried again when Update is pressed again.
-
A reinstall no longer empties the tree a viewer is serving
A bundle whose tree already has packages is reinstalled in a copy while the live tree waits aside; the viewer keeps serving it. A failed reinstall leaves the live
node_modulesuntouched instead of leaving the viewer answering 404 until an install succeeds. A tree a daemon left aside when it died mid-install is put back by the next housekeeping sweep. -
A box that names no environments is told so, instead of being sent a name it cannot match.
box runs,box queue,box runand the rest used to pass the name on to a box with an empty list, which answered 404 and advice about the form of a name — spelling advice to somebody whose box has nothing to spell. They now say what is true: the box names no environments yet, a run ordered a moment ago may not be listed straight away, and a box that goes on naming none has none configured. Agents get the same sentence through thebox_*MCP tools.The command exits 2, like the other refusals of an environment name (was 1).
Migration: a script that checked for exit code 1 on this refusal should check for 2.
bundle push --runis unchanged: it uploads and lets the box decide, because a box older than this contract names nothing until its first run. What changed there is the hint after the box’s own refusal, which no longer points back at the list that was empty. -
A box lists its environments before the first suite arrives
The route table names every configured environment; one without a bundle is listed without an address.
/_guard/api/envsanswers with it at once (note: "no suite yet"), so an environment name can be checked before the first push, and opening it in a browser shows a page with the commands that send a suite instead of a bare 503.Migration: the environment list a read token gets (
box envs, thebox_envsMCP tool) now also names environments that have no suite yet, with the noteno suite yet. Reading one of them answersunavailableuntil a suite is pushed. -
Updated dependencies [c99f849]
- @unotest/viewer@0.42.0
- @unotest/core@0.42.0
- @unotest/dsl@0.42.0
- @unotest/grounder-client@0.42.0
- @unotest/protocol@0.42.0
0.41.0 — 2026-09-21
-
env push | set | rm: the box and the token come from the project’s own filesThe values commands now resolve the box address and the project token exactly the way
bundle pushdoes — a flag, thenUNOTEST_BOX_URL/UNOTEST_BOX_TOKEN, thenunotest/.envandunotest/.secrets, wherebox createandbox accesswrote them. A project set up with either of those commands no longer answers “no box address” toenv pushwhilebundle pushworks, and the two exports stop being typed in front of every line.env set <env> <NAME> <value>takes the value as an argument. Stdin still works when there is none, and stays the way to pass a credential: an argument is in the shell’s history.env push --dry-runnow prints what the overlays resolved to, quoted, so a value that kept its quotes or picked up a trailing space is visible before it reaches a box.--dry-run --jsoncarries the same undervalues. A secret’s value is still never printed: where it would be, the mask used everywhere else (‹secret:NAME›) stands instead.A push whose box lacks some of the suite’s declared values no longer claims the run “will stop at the first step that reads” one of them: the names are what the suite DECLARES, and a declared name no scenario reads costs a run nothing.
-
A box rented from the dashboard now says how to send it a suite
A box rented from the CLI arrives with the suite already on it:
box createpushes it and prints the command that runs it. A box rented from the dashboard arrives empty, and the card said nothing about that. It showed the address, the login and the password, and left the customer with a machine that answers, an empty viewer, and no idea that anything else was needed. Everything they had to do next lived in an email and on the box’s own empty screen.The card of a ready box now carries the three commands that take a project from here to a first run: sign in, take the box’s tokens, push the suite. Each is a field that can be copied in one click, with the box’s own name already in it. The environment name is filled in when the box has exactly one, and left as
<env>when it has several — picking one for the customer would send the run to a site they did not mean, and nothing would refuse it, because the name is perfectly valid. The collection is named too: a box runs a collection, not a single scenario, and a push that names none is turned away.Those commands are now written in one place and printed by both the card and the “your box is ready” letter. They used to be the letter’s alone, and a second copy on the card would have been a second piece of advice the first time either was edited.
Once a project has taken the box’s tokens, the card stops repeating the first two commands and asks only for the push. That is the one thing the cloud can honestly know about the far end of a box: the suite travels from the customer’s machine straight to it, and nothing reports back. So the card says nothing at all about runs — not “no runs yet”, not “0 runs”. It cannot tell a box that has been testing for a week from one nobody has touched, and a page that says otherwise is inventing it.
-
An empty list of environments says which kind of empty it is
box envson a box that listed nothing said “This box serves no environments you can read.” — a sentence about the token, on a list that is not filtered by the token at all. People whose box was simply new went looking for a stronger credential, and the box’s own journal had nothing to say either way.The list now talks about the box:
This box serves no environments yet.An environment appears once a suite has been pushed to it.A token that really is not let in still answers in its own words, with its own exit code, so the two are told apart by reading them.
Behind the list, the box now records which of five things happened when it looked for its route table: there is no table yet, the table cannot be read, it is not JSON, its shape is wrong, or it is honestly empty. Each is one line in the box’s journal, naming the file and never its contents — the table holds internal addresses. A table that loaded says how many environments it found, because “none” and “three” were otherwise the same silence.
Two fixes come with it. A line belongs to a state rather than to a request, so a busy box writes one line and not one per proxied asset. And a table that failed to parse is read again a second later instead of being remembered as an empty box until its timestamp changes — which is why “I tried again and it was the same” used to be true.
An empty list stays a successful answer,
200with no entries: a box with nothing on it yet is a normal box, not a service that is down. -
A box installs a suite whose
npm ciis slow, and stops retrying in a loopA box could not install a suite whose dependency install went quiet for more than five minutes — which is what a heavy lockfile does. The install died on the 301st second, reported the runner as unavailable, and the box started the whole install again in the second it failed. That repeated for as long as the box existed: nothing ever ran on it, and the message blamed the network rather than naming the timeout that caused it.
Three things change. The daemon no longer puts a ceiling of its own on reading a run’s output, so silence is not a failure; the run’s deadline remains the only clock that ends a run, and a backstop behind it covers a connection that stops answering altogether. A run whose output stops without an exit now takes its container with it — abandoned containers used to fill the box’s capacity while healthy environments waited for a slot. And a failed install is followed by a pause that grows: 1, 2, 5, 15 and 30 minutes, capped there, never “never”. Asking for that bundle yourself — a switch or a repair — is tried at once and clears the count, as does an install that succeeds, and the log says when the box will try next by itself.
The pause is the box’s own, never yours: a run you order by hand, and a run a push to your repository orders, install the bundle at once and clear the count. Only the box’s unattended retry waits.
-
Installs on a box share one npm cache, and a suite can never reach it
Until now every install on a box started with an empty npm cache: the container that runs
npm ciwas thrown away with everything it had downloaded, so the second box of the same account — and the same box after a restart — paid the full download again for dependencies it had already had twice.Boxes now keep one npm cache and every install uses it. The second install of a dependency is taken from disk instead of the network; how much time that saves depends on your lockfile and your registry, and on a fast registry it can be none at all — the promise here is only that what has already been downloaded is not downloaded again.
The cache is mounted into the container that installs, and into no other. A container running your suite gets no access to it under any name: the cache is the directory that every later install of every project on that box unpacks, and a test that could write into it would be writing into the next install. That boundary is asserted as a pair — the install has it, the run does not — rather than as two separate claims.
A substituted cache entry is never installed. npm verifies an entry’s checksum when it READS from the cache, not only when it writes one: an entry whose content does not match is reported as corrupted and fetched again (verified on the box’s own image, npm 11.16.0 — with a registry reachable the correct package is installed; without one the install fails rather than using the substitute).
The box keeps the cache’s size in hand. It is measured on each housekeeping sweep and, over the ceiling (1 GB by default, and an operator can raise it or turn it off), emptied whole with a line in the log — losing a cache costs a download and nothing else.
-
Ordering a run in an environment now decides what that environment serves
Pushing a suite and asking for a run in a named environment is a statement about that environment: it should serve the bundle you just sent. Until now the box decided that separately, by matching the bundle’s branch — so a suite packed outside a git repository, which is what
initproduces in a clean directory, matched nothing. The run installed, executed and passed, while the environment kept pointing at nothing: its viewer was never started, the guard was given no route for it,box envslisted nothing, andbox runs --env <name>answered that the box does not serve that environment. Four different answers to one question, after the client had been told the suite was accepted and queued.An explicit run request now selects the bundle for the environment, through the same path a selection made in the viewer takes, recorded against the token that pushed — so it survives a restart and the box does not silently move the environment back on its next sweep. Branch matching keeps deciding exactly one thing, the one it is right for: which bundle the environment’s SCHEDULES run.
An environment that may not be switched (
allowSwitch: false) keeps serving what its own branch deploys, and the answer now says so instead of leaving it to be found out: the run goes ahead there, and the acceptance names the branch the environment stays on — so it is clear the result belongs to that environment’s journal rather than to the screen the environment shows.The one case with nothing to show is refused out loud. An environment that may not be switched and serves nothing yet has no viewer at all, so a run ordered there could only end where this bug ended: executed, green and invisible. The refusal names the branch to deploy to instead.
Migration note: that refusal is a new error code on the run endpoint. A CLI older than this release does not know it and will report the box’s answer as “not a box” rather than as a refusal — the request is still refused, and nothing runs. Upgrade the CLI to see the message. The acceptance note is a new field on an otherwise unchanged answer, and an older CLI ignores it.
-
The box’s own screen gives the same first-run command as everything else
A freshly rented box shows two commands: one that points the project at the box, one that sends the suite and starts it running. The second was printed without
--collection, and a box runs a collection or a schedule — never a scenario by name. Copied exactly as shown, into a project straight out ofinit, it came back refused with nothing to run: an answer about a project that had done nothing wrong, from the first screen a new box owner reads.The screen now prints the whole command, and says where collections live:
npx @unotest/web bundle push --run --env ENVIRONMENT --collection examplesThe command is no longer written out on each surface that shows it. The box’s screen, the last line of
box create, and the collectioninitwrites all come from one place, so the next flag to change changes in one edit rather than in three.examplesis the collectioninitcreates, and the screen says so — it is an example of a name, not a claim about your project.The cabinet half is unchanged in behaviour: with exactly one environment and one collection it still prints a command that can be pasted as is, and placeholders where it cannot know.
-
unotest/.secretsis now created owner-only (0600)The file holds the box push and read tokens, and was created with the default umask — on a shared machine any other user could read it. It is now narrow from the moment it is created, and a file left wider by an earlier version is narrowed the next time a command writes to it, with one line saying what changed.
unotest/.envis configuration rather than secrets and keeps the width your project gave it. -
Exploration tools refuse an unknown
{{NAME}}instead of typing itA
{{NAME}}the session could not resolve was passed through as text, soexplore_run_flow {args:["admin","{{PASSWORD}}"]}typed the placeholder into the password field and the page answered with a normal login failure. It now fails with a message that names the variable, says where to declare it, and lists the names the session does have — so the value never has to be pasted into the tool call by hand.A bare
SHOUTY_NAMEis unchanged: a reference where it is registered, a literal otherwise. A variable declared with an empty value still substitutes as empty and is reported invariableWarnings; a name declared as a secret with an empty value is refused with its own message. -
Editing a secret in the viewer’s variables panel keeps
unotest/.secretsowner-onlyThe panel writes a file by creating a temporary one beside it and renaming it into place, and a rename replaces the target together with its permissions. So a secrets file that was owner-only became world-readable again after a single edit in the panel, with the box tokens inside it, and adding the first secret to a project created the file wide in the first place.
The temporary file is now created owner-only when the layer being written holds secrets, which also narrows a file an earlier version left wide. A leftover temporary file from an interrupted write is removed rather than reused, so it cannot pass its own permissions on.
unotest/.envis configuration rather than secrets and keeps the width your project gave it. On Windows there are no POSIX permission bits and nothing is narrowed there. -
Updated dependencies [436b683]
-
Updated dependencies [a1b9eac]
- @unotest/protocol@0.41.0
- @unotest/viewer@0.41.0
- @unotest/core@0.41.0
- @unotest/dsl@0.41.0
- @unotest/grounder-client@0.41.0
0.40.0 — 2026-09-19
-
A box that finishes coming up tells the person who rented it
Renting a box takes minutes, and until now it ended in silence. Somebody who closed the tab while the machine was being built learned nothing: not that it was up, not where it answers, not what to do next. A rental that says nothing looks like a rental that did not work.
The service now sends one message, at the moment the box becomes ready, to the address on the account. It names the box’s address, points at the card on the dashboard where the login and password stay readable for as long as the box lives, and gives the three commands that put a suite on it:
npx @unotest/web loginnpx @unotest/web box access <box>npx @unotest/web bundle push --run --env <environment> --collection examplesThe last one names a collection because a box runs a collection or a schedule, never a scenario by name, and a project straight out of
inithas no schedule: without the flag the run is refused before it starts.examplesis whatinitwrites, and the message says to name your own instead where you have one.The environment is filled in only when the box has one. A box can have several —
box createsends every environment the project has — and a command naming the first of them reads as a command naming the only one, which would send a run somewhere the reader did not choose. With several, the command carries<env>and the message says where the names are written. Same rule as the CLI’s own next step, so the two do not answer one question differently.The message carries no password. The break-glass password of a box is on its card, which can be looked at again; a copy in a mailbox is one nobody can withdraw. The type the message is built from has no field for it, so it cannot be added back by accident.
It is sent once. The guard is the transition itself, taken inside the write lock that makes a box ready, so a resumed build, the billing tick and a read of the card all find the box already ready and announce nothing. A mail server that is down is logged and does not touch the rental: the customer paid for a machine, not for mail.
-
A box deleted by hand tells its owner too, and the letter says which deletion happened
A box could go two ways, and only one of them said anything. When the balance stopped covering a box’s next hour, the billing tick deleted it and wrote to the owner. When a person deleted one — from the dashboard, the CLI or the REST API — the machine was released, the record was settled, and nobody was told.
That silence is only harmless while the person who presses delete and the person who pays for the account are the same person. An account can have several people on it, an API key, and a dashboard open where it should not be. The one deletion a customer most needs to hear about is the one they did not make, and it was the one that arrived as nothing at all.
Every deletion now ends in the same letter, from the same place, and the letter’s first paragraph says how the box went: the balance could not cover the next hour, or somebody with access to the account asked for it. The second version names no sum — what the balance happened to be when a person pressed delete explains nothing about why the box is gone, and a figure printed beside a deletion reads as its cause. It points at the dashboard instead, for a reader whose next question is who else has access. Everything after that first paragraph is one text for both: the machine and what was stored on it are gone, the scenarios in the customer’s own repository are not, and the name is never reused.
One letter per deletion, not per caller. The transition to deleted is claimed under the same write lock that settles the record, and only the caller that made it writes. A person and the billing tick arriving at one box in the same moment therefore send one letter between them — previously they could each send their own.
A box the fleet has no record of is now settled rather than retried forever. The tick used to treat “no such box” from the fleet as a failure to delete, leaving the record live and billable and trying again every hour; it now ends the way it does when a person deletes such a box, which is that there is nothing to release and the record is closed.
The journal says which way a box went, on both paths. Until now a deletion by hand left no line at all, and the only way to tell one from an eviction was the absence of the tick’s own lines.
A mail server that is down still never holds a deletion up: the machine is released and the record settled first, and a failed send is a line in the log.
-
box accesswithout a name lists the boxes the account already has, instead of advising a second oneRun in a directory that does not know a box — a fresh checkout, a second project —
box access,box status,box destroyandbox pickerrefused with three things to try, and the only one anybody can follow without thinking wasbox create. For somebody who already rents a box, following it rents and pays for another.The refusal now asks the account first. With one box it names it and gives the command back ready to run:
no box named and none configured — this account has one box,"larch": run `box access larch`With several it lists them and asks which. Only an account with no boxes at all is still told to create one, and a cloud that refused or could not be reached is told the same as before — a cloud that did not answer is never read as “you have no boxes”.
-
Reading a box takes the same environment name that ordered the run
bundle push --run --env prodhas always taken the bare environment name and let the box fill the project in from the token. The commands and MCP tools that read the results back wanted the fullacme/prod, and a bare name came back as a 404 that talked about the environment rather than about its form:✗ this box does not serve 'prod'So the name that worked in one command failed in the next, and nothing on screen said why.
Both halves now take both spellings.
box runs,box run,box queueandbox screenshot— andbox_runs,box_run,box_run_download,box_queue,box_screenshot— translate the name against the box’s own list before the request, exactly the listbox envsprints. A bare name only one project on the box has is sent as that project’s full name; a bare name several projects share is refused before anything is asked, naming them:✗ box runs: more than one project on this box has an environmentcalled "test" (acme/test, beta/test) — name the one you mean infull.A name the box serves in neither form is refused the same way, listing what it does serve. Where the list cannot be had — a box that did not answer, a refused token, an empty list, which does not prove there are no environments — the name goes as it was typed and the box decides, exactly as before; its refusal now names the form as well as the command that lists the names.
-
bundle pushthat refuses leaves nothing behind in the projectThe environment check now stands in front of the packing step, not only in front of the upload. Packing writes: a suite that has no
package-lock.jsonyet gets one, in your repository, next to your scenarios. So a push refused for naming an environment the box does not have still ended with a file that was not there before the command — the refusal cost something after all, and the nextgit statusshowed it.The order is now: the flags, the box address, the token, the environment, and only then the suite is packed. Nothing that can be refused happens after the first write.
What was already right stays:
--dry-runstill packs and still prints its bundle id, without asking for a box. The refusals about a missing address and a missing token answer in the same words and with the same exit codes as before — they simply answer sooner, before the lockfile is touched. A push that goes through writes the lockfile exactly as it always did. -
bundle push --runchecks the environment name before uploading, and names the environments itselfPushing with an environment name the box does not have uploaded the whole bundle first, and only then came back with a refusal — so the last thing on screen was “uploaded”, the wasted upload looked like success, and the refusal read like a problem with the box. The advice that followed was to ask the operator which environments exist; on a rented box the customer is the operator.
The name is now checked against the box’s own list first, with the read token
box envsuses. An unknown name is refused before anything is uploaded, and the refusal names what the box does serve:✗ no environment named "staging" on https://larch.box.unotest.com— nothing was uploaded.it serves acme/test, acme/prod — push into one of them with--env testBoth spellings the box itself gives are accepted,
acme/testand the baretest. Where the list cannot be had — no read token, a box that did not answer, an empty list, which does not prove there are none — the push goes ahead exactly as before and the box decides. Refusals that still come from the box now point atbox envsinstead of an operator. -
A fresh project can start a remote run:
initwrites a collection, and the refusal says what a box runsA box runs a collection or a schedule; the local runner takes a scenario by name. Both are true, and nothing said they differ — so a project created by
init, which wrote neither a collection nor a schedule, could not start a remote run at all. The command the box’s own screen offers came back with “nothing to run”, and the refusal named the state without naming the difference or anything to try.initnow writesunotest/e2e/_collections/examples.yaml, naming the examples it already creates. They hit the public playground, so the first remote run passes before the box can reach the customer’s own application:npx @unotest/web bundle push --run --env <environment> --collection examplesA run asked for without a collection is still refused — that has not changed — but the refusal now says that a box runs a collection or a schedule rather than a scenario by name, and what to pass instead.
-
The box’s screens fit a phone: the header wraps instead of running off the side
Four things sit in the header of every guard page — the box’s own address, who is looking, the link to the other half of the guard, and the way out. On a screen 320 px wide they did not fit on one line, and what did not fit was pushed past the right edge, taking the page’s width with it. The whole screen then scrolled sideways, and the control left outside was the one that signs a viewer out: a box could not be left from a phone.
The header now wraps. On a narrow screen it takes a second row, and every control stays reachable; a long box address or a long directory name wraps with it instead of widening the row. Nothing changes on a wide screen — the header is one row of the same height it always was.
-
New box keeps the environment you named, so a box can be rented again
Renting a box from the cabinet was impossible for anyone who used the Environment field the way it asks to be used. Typing
prodand pressing Enter turned the name into a tag, as it should; submitting the form then answered “Name what runs at this address — prod, staging, pr-42.” and came back with the field empty and the tag gone. The form was refusing the very name it had just been given, and there was no way through it: the only path to a first box through the cabinet was closed.The tag the browser makes now travels under the name the server reads environments back from. It had been travelling under the name of the text box it was typed in, so a row with one tag arrived with an address and no names at all, and the refusal about names was the server honestly reporting what it received.
Two things beside the fix, so this class of failure is harder to repeat. The name a tag posts under is stated once, in the form, and the browser reads it from there rather than spelling it out a second time. And the reader on the server now accepts either half of the field repeated — two tags on one address have always arrived as a repeated field, and a reader that expects a single value drops a repeated one whole rather than in part.
A form submitted with scripting off is unchanged: the typed name still posts on its own, one name per submit.
-
picker offsays how long the withdrawal takes, as one numberSwitching the Picker off — a box’s with
box picker off, a project’s withpicker off— withdraws its token through a revocation list that two services read on their own schedules: the cloud rewrites the list, the grounding service re-reads it. The command used to say only that the token stops working “once the list is refreshed”, because the CLI knew neither interval and half a figure read as the whole would have been wrong.The cloud now adds both halves and hands the sum over, and the command says it:
✓ The Picker token for this box is revoked — the grounder stopsaccepting it within about 2 minutes, once it refreshes itsrevocation listThe dashboard’s own notices after switching off say the same figure. When the cloud cannot name it — the grounding service has not answered yet — the sentence stays as it was, with no number: a default would be a promise nothing keeps.
-
bundle push --run --envnow names an environment the box will accept, and refuses one it cannot.A box lists its environments as
<project>/<environment>, and that is the form its viewer API and everyboxsubcommand take. The door that orders runs takes the bare environment instead and fills the project in from the token that pushed. The pre-upload check accepted both forms but carried whichever was typed straight through, and a refusal advised the listed one — so a push that followed our own advice was refused by the box after the bundle had already been uploaded.Both forms are still accepted, with one exception named below. Whichever is given, the run is now ordered under the name the box runs, and a refusal advises that same name.
On a box that serves several projects, a listed name is translated only while the bare name it becomes belongs to one of them. Where two projects share an environment name, the run would be ordered inside whichever project your token belongs to — not necessarily the one you named — so the push is refused before the upload instead, and the refusal names both:
✗ "beta/prod" cannot be ordered by that name onhttps://larch.box.unotest.com — nothing was uploaded.acme/prod and beta/prod are both called "prod", and a run isordered by the bare name inside the project your token belongsto — push with --env prod if that project is yoursA bare name several projects share is left as it was typed: that is the form the box expects, and the token is what says which project it means.
None of this reaches a box that has not named an environment yet. A box with nothing to list answers with an empty list, which does not prove it serves nothing, so the push goes ahead and the box decides — exactly as before. Until a suite has been pushed to it once, a wrong name is still answered by the box after the upload, not by this check.
-
The New box dialog closes again after the form has refused something
A dialog in the dashboard could be opened two ways and closed only one. The Create box button opens it with a link to
#new-box, and the × and the veil close it by dropping that target again. A form the server refused comes back open too — the refusal has to be visible — but that copy is opened by a class in the served markup, and a link to#cannot take a class away. So anyone who filled the form in, got a refusal, and reached for the × found the dialog nailed open until they reloaded the page.Closing now undoes whatever opened it. The dialog the button opened still closes on the spot, with no request. The dialog a refusal opened closes by going back to the boxes page — which has no refusal to keep it open — and the × and the veil both carry that address. No script is involved in either case: the dashboard is where boxes are bought, and it works with scripting off.
Closing after a refusal drops what was typed. That is the intended reading of closing a form, and a draft that reappeared at the next click would be the bigger surprise.
- @unotest/viewer@0.40.0
- @unotest/core@0.40.0
- @unotest/dsl@0.40.0
- @unotest/grounder-client@0.40.0
- @unotest/protocol@0.40.0
0.39.0 — 2026-09-18
-
picker on|off: connect a project to the account’s Picker, with no boxAn account can have the Picker on without renting a box.
npx @unotest/web picker onfetches this project’s own access and writes the three grounder keys — mode and address intounotest/.env, the token intounotest/.secrets, with.gitignoreguarded — so an agent grounds intent locators remotely from the next restart.picker offgives that access back and clears the three keys, but only when they name the account’s grounder: a project pointing at a grounder of its own keeps it and is told so.The command states the account’s monthly ceiling before anything is spent. There is no hourly charge for a Picker without a box, so the ceiling is the price; the figure comes from the service, which takes it from the grounder that enforces it. A service that has no figure yet says so rather than leaving the silence to read as “no limit”.
picker offleaves the Picker ON for the account — the switch that turns it off for everything, and stops it counting against your monthly ceiling, is in the cabinet. A box’s own Picker is unchanged and staysbox picker on|off [name]. -
Switching the Picker off takes back the access it granted
Until now switching off changed what happens next: no further token was issued. The token already on a machine kept working until its thirty days ran out — so a button that said “off” left access on, and the spend it allows was ours to pay.
Each issued grant is now remembered by its id, and switching off hands those ids back: one grant when a project runs
picker off, every grant of the account when it is switched off in the cabinet. A project giving back its own grant leaves the other machines of the same account working.Access stops within the time it takes the list of withdrawn grants to reach the grounder, not instantly — the commands say so, and the interval comes from the service’s own configuration. Giving back a grant that has already been taken back, or one whose term has run out, is a success that says nothing happened rather than an error.
-
The Picker can be switched on for an account that rents no box
Until now the Picker was a property of a box: a flag on the box row, a surcharge counted per box, a token naming the box it was minted for. An account without a box had nowhere for any of that to live, so there was nothing to switch on and nothing to check before handing a token out.
An account now has a Picker state of its own — on, off, or never switched — and a token is issued against it. The token names the account and nothing else, so what it spends is counted under the customer who asked for it, and it is refused before it is signed when the Picker is off, when this service has no grounder to send anyone to, or when the account has already spent its month.
There is no hourly rate for the Picker without a box. What bounds the spend is the monthly ceiling, and a token lasts thirty days.
-
The screen on a box with no suite names the access step before the push
That screen named one command:
npx @unotest/web bundle push --run --env ENVIRONMENTIn a project that has just been created, the command refuses with
no box addressbefore it does anything: the project holds neither the box’s address nor its tokens until it is given them. A reader who followed the screen from a fresh project met that refusal instead of a run.The screen now names both steps, in the order they have to happen:
npx @unotest/web box access larchnpx @unotest/web bundle push --run --env ENVIRONMENTThe box’s own name is filled in when its licence carries one.
box accesswith no name refuses, and its refusal offers to create a box — to somebody who is reading this screen on the box they already own. Where the name is not known, the screen prints a placeholder and says that the name is the first part of the address in the browser’s address bar. -
Every time the cabinet prints now says which zone it is in
A box card read “Paid until 2026-09-17 23:39”. Seen in the first hour of the 18th, from a zone ahead of UTC, that says the paid hour ran out yesterday. It had not: the row is UTC and the reader is not, and the page said nothing about which. The ledger table did name UTC — in a column heading — so the two screens disagreed about whether an instant needs its zone, and every instant printed outside a table had none.
The zone is now part of the instant itself, written once where both of the cabinet’s time formats take it from, so a screen added tomorrow cannot forget it. The ledger’s heading no longer repeats it.
What it does not do: guess the reader’s zone. The pages carry no script and this service has no setting to read, so naming the zone we print in is the honest answer rather than showing a local time nobody asked us to compute.
-
Revoking a key no longer promises that the machine stops at once
The cabinet said that revoking a machine’s key means “that machine stops working immediately”. It does not. Revoking marks the key as revoked, and the box access token pairs that machine already holds keep opening the box’s doors until they expire — up to thirty days. Somebody whose laptop was lost read “immediately” and considered the matter closed, which is the larger harm.
The machines table and the revoke confirmation now say what actually happens: the machine gets no new tokens with that key, it cannot sign in again and cannot mint a fresh box access pair, and the pairs it already holds keep working until they expire. What ends that access sooner is named as a fact rather than offered as a step — destroying a box withdraws its pairs, and a repeat
box accessreplaces a pair only from a machine with the same host name, which is rarely something the owner of a lost laptop can do.Reissuing the CI key does stop that key immediately, and that half of the hint stays; it now adds that box access pairs already minted with the key outlive it.
Revoking those pairs along with the key is a separate change, still to come.
- @unotest/viewer@0.39.0
- @unotest/core@0.39.0
- @unotest/dsl@0.39.0
- @unotest/grounder-client@0.39.0
- @unotest/protocol@0.39.0
0.38.0 — 2026-09-17
-
Signing in to a box explains itself. The box’s first page now says Sign in to this box and tells you where the user name and password are: on the box’s card in your dashboard, as Login and Password. Someone who did not rent the box is told to ask whoever runs it. When a box offers several ways in, the identity provider comes first and the local account’s button reads Box account.
-
A new box says what is missing. A freshly rented box no longer reports “No environment is being served yet”. It explains that a box serves an environment once a bundle has been pushed to it, and gives the one command that does it:
npx @unotest/web bundle push --run --env ENVIRONMENT--envtakes the environment’s own name,prodrather thanproject/prod. The command fits the card and can be read and copied whole. The empty environment list and the button that could not be pressed are gone. -
Renting a box asks for an address and what runs on it. A row is one address and the environments on it, added as tags. There is no project field: the project is the box’s own name, so a box called
acmegetsacme/prodandacme/dev. Refusals name the field inside its row and say what to do, such as “Add https:// to the address”. A row with only half of what it needs counts as unfinished, not wrong. -
@unotest/core@0.38.0
-
@unotest/dsl@0.38.0
-
@unotest/grounder-client@0.38.0
-
@unotest/protocol@0.38.0
-
@unotest/viewer@0.38.0
0.37.0 — 2026-09-17
-
box pickertells you to restart your editorThe grounder’s keys are read when the MCP server starts, so turning the Picker on under a running editor changed nothing until that editor reconnected — and from where you stand that is indistinguishable from a Picker that does not work. Either way the agent has nothing to ground an intent with, and the only difference is a restart nobody mentioned.
box picker onnow says it in a line of its own, andbox picker offsays it too when it removed the keys: a server still running offers grounding that the cloud has already withdrawn. The line appears only when the command actually wrote or removed those keys — a box already in the state you asked for, or a grounder of somebody else’s that was left untouched, changes nothing and so says nothing. -
A box that only half came up is no longer called healthy.
After an update the box waits for its stack to settle before recording the release as applied. That wait only ever saw the containers docker was running, so a service that never started was not waited for at all — it could not be called unsettled, because it was not in the list. A box whose guard was stuck at
createdreported two services out of four settling, recorded the release, and served nothing; the same shape as a freshly rented box answering 502 to everything.The wait now asks docker about every container rather than only the running ones, and counts what came back against what the project actually declares — a service the daemon never created is absent from any listing, so it can only be found by what is missing from the answer. What the project declares is asked of compose itself rather than read off a file: compose is what merges the override files and applies the profiles, so a service held back by a profile is not waited for, and the same answer says which services are one-shots — the ones another service waits on to complete. A finished one-shot still counts as settled; treating
box-initas a service that fell over would have sent every box into a rollback on every tick. -
The Picker’s token is read from
unotest/.secrets, where the command put itbox picker onwrites the grounder’s mode and address tounotest/.envand its token tounotest/.secrets— and the values in.secretsare deliberately kept out of the environment, so subprocesses andshell()do not inherit them. The MCP server, however, looked for the token in the environment alone. The command reported success, theground_elementtool appeared, and the first intent was refused with a demand to set a variable that the command had already set. The only workaround anyone could find was to paste the token into the editor’s MCP configuration, which commits a credential to a file that is tracked.Credentials our own commands write are now read back the way they were written: the environment first (an
export, a CI secret), thenunotest/.secrets, thenunotest/.env. The box tokens already worked this way and now share the one implementation with the Picker’s.When the token is missing everywhere, the refusal names every place that was searched, the command that writes it, and the editor restart that makes a running server pick it up.
The boot health check no longer disappears in that state either. Remote mode with no token used to switch the check off entirely, which reads as “nothing to report” — a Picker that could not ground looked exactly like a healthy one until an agent called it. It now reports itself as not ok and says why.
-
Push tokens are scoped to the project your box actually serves
A box rented from the cabinet serves the projects you typed into the form —
shop/prod, say. The CLI, asking for a pair, used to send the BOX’s name as the project, because that is the only name it has. On a box created withbox createthe two happen to match; on a box created in the cabinet they do not, and everybundle pushwas refused with “this token is for a project this box does not have” — a refusal with no command behind it, since nothing told you which projects the box has.The cloud now decides the scope, because it is the only side that knows what a box serves.
box create,box accessandbox picker onsend no project at all and get a pair scoped to the box’s own. A box serving several projects has no single right answer, so it refuses to invent one and names them instead:box access --project <name>(new, and also onbox picker on) says which one the pair is for. Tokens minted before this release keep the old scope until you runbox accessagain — which is what the refusal now tells you to do.Two things say the names out loud, so a mismatch is yours to fix:
box statuslists the projects the box serves.- A refused push names the project in your token, the projects on the box, and the command that mints a matching pair. It no longer tells you your repository maps to another project when your repository maps to nothing — the two cases now print different advice, and the box says which is which.
-
A revoked box token is really revoked
Revoking a box’s tokens —
box picker off,box destroy, or a revocation an operator issues — writes the cloud’s revocation list first and applies it in memory second. The list lived in the service’s configuration directory, which a hardened service mounts read-only, so the write failed, the list in memory was never updated, and the signed document that boxes and the grounder read never changed. The token went on working across the fleet while the command reported success.The list now lives beside the service’s databases, in a directory the service owns and may write to. It is state the service signs and rewrites itself rather than operator configuration — the operator still signs and places the first one.
-
Updated dependencies [51e47c6]
- @unotest/protocol@0.37.0
- @unotest/viewer@0.37.0
- @unotest/core@0.37.0
- @unotest/dsl@0.37.0
- @unotest/grounder-client@0.37.0
0.36.0 — 2026-09-16
-
A rented box now brings itself up, and a failed first update no longer takes it apart.
Two things a box could not do for itself:
- Its
.envwas written where the box was ordered, not where it runs. Two of the values the compose template needs belong to the machine — the path the docker daemon resolves the projects volume to, and the group that owns its socket — and neither exists when a box is ordered. They were left out, compose substituted empty strings, the run sidecar exited and every container that depends on it stayed created but never started. The box now fills both in itself, after docker is installed and before its stack is first brought up, and the project name compose runs under is written down rather than guessed from a directory name. What the template requires without a default is one list, checked against the template itself, and every writer of a box’s.envis refused by name if it hands over an incomplete one. - A rollback could delete a box’s only deployment. Undoing an update removes the files that update introduced — correct when there is an earlier deployment behind it, destructive on a box that has never run one: the first failed update took the whole stack with it, leaving a machine with no configuration to bring up. A rollback with nothing to return to now leaves the files where they are. Either way, a box a rollback could not put back stops applying releases and says it needs an operator, instead of fetching, failing and rolling back the same release every hour.
- Its
-
box statusanswers for a box that never came upA box the fleet could not build has no address, and the cloud said so by sending
url: null. The CLI reads that field as one that may be missing, not as one that may be null, so the whole answer failed to parse:box statusshowed neither the state of the box nor the reason it failed — in the one case the command exists for.The cloud now leaves out what it has no value for, by a single rule for the whole answer rather than field by field. The CLI accepts
nullas well and reads it as “no address”, because it is published and meets clouds older than itself. A box that failed now prints its state, the reason the fleet gave, and what it is paid up to, with--jsonstaying readable in the same case. -
@unotest/viewer@0.36.0
-
@unotest/core@0.36.0
-
@unotest/dsl@0.36.0
-
@unotest/grounder-client@0.36.0
-
@unotest/protocol@0.36.0
0.35.2 — 2026-09-16
- @unotest/core@0.35.2
- @unotest/dsl@0.35.2
- @unotest/grounder-client@0.35.2
- @unotest/protocol@0.35.2
- @unotest/viewer@0.35.2
0.35.1 — 2026-09-16
-
unotest-web viewer --helpnow prints the usage and exits instead of starting a viewer and scaffolding aunotest/project into the current directory. Asking any subcommand for help is answered before anything is read from or written to disk. -
lintand a pushed bundle now agree on what “declared” meansA bundle has always read
unotest/.env.exampleand.secrets.exampleas the suite’s declared externals; the linter read onlyunotest/.envand.secrets, which are gitignored. A project was therefore complete for one and broken for the other: green on a machine that had the real files, red on a fresh checkout — a CI runner, a colleague who had just cloned — over names the repository does declare, with a message pointing at files that checkout does not have.Both now read the same list. A name written in an example twin counts as declared, and the “searched:” line of a refusal names the twins alongside the real files.
Values are never read from an example: it holds placeholders, and a scenario that reads such a variable still fails at that step — “no value”, which is a different problem from “no such name” and has a different fix. A name declared nowhere is the error it always was.
-
unotest-web --helpnow listsbox picker on|offalongside the other box commands.- @unotest/core@0.35.1
- @unotest/dsl@0.35.1
- @unotest/grounder-client@0.35.1
- @unotest/protocol@0.35.1
- @unotest/viewer@0.35.1
0.35.0 — 2026-09-15
-
A remote grounder can now say “this account has spent its month”, and a client can tell that apart from a bad token.
A grounder running on an external model provider meters what each account costs and stops at a ceiling. At the ceiling the two routes that call a model — indexing a page and resolving an intent — answer HTTP 402 with
grounder-budget-exhausted. Money, not permission: the token is perfectly good, nothing is re-minted over this, and a client that treats it as an auth failure would retry forever. The body carries no amount and nothing about any other account; the figure is an estimate of a bill we do not issue, and a customer would rightly check it against the provider’s own.What a ceiling stops is writing tests, not running them, and every text about it says so: only the MCP server asks the grounder —
ground_element, and an intent locator insideexplore_step/find_element. A scenario carries the locator it was written with, so suites, their runs on a box and the viewer are untouched by a cap.ErrorResponse.codenames it, and gainsrevocation-list-unavailableat the same time: a grounder has been answering with that code since signed tokens arrived, and the union never learned about it — a client written from the type could not discriminate a refusal it was already receiving.retryAfterSecondsjoins the shape for it, matching theRetry-Afterheader that was already being sent.Nothing changes for a grounder running on our own GPU: nothing is metered there, so nothing can be refused for money.
-
box picker on|off: the Picker, switched on a box you already haveThe Picker is an hourly surcharge on top of the box, and until now it could only be chosen when the box was rented — with
box create --picker, a flag most people met for the first time in--help.unotest-web box picker on|off [name]switches it on a live box, with no re-creating and no re-pushing.onmints the box’s tokens the waybox accessdoes and writes the Picker’s three keys —UNOTEST_GROUNDER_MODE=remoteandUNOTEST_GROUNDER_REMOTE_URLintounotest/.env,UNOTEST_GROUNDER_REMOTE_TOKENintounotest/.secrets.offclears the three again, but only when they name that box’s grounder: a project pointing at a grounder of its own, or at another box’s, keeps it and is told so. Either way the charge follows at the next hourly tick, because a started hour is charged at the rate it started with.box createasks about the Picker on a terminal when--pickerwas not given, quoting the hourly surcharge from the cloud’s own price list rather than a number kept in the CLI.--yes,--jsonand a run with no terminal answer no without asking.box statussays whether the Picker is on.run_testoffers it once. A project that has a box but grounds intents nowhere remote getspickerSuggestion({state: "no-picker", next, why}) after the first run that passes — exactly once per MCP server session, on the same terms asboxSuggestion, and never both: with no box there is nothing to put a Picker on.- A refusal for money reads as one. A remote grounder answering
grounder-budget-exhausted(HTTP 402) means the account’s estimated spend for the month has reached its ceiling, not that the token went bad: nothing is re-minted over it, and the CLI and MCP replies name both ways out — top up the balance, orbox picker offto stop the Picker and ground locally again. No amount is quoted: the figure estimates a bill we do not issue.
-
A box can now say “I cannot judge this token yet” instead of “your token is wrong”.
A box rented from the fleet accepts tokens the cloud signed for it, and it checks every one against the operator’s revocation list. Until that list has reached the box — the minutes between it coming up and its first update tick — there is nothing to check against, so such a token is refused. That is not the same refusal as a bad credential: the answer is to send the SAME token again shortly, never to mint another.
BoxReadErrorCodegainsrevocation-list-unavailablefor it, and a refusal body may carryretryAfterSeconds(the box sendsRetry-Afterwith the same number). The push and run answers gain the same code, plusambiguous-projectfor a cloud-minted push token that names no project on a box that serves several. Nothing changes for the tokens a person or a CI job mints on the box itself. -
box statussays where the Picker groundsA box whose Picker is on now prints one more line:
Grounding: Our GPUorGrounding: External provider (Gemini)— which models the cloud resolves intent locators with. It is the answer to a question a customer could not check before, only read on a web page.The line appears only while the Picker is on. With it off, intents are resolved on the machine the tests run from, and naming our models under that box would describe somebody else’s. A cloud that has not heard from its own grounder sends nothing and the CLI prints nothing: a mode we have not been told is not a mode to invent.
On the wire the field is
grounding, notgrounderMode.UNOTEST_GROUNDER_MODEalready means something else and belongs to the reader — whether THEIR machine grounds locally, remotely or not at all — and inbox statusthe two would have met a line apart. It is optional, so an older cloud simply says nothing. -
A box can now say “this token has expired” instead of “your token is wrong”.
Tokens the cloud signs for a rented box live thirty days. Until now a box refused an expired one with the same blanket
unauthorizedit gives a forgery, so the CLI could not tell “ask the cloud for another pair and retry” from “this credential will never work”, and an expired pair looked revoked.The read, push, run and values answers gain the code
expiredfor it. A box sends it only for a token its operator key really signed, of the right kind, for that box — the signature, the kind and the box are all checked before the expiry is — so nothing a guesser sends can produce it. It is also decided before the revocation list is consulted: a box that has not received a list yet still answersexpiredrather than asking for the same dead token again.Two codes that a box could already send are now accepted when parsing a run or values answer, which had been left out of those two readers:
revocation-list-unavailableandambiguous-project. -
Fake cloud: two access pairs that arrive without a
client_nameno longer replace each other. A machine whose hostname the contract’s charset refuses sends no name at all, so two such machines were evicting one another from a box — the fake now replaces a pair only when a name actually matches. Its refusal for a box that is not ready is worded like the rest of the service (“That box is deleted.”), so a client sees the same sentence from the fake and from the real cloud. -
The Picker comes with the box, and the CLI says so
The Picker used to be priced as a factor on the box’s own rate, which made its surcharge a different number for every size and made “what does it cost” a sum rather than a price. It is now a fixed hourly price per size — and, for now, one nobody pays: it is included in the price of the box.
box create’s question,box picker on|offandbox statusall say it the same way, with the number the cloud sends and never one of this CLI’s own: “included in the price of the box (normally €0.05/h)”. The list price stays visible on purpose. “Included”, alone, reads as “cheap now, billed later” — and what is actually charged is the number a client should quote, soGET /ratessends both:pickeris what will be added to the bill (zero while it is included) and the newpickerListis what it lists at, besidepickerIncluded.A cloud that does not send the two new fields is quoted the old way, from
pickeralone — nothing to strike through, and no invented number. -
box picker offnow takes the token away, not just the surchargeUntil now
offstopped the hourly charge and cleared the Picker’s keys from this project’s files. A copy of the token already on another machine — a teammate’s laptop, a CI secret — went on working until the box’s licence ran out, grounding intents on our grounder that nobody was paying for.Switching the Picker off now revokes that box’s Picker tokens, all of them: the cloud writes their ids into the operator’s signed revocation list, and the grounder stops accepting them once it has re-read that list. The command says so, and says the minute rather than promising an instant. Clearing the files is now the convenience it always was; the revocation is what ends the access.
Two smaller things follow from it.
offasks the cloud to revoke even when the Picker was already off, so repeating the command is how you finish the job if it failed once. And a revocation the cloud cannot write is a refusal you see — the surcharge stops either way, but a token still in the wild is not something to leave in a log. -
Updated dependencies [79c99ab]
-
Updated dependencies [dee96a1]
-
Updated dependencies [86da16e]
-
Updated dependencies [7ffa4ae]
- @unotest/protocol@0.35.0
- @unotest/grounder-client@0.35.0
- @unotest/viewer@0.35.0
- @unotest/core@0.35.0
- @unotest/dsl@0.35.0
0.34.0 — 2026-09-07
Never published to npm on its own: the registry went from 0.33.0 straight to 0.35.0, which carries everything listed here.
-
From
initto a paid box without copying a token:login,box create, and the tokens that keep themselves fresh.Three new commands sign this machine in to unotest cloud — the account that rents boxes — and rent one:
loginruns the device flow (RFC 8628): it prints a link and a code, opens the browser when there is one, and waits for the approval. The key is kept in~/.config/unotest/credentials.json(mode 0600,XDG_CONFIG_HOMEhonoured) and is never printed, not even with--json.UNOTEST_CLOUD_TOKENin the environment wins over the file, which is how a CI job signs in.logoutrevokes the key on the server when the cloud can be reached and removes it locally always;whoamisays who, where the key came from, and the balance (exit 77 when not signed in).box create [name] [--size s|m|l] [--topup <eur>]creates a box named after the project (its package name, as a slug), with the environments the project runs locally (unotest/.envand everyunotest/.env.<name>, theirAPP_BASE_URLor the config’sbaseUrlas the target). When the balance will not cover the first hour it prints a Stripe Checkout link, opens it when it can, and waits for the payment. Then it waits for the box, mints its cloud-signed push and read tokens, writesUNOTEST_BOX_URLtounotest/.envandUNOTEST_BOX_TOKEN+UNOTEST_BOX_READ_TOKENtounotest/.secrets(through the same structure-preserving writer the viewer’s Variables panel uses, with.gitignoreguarded the wayinitguards it), pushes the suite — retrying with backoff while a fresh box has no revocation list yet — and prints the viewer’s address. Exit codes for an agent driving it without a terminal: 75 the payment was not confirmed in time (nothing created, nothing charged), 76 the terms of service are not accepted (a person must, in a browser), 77 not signed in. No prompt is ever shown without a TTY.--pickerrents the box with the Picker;UNOTEST_GROUNDER_MODE=remoteandUNOTEST_GROUNDER_REMOTE_URLthen go tounotest/.envandUNOTEST_GROUNDER_REMOTE_TOKENtounotest/.secrets— the names the MCP server reads — so intents ground on the box with no further setup. A box without the Picker leaves those keys alone. The last line ofcreateis the next step: the completebundle push --run --env … --collection …when the project has exactly one environment and one collection, the viewer’s address plus the command with placeholders otherwise.box access [name]re-mints the pair and rewrites the two files;box statusandbox destroy(asks on a terminal,--yeselsewhere) are thin wrappers. The name defaults to the boxUNOTEST_BOX_URLpoints at (acme.box.unotest.com→acme).
Cloud-signed box tokens live thirty days. When a box answers that one has expired,
bundle pushand thebox …read commands re-mint the pair through the cloud and retry once, provided a cloud login is at hand; without one they say to runlogin && box access. The box a re-mint is for is the one the project points at, named from its address or, when the address spells no name (a stand behind an IP), by the same defaultcreateused — so a lab box re-mints like any other. A--boxnaming somewhere else is said to be somewhere else rather than blamed on a missing login. A box that has not yet received its revocation list is told apart from a real refusal and retried; a token the box calls revoked is never re-minted quietly.bundle pushnow also reads the push token fromunotest/.secrets, wherebox createputs it (thenunotest/.env, for suites set up by hand), so nothing has to be exported after the setup.The MCP server knows the state too. While the project points at no box,
box_envs,box_runs,box_runand the other box tools answer{state: "no-box", next, why}instead of an error, andrun_testcarries the sameboxSuggestionexactly once per server — after the first local run that passes — so an agent offers the box at the moment it is worth something and never nags. The box tools re-mint an expired cloud-signed read token the way the CLI does, through the composition root, andunotest/.secretsis read on every call: a pair written bybox createwhile the server runs is used by the next call. On a Picker box,ground_element(and intent locators) whose picker token the grounder refuses re-mint it once through the cloud and hand the new token to the running client; a refusal that cannot be mended answers{state: "picker-token-refused", next: "npx @unotest/web box access", why}, and a grounder that has not read its revocation list yet answers{state: "retry-shortly", retryAfterSeconds}. Box tokens are checked before the network: cloud-signed ones by their whole shape (unos_…), a push token in the read variable (and the reverse) is named as the wrong kind, anything else as not a token.A renewal the cloud itself refuses is its own answer, not the cloud’s. When a box or the grounder says a cloud-signed token is stale and the
/accesscall that would replace it is refused in turn — no such box, the box not ready, too many live pairs, the cloud unreachable — every surface says that the credentials could not be renewed and keeps the cloud’s refusal as the cause. The CLI prints one line and exits with the code that refusal earns; the grounding tools answer{state: "picker-token-refused", next, why}as before, so an intent is never answered with a sentence about boxes.initends with one line saying how to run the suite on a box; the “mint a read token in the guard” advice in the box commands’ refusals now points atloginandbox accessinstead.UNOTEST_CLOUD_URLpoints the CLI at a stand or a fake cloud; nobody sets it otherwise.@unotest/protocol:replaceEnvVar(an in-place value replacement the new upsert builds on),BOX_PUSH_TOKEN_PREFIX/BOX_SIGNED_TOKEN_PREFIX/BOX_SIGNED_TOKEN, andUNOTEST_BOX_URL,UNOTEST_GROUNDER_MODE,UNOTEST_GROUNDER_REMOTE_URLlisted among the runner-config keys so they file under the Runner section ofunotest/.env.@unotest/grounder-client:GrounderHttpClientOptions.tokenmay be a function yielding the current bearer;GrounderRemoteErrorcarries the grounder’sretryAfterSeconds, and the error codes gainrevocation-list-unavailable. -
A box refusing a token now answers with the words the box actually sends, and with the step that fits the token you presented.
The CLI knew five refusal codes; a box sends three.
expiredstill re-mints through the cloud and retries once,revocation-list-unavailablestill means the same token, later. Everything a retry cannot fix — revoked, bad signature, wrong door — arrives as oneunauthorized, and that is deliberate on the box side: a refusal that names WHICH check failed is an oracle. The three codes written ahead of the box and never sent by anything are gone.What you see changes in two ways:
- the sentence is the box’s own reason, never a stock phrase of ours. A code this CLI does not know (a box newer than the CLI) reaches you with the box’s message too, instead of a generic line about a token.
- the suggested fix follows the KIND of token: a cloud-signed pair is
re-minted with
npx @unotest/web box access; a token you minted yourself on a box you run is re-minted in that box’s guard, because there is no cloud behind it to point at.
The MCP
box_*tools return this as a state rather than an error, the way they already do for a project with no box:{state: "box-token-refused", next, why}—whyis what the box said,nextis the command that fixes it.pnpm fake:cloud --box-refusal <code>makes the fake box refuse with any code, including one the CLI does not know, so both paths can be exercised without a real box. -
Secrets no longer travel with the page capture that grounding reads.
UNOTEST_BOX_SECRET_NAMESandunotest/.secretsmasked what the runner said out loud — logs, the debug log, the recorded session — but not the captureground_elementand intent locators send to the grounder, and in the hosted mode on to a model provider. Only the field TYPE was filtered there: apasswordorhiddeninput was dropped by the page walker, while an API token in a plain text input, a one-time code, a card number, a whole textarea, a login, or a key the application printed on the page went out verbatim.Both modes come through one place now, and it cleans the capture twice over before the content hash is taken:
- every registered secret value is replaced by
‹secret:NAME›wherever it occurs — a control’s value, a text node, an accessible name, the page title. Masking is by EXACT value: the same secret written in another case is not recognised, deliberately, because matching loosely would mask ordinary words and the noise would land in what grounding reads. - the page URL and every link keep scheme, host and path; query, fragment
and
user:pass@are dropped. That is where a password-reset token, a signed link or a session id lives, and no list of known secrets can cover those. A link made of nothing BUT a fragment (#main) keeps it — that fragment is the whole label, and there is no path to name instead; a fragment hanging off a real address (/invite#SECRET) is dropped like any other.data:,blob:andjavascript:keep their scheme alone: there the document, or the code of a call with its arguments, sits where the path would be.mailto:andtel:stay whole — there the path is the addressee, which is a label like any other.
The masking is what the grounder is given, so it is also what the content hash is taken of — two visits that differ only in a link’s one-time token are correctly one page to the index cache, not two.
The graph’s own vocabulary is untouched:
kind,tag,role,refandtestIdare structure, not content, and a secret whose value happens to bebuttonno longer has anywhere to do damage. Everything else in a capture is masked, including a field added to it later.Loading a secret shorter than six characters now warns, once, naming the variable and its length and never its value. Nothing changes about how it is masked — the warning exists because masking by value has no notion of length, so
adminas a password also masks the word wherever a page writes it, and grounding can get worse on a page that has nothing to do with the secret.Still not covered, and worth saying plainly: a value the run was never told is a secret. A key the application itself renders on the page — the “copy it now” screen — is in no registry, and goes out with the capture.
- every registered secret value is replaced by
-
Failure artifacts survive a run that is isolated from its own artifact root.
A run executed inside a container sees only what is mounted into it. The failure bundle’s directory is derived from the artifact root, which on such a run is not one of those mounts — so it could not be created, and a failed test produced no screenshot, no trace and no page HTML at all. Worse, that one error took the always-on copies in the run directory down with it: both sinks shared a single try, so the artifact that does not depend on the bundle was lost too.
Two changes.
UNOTEST_FAILURES_ROOTnow says where bundles live, for whoever spawns the run to point at a directory it can actually write (unset, everything behaves exactly as before). And the two sinks are independent: an unusable bundle root no longer costs you the run directory’s screenshot and page HTML.The viewer serves failure assets from either layout, so a bundle written inside the environment’s runs tree renders like a local one.
-
initno longer rewrites a config the project had already edited, and--helpno longer runs the command it is asking about.Two defects, one outcome.
initwrote the channel intounotest/unotest.config.mjsunconditionally at the end of every run, on the assumption that the file was the placeholder it had written seconds earlier. In a project that had runinitbefore, that file is the project’s own: it was replaced by the template with no--force, no warning, and analready presentline printed about it. And because the option loop ignored anything it did not recognise,init --help— the first thing a person types when a command is unfamiliar — fell through into a real run in the current directory, scaffolding the layout and taking the config with it.What changes:
- an existing file stays exactly as it is; the channel is patched only
into a config this run created itself. A re-run reports
already presentand changes nothing. --forcestill takes the templates, and now keeps what it replaced next to it as<file>.bak. Being overruled is not the same as losing the work.- the package-owned copies
initkeeps in sync — the test-authoring skill, the agent-trace hook — are still synced without--force, but a copy that diverged is now kept as<file>.baktoo. “Nobody edits these” is an assumption about a file in your repository, and it costs nothing to be wrong about it safely. --help/-hworks on every command, prints the usage, and does nothing else — answered before the workspace is even resolved, so a question touches no files anywhere.- an option a command does not recognise is refused with its usage and
exit 2, instead of being dropped silently.
initreads its options against the list of what it knows, so the next flag added cannot fall through the same hole.
- an existing file stays exactly as it is; the channel is patched only
into a config this run created itself. A re-run reports
-
initwrites files that pass the project’s ownprettier --check.The block
initmaintains inCLAUDE.mdnow has a blank line between each marker and the text inside, and the JSON it writes —.mcp.json,.claude/settings.json— keeps a short array on one line ("allow": ["mcp__unotest-web"]) instead of one element per line. Both are what prettier produces, so a repository whose CI runsformat:checkno longer goes red on files it did not write itself. A fileinitmerges into is re-emitted by the same rules, including the entries the user had there.AGENTS.mdtoo: its DSL examples now sit in fences prettier does not reformat, and every list has the blank line before it that prettier’s Markdown rules want. -
The search text sent for grounding is masked the same way the page capture already is.
Masking the capture cleaned what the PAGE said. The other half of a grounding call — the intent, the thing being searched for — went out untouched: to the grounding service, into the picker’s prompt and the embedder’s query, and in the hosted mode on to a model provider. A scenario that puts a variable into the search (
intent("the row for " + LOGIN)) sent its value verbatim, while the same value was masked everywhere else in the run.Now every registered secret is replaced by
‹secret:NAME›across the whole intent, not only its text: the predicate’s field description and its value too, which is where a value is most literally the thing being searched for. Only the structural keys are kept — the comparison operator, the kind of unit wanted back, the kind of ordinal — so a field added to the contract later is masked by default rather than forgotten.A numeric secret is masked as well, and only on an exact match: a one-time code arrives as a number, and a number has no text for a value to hide inside, so
447213masks while4472130and every ordinary quantity are left alone.This happens in one place for both grounding modes — local and remote — so a value cannot escape through whichever mode a project runs.
The honest cost, because it is real: masking is by exact value. Where the page rendered the same secret in another form — a different case, a percent-encoded path, a value split by an invisible character — the mask now lands on the intent alone, and the correspondence between what you searched for and what the page shows is lost for that one case. Grounding can get worse there. It is the same boundary already stated about masking generally: the exact value is what is recognised, not the secret “in any form”.
-
Updated dependencies [32a495e]
-
Updated dependencies [c6b91bb]
- @unotest/protocol@0.34.0
- @unotest/viewer@0.34.0
- @unotest/grounder-client@0.34.0
- @unotest/core@0.34.0
- @unotest/dsl@0.34.0
-
Named late, and it should have been named in 0.33.0: on a box the pushed suite is mounted read-only. Since run isolation (0.33.0) a run sees its bundle at
/box/sourcesand cannot write next to its own files — apreparescript that seeded a database beside the fixtures, or a helper that kept a scratch file underunotest/.tmp, now fails withEROFS/ENOENTbefore a single scenario runs. The places a run may write are/tmp(the container’s own, gone with the run) and the run’s artifact directory underUNOTEST_ARTIFACTS_ROOT;os.tmpdir()in Node and$TMPDIRin a shell both land in the former. Migration for a suite that wrote beside its sources: pick the scratch directory by whetherUNOTEST_ARTIFACTS_ROOTis set —unotest/.tmplocally,/tmp/<suite>on a box — and point the seed, the fixture and the scenario at the same helper. Our own dogfood suite failed on exactly this for a day.
0.33.0 — 2026-09-06
-
Box: every run is announced — Slack, Telegram, or a webhook of your own — and the channels are managed from the guard.
A box now tells people how its runs went, whoever started them: the schedule’s ticks, a pull request’s check, somebody pressing Run in the viewer, a terminal inside the container. The daemon reads each run’s own journal, so the three producers are one source; the run’s manifest says what set it off —
trigger: { kind: "schedule" | "ci" | "manual" | "cli", by? }, written by the runner fromUNOTEST_RUN_TRIGGER/UNOTEST_RUN_ACTOR(a box sets them for its scheduled and CI runs, the viewer for a run ordered from its UI; a terminal sets nothing and reads ascli).A scheduled series is announced on a change of state:
failedafterafterFailuresred ticks in a row,recovered,still-failingat a rule’srepeatEvery,not-runwhen the suite could not run at all (no bundle, a bundle that does not install) — daily by default. A run somebody ordered is announced on its own,failedorpassed, every time. A withdrawn or aborted run says nothing; a run whose journal stops and whose heartbeat goes cold isinterrupted.Channels, rules, mutes and quiet hours live on the box and are managed from the guard’s
/_guard/notificationspage (orboxd notify …): a channel’s secret is set once and never shown again, a rule picks environments, triggers, events and channels, a series or the whole project can be muted until a time, and quiet hours hold reminders andpassedback until the morning. A generic webhook receives the event as JSON (version: 1, the box named, thetrigger) withX-Unotest-Eventand, when the channel has a secret,X-Unotest-Signature: sha256=<hex>— HMAC-SHA256 over the raw body, the scheme GitHub uses — plus any headers the channel declares.@unotest/protocol:RunManifest.triggerand the trigger helpers (runTriggerFromEnv,runTriggerEnv,manifestTrigger); the session-door paths, views and parsers of the notifications API (boxNotifyProjectPath,boxNotifyChannelPath,boxNotifyRulePath,boxNotifySeriesPath,boxNotifyMutePath,boxNotifyQuietHoursPath,boxNotifyPreviewPath,BoxNotifyProjectView, …). The first draft of this feature (channels declared in the box config, secrets by field) never shipped; this replaces it whole. -
lintande2eno longer fail on a project tree they cannot write to.Editor typings (
unotest/jsconfig.json,.unotest/types/env.d.ts) are a convenience for a human with an IDE open, and they were being written on every implicitlint/e2e. On a box that runs a suite in an isolated container the bundle is mounted read-only, so that write raisedEROFS/ENOENTand every scenario ended asinterrupted— a suite failing because a cache could not be refreshed.The write is now skipped when the tree refuses it (matched on the errno
code, never on a message), both artifacts reportskipped, the result carries the reason, and the run carries on; any other failure still throws.UNOTEST_EDITOR_TYPES=0turns the whole thing off up front — a box sets it on every run it starts, because there is no editor in a container and the attempt is only noise in the log. -
Judge: the bearer token on
/judgeis compared in constant time, and the docs say what a verdict sends and what it is not.UNOTEST_JUDGE_TOKENwas checked with a plain string comparison, which returns as soon as one character differs; both sides are now hashed and compared withtimingSafeEqual. The judge guide and the package README gain a section on what leaves the machine — the one element’s rendered text and the rubric, nothing else; the secret redactor does not act on that body; CI defaults tofake— and on why a verdict, being a model’s reading of the page’s own text, is a check on wording and never a security gate. -
Box notifications: a webhook channel’s header values are credentials.
The notifications API and the guard’s page now see a webhook’s headers as names with
{ set: true }— the values (the receiver’sAuthorization, most of the time) are never read back, like the signing secret. The edit form lists the names; typing a value after one replaces the set, names alone keep it./api/box/notifyis the guard’s own route: it is no longer reachable through the session proxy for any role — a readonly session could previously list every project’s channels with their headers. -
Box notifications: the webhook signature now covers a timestamp.
Every webhook delivery carries
X-Unotest-Timestamp(Unix milliseconds, when it was sent), andX-Unotest-Signatureis HMAC-SHA256 over<timestamp>.<raw body>rather than the body alone — GitHub’s scheme with a timestamp in front, so a delivery captured on the wire cannot be replayed to the receiver once its window (five minutes, documented) has passed. The GitHub webhook the box receives is unchanged: that is GitHub’s contract.The
byof a CI-triggered run (push a1b2c3d (branch)) is cut at 200 characters — a branch name is the pusher’s input, and it must not balloon the run’s manifest and every notification the run produces. -
Pin playwright to the exact release the box image ships.
@unotest/webdepended onplaywright ^1.62.0, so a fresh install of a suite could pick a newer playwright than the browsers inside the box’smcr.microsoft.com/playwrightimage, and every run on the box failed with “Browser ‘chromium’ is not installed” the day 1.63.0 shipped. The dependency is now the exact version of the image (1.62.1); the two are bumped together, and a guard in the monorepo’sverifykeeps them equal. -
Building the runner no longer writes into the project tree.
ExplorationServicecreated.unotest/explorations/in its constructor, and the composition root builds it for every entry point — including ane2erun. Where the project tree is read-only (a box mounts a pushed bundle that way) the run ended before a browser was asked for. Recording is an MCP activity, so the folder is now created with the first record written instead.With the editor-typings fix in the same release, an ordinary
lint/e2ewrites nothing under the sources: run artifacts go toUNOTEST_ARTIFACTS_ROOT(which the box makes writable), and everything else that writes into the project — recording, authoring,init,env set,bundle push— happens on a developer’s machine. -
Run isolation, stage 1: a box no longer executes a test bundle inside its own daemon.
box-runner (new, private): the run sidecar — the only service on a box holding the docker socket, and the only one that starts a container. Three authenticated routes on the
boxnetwork (POST /runs,GET /runs/:id/streamNDJSON,DELETE /runs/:id), a shared secret compared in constant time, and an API that cannot be told anything about how a container is built: no path, image, network, user, mount or flag. A request names a project, an environment, a bundle, a scenario and the environment’s values; the bind sources are derived from the first three, resolved withrealpathand re-checked against the projects root, and must already exist. The container is created over the Docker Engine API (pinnedv1.43) straight over the socket — nodockerbinary in the image and no client dependency, and a container that is a JSON document has no place for a value to become a flag. A daemon that refuses or is not there comes back as502 {reason}before the run is accepted, or as anerrorevent on the stream after — never as a run that “exited with code null”. An environment variable the box does not forward is named in the sidecar’s log rather than dropped silently. A run gets the bundle tree read-only as its working directory, the environment’sunotest/.runs.<env>read-write, anoexectmpfs/tmp, a read-only rootfs, all capabilities dropped,no-new-privileges, its own uid in boxd’s group, memory/pids/cpu limits clamped to both the operator’s ceilings and the host’s own size (docker refuses a container bigger than the machine instead of clamping it), a 512 MB/dev/shm(docker’s default 64 MB kills Chromium mid-page; the host’s IPC namespace is deliberately not borrowed), and therunsnetwork only. The container engine is an interface, so every rule is asserted on the container that would have reached docker — including the cases where the answer is no container at all.boxd: running the SUITE is its own contract (
IScenarioRunner), separate from running a command (ICommandRunner, still the daemon’s ownnpm ciand viewers).ContainerScenarioRunnertalks to the sidecar,ProcessScenarioRunnerkeeps the old child-process shape for a box without docker;BOXD_RUNNER_KIND=container|processchooses, and half a container configuration refuses to start. Reading a bundle’s schedules (unotest-web schedules --json) executes the project’s config module, so it goes the same way. A run’s environment is built from named parts (run-environment.ts) instead of inheritingprocess.env, and its debug tree is pointed at the run’s own directory rather than the read-only sources. New metricsboxd_run_container_total{outcome}andboxd_run_container_start_seconds. An environment’s runs directory is created by the daemon with2775, so the run’s uid may write into it and the daemon’s group may read it back.box-kit:
secretsMatch(constant-time secret comparison, moved out of dist-service),splitLines, andenvNumber/envPositiveNumber— the env-reading rule the box-side services share.npm ciof a bundle is a container of its own (kind: "install"): the bundle tree is its only mount and it is writable, there is no environment and no artifacts directory in reach, and a dependency’s install scripts RUN — a native module builds or fetches its binary as usual. That is what running them inside the daemon could never allow. The container’s last steps, only on success, check the tree against the box’s size cap, set the final modes on what the install created and write the install marker — each with an exit code of its own, so the daemon’s log names the step that failed — so a killed or oversized install leaves a tree the box will not mount; a failed install takes the tree with it. The daemon no longer passes--ignore-scriptsand no longer walks the tree afterwards.The daemon runs with
umask 002and clearsnode_modulesbefore each install, so a tree it created is one the install container (another uid in its group) can actually write.An install that fails KEEPS the tree, unmarked: nothing mounts it and the next attempt reinstalls in place (
npm ciwipesnode_modulesitself). And a bundle directory holding a manifest with neither an archive nor a tree behind it no longer counts as “this box has it” — a push of the same content brings it back instead of being answeredalready had this exact bundle.web:
bundle pushand the docs say that a dependency’s install scripts run on a box, in isolation — the earlier advice to vendor them is gone.A run ordered in the viewer now goes through the daemon’s queue instead of being spawned by the viewer.
POST/GET/DELETE /api/box/envs/<project>/<env>/runs[/<runId>](paths and wire types in@unotest/protocol) takes amanualticket like every other producer. Two callers may use it, and neither is taken on its word: the guard, proving it is the guard with a shared secret frombox-init(the actor header is read only next to it — a viewer container could otherwise claim to be any admin), and a viewer, with a per-environment token the daemon minted when it started that viewer plus a short-lived single-use ticket the guard signed over WHO clicked. The guard gates the route underadminand requires anOriginon a mutation; the daemon checks the token’s environment, the ticket’s environment and itsjtiagainst the path.@unotest/viewergetsBoxdRunner: on a box the Run button orders and the Stop button asks the daemon, and the progress still comes from the run’s journal on disk. A local viewer spawns as it always did — the composition root picks by whether a box handed it credentials. -
Run isolation, stage 2b and 3: an environment’s viewer runs in a container of its own, and a box ships the sidecar in its compose stack.
The viewer was the last thing on a box that executed a bundle’s code next to the daemon’s state: it runs the UI, the language service and the linter out of the bundle’s own
node_modules. It is now a container the run sidecar starts and the daemon asks for —POST /viewers, one per environment, addressed by a name both services derive (unotest-viewer-<project>-<env>-<8 hex>, port 7788).Each viewer gets a NETWORK of its own with exactly two other containers attached: the guard, which proxies people at it, and the daemon, which it orders runs from. Not the sidecar’s network, not the runs network, and above all not another viewer’s — a viewer has no authentication of its own, and two on one network would be two containers of untrusted code with a route to each other. That network is INTERNAL: a viewer’s server makes no outbound call, so it gets none, while the network a run joins stays open because a test drives an application. The name of a viewer and its network is a function of the project/environment PAIR rather than of the string they join to, so two environments whose names concatenate the same way cannot end up sharing one. Its mounts are the bundle tree read-only and the environment’s
unotest/read-write (the run history it renders and the queue it withdraws tickets from); the environment’s secrets and itscurrentlink sit in the directory above and are mounted by nobody. Its environment is assembled by the daemon — the target, the environment’s variables and secrets, its own credential — and the daemon’s ownprocess.envno longer travels.Editing a suite’s files from the viewer on a box no longer works: the sources are read-only there. Such edits never survived the next
bundle pushanyway, which replaced the tree. Everything else in the UI is unchanged.Readiness is the docker daemon’s own healthcheck, probed inside the container and read through the sidecar — the daemon no longer waits on a lock file, and nothing dials a viewer from the process holding the docker socket. A viewer’s output is streamed into the daemon’s log and reattached when the stream drops. A container whose bundle and values still match is ADOPTED across a restart of either service, keeping the credential it was born with; one whose environment is gone is stopped, with its network and its token. The sidecar recognises its own containers by label, never by name alone, and enforces its own ceiling on how many viewers may exist (
BOX_RUNNER_MAX_VIEWERS) — boxd knows how many environments there are right up until boxd is the thing that was compromised.A run somebody stopped now ENDS in the viewer instead of hanging. Stopping a run stops its container gracefully (SIGTERM, then SIGKILL after
BOX_RUNNER_STOP_GRACE_SECONDS, default 10) so the suite writes the terminal event of its own journal; if it could not — the grace ran out, the machine died — the daemon appends it. The same grace applies to a run killed on its timeout, so a run’s budget is the timeout plus the grace.A viewer that dies is noticed in about a second and replaced: the log stream the daemon holds open ends with the container, and the 404 that answers its immediate reattach is the signal. The route comes out of the table at once — the guard then says the environment has no viewer instead of proxying at a dead address — and a fresh container is started, on the same backoff the daemon uses for everything else it cannot reach.
A restart of the sidecar is no longer an outage. While it cannot be reached the daemon keeps the routes it published (the viewer containers are up, and the guard reaches them directly) and tries again with a short backoff instead of waiting for its next five-minute sweep. Editing a file in the viewer on a box now answers 403 with the reason — the sources are read-only there — rather than a blank 500.
The viewer no longer collapses the daemon’s refusals into a blank 500: the box’s own status and reason reach the browser, so “that click has already been used” (409), “the box cannot verify the caller” (503) and “the box daemon is not answering” (503) are told apart by the person who clicked.
Deployment:
compose.yamlgains therunnerservice (the only holder of the docker socket, the release ships its bundledbox-runner.mjsbesideboxd.mjs), fixed container names for the guard and the daemon, abox_secretsvolume whose secretsbox-initgenerates, and a one-off migration of the projects tree to the group layout an install container needs. Two values an operator fills in:BOX_PROJECTS_HOST_DIR(a bind source is resolved by the docker daemon on the host) andBOX_DOCKER_GID. Step by step in the box release’s cutover manual. -
Viewer: a run that started before the page was reloaded is shown again.
The live sidebar was rebuilt from WebSocket events only, and those speak from the moment the page connects — a collection already in progress never re-sent its
startedevent, so after a reload it was missing from ACTIVE, then reappeared nameless with no scenario list once its next scenario began, while its children showed up one by one. On load, and on an environment or target switch, the viewer now asks the server which runs are live and replays each one’s journal into the same state the live stream feeds, so a reload mid-run shows the collection with its name, scenarios and progress. -
Viewer: a session that may not edit variables does not see credential-shaped values, nor the project’s absolute path.
Behind the guard, a readonly session could read
GET /api/variablesand get every box variable’s value —PASSWORD=…included when the operator had pushed it as a variable rather than a secret. The viewer now reads the capabilities the guard stamps on each request: when the caller cannot edit variables, a value whose name matchesPASSWORD | PASSWD | SECRET | TOKEN | KEY | CREDENTIALis sent empty and flaggedsecret, andGET /api/targetsleaves outprojectRoot. Display-masking by capability, not authorization — a viewer without a proxy in front is unchanged, and the guard still refuses every write. -
Viewer: a collection run no longer shows up nameless and without scenarios.
The journal tailer moved its cursor to the end of the file even when the last line was still being written, so the half already on disk was lost and the other half was later read as garbage. The longest line in a journal is
collection-run:startedwith the scenario list, so that was the one it usually caught: the run appeared under its id instead of its collection name, with “collection has no scenarios” and a2/0 donecounter. The cursor now stops at the last complete line and the rest is read on the next change. -
Viewer: a run started the moment the viewer came up is shown, not lost until the next change.
Watching a directory becomes live a moment after the watcher reports it is ready, and a run directory created inside that gap was not merely late — it was invisible until something else changed in the same day, which in a quiet environment is the next run. Measured at 1-3% on macOS with a busy machine, and the window is exactly “open the viewer, start a run”. The viewer now proves the watch delivers before it declares itself up: it creates a directory of its own and waits to hear about it, so boot completes on evidence rather than on a promise. A tree it cannot write to, or a probe that never comes back, costs a warning in the log and boots as before.
A directory whose name starts with a dot is never reported as a run — an editor’s leftovers, or the watcher’s own probe, can no longer appear in the run list as a phantom.
Run queue: a waiter whose process paused is no longer mistaken for dead — the queue stamps its own files with its own clock.
A run waiting for a slot proves it is alive by touching its ticket, and what it is judged against was the modification time the filesystem wrote: on a network share that is the server’s clock, and against a machine busy enough to keep a process off the CPU, a ticket written a moment ago could look like it belonged to a process that died. The queue now stamps every file it creates with the same clock it judges them by.
-
Updated dependencies [2abfd4d]
-
Updated dependencies [b3e1220]
-
Updated dependencies [b3e1220]
-
Updated dependencies [5d1bde3]
-
Updated dependencies [348aba7]
-
Updated dependencies [9158968]
-
Updated dependencies [b3e1220]
-
Updated dependencies [bd6b7ba]
-
Updated dependencies [8846a06]
- @unotest/protocol@0.33.0
- @unotest/viewer@0.33.0
- @unotest/core@0.33.0
- @unotest/dsl@0.33.0
- @unotest/grounder-client@0.33.0
0.32.0 — 2026-09-05
-
npx @unotest/web box …reads a box’s results from a terminal:box envslists the environments a read token may look at,box runstheir history (--latestcollapses it to one line per scenario with its failing streak),box run <id>explains one run — the failure, the soft steps, the judge’s verdicts —box queueshows who is waiting, andbox screenshotsaves a frame the run captured.box run --downloadfetches the run’s*.unotest.zipinto.unotest/box/and unpacks its failure bundle into.unotest/failures/, wherelist_failures,get_failure_*andagent_fixalready look — so a run that failed on a box is debugged with the commands a local failure is.--no-screenshotsasks the box itself to leave the step frames out (GET /api/runs/:id/export?screenshots=0), which is what makes the download smaller rather than only the disk.Reading needs a personal read token in
UNOTEST_BOX_READ_TOKEN; the project’sUNOTEST_BOX_TOKENstill pushes bundles and values and cannot read runs.The viewer publishes its archive reader as
@unotest/viewer/snapshot, so the three places that open a*.unotest.zip— its server, its browser bundle and now the CLI — share one implementation and one message for a file that is not an archive. -
Six MCP tools close the loop that
bundle push --runopens:box_runsays what became of a run the agent ordered on a box,box_run_downloadunpacks its failure bundle into.unotest/failures/soget_failure_trace,get_failure_console,get_failure_a11y,get_failure_screenshot,get_failure_networkandagent_fixwork on it unchanged, andbox_envs,box_runs,box_queueandbox_screenshotcover the cases where the agent has no run id, no environment name, a run that never started, or a page it would rather see than read about. Their descriptions carry the route, not just the arguments.Without a read token the tools refuse with a message saying where to mint one, so an agent meets an instruction rather than an unexplained failure.
-
Box: personal read tokens, so an agent can read a box’s runs without a browser.
A user mints a token for themselves on the guard’s new Read tokens page (
/_guard/tokens), sees the value once, and points a client at the box withUNOTEST_BOX_READ_TOKEN. The token is alwaysreadonlywhatever its owner’s role, may onlyGET, and names the environment it means inX-Unotest-Environment: <project>/<environment>(GET /_guard/api/envslists them). It is not a machine identity: it follows its owner — revoked, disabled or a lapsed seat all stop it, and the refusal says which. An administrator sees every token on the box and can revoke one that is not theirs; issuing, first use and revocation all land in the audit trail.UNOTEST_BOX_TOKENis unchanged: the project token still pushes bundles and environment values, and read tokens cannot — asking with the wrong one now says which token the route wants instead of a bare “unauthenticated”.A read token is never passed on to the viewer behind the guard, so it cannot end up in the logs of a service that has no use for it. A box whose licence has lapsed, and an environment whose viewer is not up yet, answer a token in the read contract’s shape rather than with a page or a bare 503 — “wait” and “renew the licence” are not the same instruction as “your token is wrong”.
-
The agent integration guide’s tool catalog matches the server again. It listed fifteen per-action tools (
goto,click,fill,press, …) that were removed when recording moved intoexplore_step, had no section at all for the ten exploration tools that replaced them, counted the debugger’s eleven tools as six, and put the total at 39 when it is 50. An agent reading it as a map — which is exactly what this file is for — would call tools that do not exist and conclude the server is broken. Every name now comes from the list the test suite verifies against the real registry, and the section counts add up to the total. -
BoxReadClientreads a box’s runs — environments, run history, one run’s whole journal, a collection’s children, the queue, a run’s export zip and its individual artifacts — over the same HTTP routes a browser uses, authenticated with the personal read token inUNOTEST_BOX_READ_TOKEN. Every refusal arrives as a typedBoxReadErrorwhosekindsays what to do next, so throttling is never mistaken for a rejected token and a live run’s export says “wait” rather than looking like a broken box. Configuration mistakes surface when a read is attempted rather than at startup, so a stale token in a project’s.envcannot stop a local run that never touches a box.The viewer gains a
@unotest/viewer/wireentry point exporting its HTTP contract types (the runs page, a run’s full snapshot, the queue payload and the snapshot manifest), so a client can name the shapes it parses instead of keeping a second copy of them. -
The box address and the read token are resolved from
unotest/.envandunotest/.secretson every request, not once at startup, and theboxCLI reads those files directly.unotest-web box …runs without loading the project config, so nothing had flattenedunotest/.envinto its environment — the command told people to put the address there and then refused with “no box address”. And an MCP server that resolved once meant a token written while it ran was ignored until the editor reconnected it, which is exactly the restart this path exists to avoid. One rule for both settings, from both entry points: a flag, then the environment, then the project’s files. -
Protocol contract for reading a box’s run results with a personal read token:
UNOTEST_BOX_READ_TOKEN(distinct from the project’sUNOTEST_BOX_TOKEN, which stays a write credential for bundles and environment values), theX-Unotest-Environment: <project>/<environment>header every bearer request names its own environment with, the/_guard/api/envslisting and itsBoxReadEnvironmententry, and typed refusals (unauthorized,forbidden,unknown-environment,not-found,rate-limited,run-in-progress,unavailable,malformed) with parsers that reject anything that is not a box answering. Throttling gets a code of its own rather than anunauthorizedcarryingRetry-After: a rejected token means mint a new one, a throttled one means wait and resend the same one. The viewer’sViewerEnvOptionis now that same protocol type rather than a second copy of it. An environment whose viewer is still starting — the common state right after a bundle push — answersunavailablerather than looking like an unreachable box, so the advice is to wait rather than to check the address. -
A run exported as
*.unotest.zipnow carries everything needed to diagnose it away from the machine that produced it:stdout.logandstderr.log(previously dropped, which left the one artifact that explains a runner crash outside the bundle), the failing page’spage.htmlreachable through the manifest, and the run’s step screenshots — declared in a newmanifest.screenshotslist, because an importer keeps only what the manifest names and a frame absent from it did not survive the round trip. -
UNOTEST_BOX_READ_TOKENis now read fromunotest/.secretsas well as from the environment, with an exported value winning. This is what makes thebox_*tools usable from an agent at all: an MCP server is started by the editor, so a token that exists only in a shell is a token the server never receives — the alternative was editing the editor’s own JSON config and reconnecting the server.unotest/.envis deliberately not consulted: it travels inside a pushed bundle, while.secretsdoes not. The CLI resolves the token the same way, so one token serves both. -
box runandbox_runnow carry the{tag}of a failed soft step, in the JSON and in the printed line (Rubric [judge-red], the shape a local run already prints). A data-driven test runs onestep.softover many cases, so its failures all share a label and the tag is the only thing that says which case failed — without it a remote reader saw “Rubric failed” three times and had to tell them apart by line number. The tag was in the run’s journal all along; the summary dropped it. -
Secrets injected by a box are masked. Masking works by value, and the registry of values was built from
unotest/.secretsalone — a file that does not exist on a box, where the daemon passes the values as environment variables and names them inUNOTEST_BOX_SECRET_NAMES. The registry was therefore empty on every box run, and a password a scenario typed reached the run journal, the run’sstdout.log, the viewer’s System pane and anything an agent downloaded, in the clear. The runner now registers those values alongside the ones it reads from files, so masking no longer depends on where the run happens to be. Nothing changes on a developer’s machine, where the variable is not set. -
Failure text no longer carries terminal colouring into files and replies.
Playwright paints its call log whenever the environment claims a terminal is watching —
FORCE_COLOR, which an MCP server inherits from whatever launched it — and that message was copied verbatim intosteps.jsonl,runtime.jsonand the failure bundle. An agent reading the JSON got an escape sequence in the middle of the sentence it was trying to parse, and the viewer rendered it as a chewed-up word.The colouring is dropped where a thrown error becomes our data, so every reader of a failure gets the same clean text. Our own output is unaffected: it paints at print time, which is where colour belongs.
The same on mobile: a failure’s text in the run journal and in the report no longer carries terminal escapes.
-
A failure somebody paused on stays a failure, with the evidence to show for it.
A run driven through the debugger —
run_testthenresume, or the viewer’s Continue — reportedcompletedafter a failure it had paused on, wrote no failure bundle and nofailure/artifacts, and leftlist_failureswith nothing to show. Continuing past a failure is how it gets inspected; it was never meant to retract it. The verdict is now decided where the run’s own events are seen, so the journal,runtime.jsonand the reply agree, and a debug run leaves the same evidence a plainunotest-web e2erun does.abort_runtime(and Stop, and SIGTERM) now also ends the run inruntime.json, not only insteps.jsonl: the control file used to keep sayingpaused-stepabout a run that was over, so anything reading it rather than the journal saw a pause that never ended. -
README links the documentation site instead of manuals that are not shipped in the package (
guides/manuals/*), and says what a run on a box cannot reach:localhost, port-forwards, host tools, a judge service on your machine — with the recipe for the judge on a box. -
Secret values registered from
unotest/.secretsare now masked in what the runner prints, not only in what it writes. The run journal and every failure artifact were redacted; the terminal line was not, and that line is also copied into the run’sstdout.log/stderr.logand streamed to the viewer’s System pane. Masking is applied once, where the logger is built, so child loggers and message arguments are covered too. The collection runner is covered by the same rule: its messages go through its logger rather than straight to the stream, and the logger it builds when a caller passes none reads the project’s secrets the way the composition root’s does. -
A
step.soft(...)failure no longer stops a debug run — or goes missing from it.Under a debugger (
run_test, ore2e --debug), every soft failure raised the debug wheel: the run stopped on each one and an agent had toresumeits way through them. Worse, it was then lost — a paused failure is consumed where it paused, so it never reached thestep.softenvelope that records it, the envelope closed as if the block had passed, and a run with three soft failures could finish green with none of them listed.Pausing is now for a failure that ENDS the run. A failure under any enclosing soft step is recorded and stepped over exactly as it is on the command line, and
runtime.json— which therun_testreply is built from — carries every soft failure of the run, not just the last stop.A hard failure still pauses: that is what the debugger is for.
The same holds on mobile: a
step.soft(...)failure no longer stops a run under the debugger, and every soft failure of a run is now part of its runtime state instead of being lost at the pause. -
Updated dependencies [442fadf]
-
Updated dependencies [442fadf]
-
Updated dependencies [8aa0f30]
-
Updated dependencies [8aa0f30]
-
Updated dependencies [047ca68]
-
Updated dependencies [047ca68]
-
Updated dependencies [047ca68]
- @unotest/viewer@0.32.0
- @unotest/protocol@0.32.0
- @unotest/dsl@0.32.0
- @unotest/core@0.32.0
- @unotest/grounder-client@0.32.0
0.31.0 — 2026-09-04
-
New DSL function
note(label, value): attach a labelled value to the current step — the question a data-driven case asked, the answer it got — kept in the run journal and shown in the viewer under the step, during and after the run. Any value: a string as it is, anything else as JSON; cut at 4 KB (markedtruncated), secrets masked.log(...)now also lands in the journal under the step that wrote it (the stdout line stays), andassertJudgeverdicts carry the position of their statement, so the viewer can show a verdict under its step and the whole verdict on a failed step’s error card. Each of these events records the DSL call’sfile/line/coland the nearest entry-file statement (entryLine/entryCol) — a note from inside a helper is attributed to the entry step that called the helper. Without a run journal (exploration, an ad-hoc runtime)notegoes to the logger; nothing fails. -
ExecutionWalker.runtakes a trailingentryArgslist and binds it by position onto the entry function’s parameters (missing onesnull) in the root scope before the body runs — whatexplore_run_flowneeds to replay a parameterisedflow_*helper live. -
explore_run_flowreplays parameterised flows: passargs(positional JSON values,"{{NAME}}"for a variable) and the helper’s parameters are bound for the live replay —flow_login(username, password)no longer fails withVariable "username" is not defined.explore_startlists each flow’sparams; a wrong argument count is refused with the signature. The recording keeps variable NAMES, so the saved test readsflow_login(LOGIN, PASSWORD);—flow_callis now a regular action plugin rendering the call with its arguments. -
New
explore_stepactionscreenshot— the evidence step of an exploration. It files a PNG under the session (.unotest/explorations/<explorationId>/screenshots/<NNN>-<name>.png,adhoc/without a session), replies with the absolutepath,widthandheight, and returns the image itself as an MCP image block (a JPEG copy when the PNG is over 1 MiB; path only when both are).namedefaults to the section slug,fullPagecaptures the whole page,locatorcaptures one element,outline: trueadds the page outline to the reply,evidenceOnly: truekeeps the file but records no step. A recorded screenshot becomesscreenshot(name)in the saved test; an element capture degrades to a page capture with a comment and a non-blockingDEGRADED_STEPwarning.explore_stopnow lists the session’s files inartifacts. Driver:DriverPage.screenshotElement. -
New linter rule
lint:external-variable-undeclared(defaulterror): a bareUPPER_SNAKEidentifier that no layer file declares —unotest/.env,unotest/.secrets, or the.env.<name>/.secrets.<name>overlay of the environmentlint --env <name>/UNOTEST_ENVselects — is reported with the files searched, instead of failing at that statement at run time after the browser is up. A name bound in the file (assigned, a parameter, a loop counter) is never flagged. The shell is not a declaration: a value CI exports still needs its name inunotest/.env(NAME=with no value declares it; the shell’s value wins at run time, as before). As an error it is not silenced by// lint-ok:; downgrade it inlinter.rulesif a project needs that.run_test(MCP) ande2erun the same check before the browser starts, against what the run will actually resolve — files and the ambient shell — so a suite that lives on a shell variable keeps running; a name that would have failed in the scenario anyway now fails up front (run_testanswerslint_failed;e2eprints it and proceeds, like every pre-run diagnostic).The linter also descends into operator, array and property-access operands now, so a regex or
{{mustache}}literal insidea + bis diagnosed the same as one on its own. -
New linter rule
lint:one-test-per-file: a second (third, …) top-levelfunction test_*in one scenario file is reported at its declaration. A file is the unit a collection runs and the viewer shows — the Steps tree projects a run onto the firsttest_*, so the others executed with nowhere to be seen. Fold the cases into one test with tagged steps (step("…", {tag: id}, () => { … }),step.softwhen a case must not stop the run) or move shared journeys intoflow_*helpers underunotest/e2e/_helpers/;flow_*functions in a scenario file are not counted.Migration window: the rule is a warning in 0.31 — visible in
lint, the editor andrun_test’s warnings, silenced per line with// lint-ok: <reason>, never blocking. A later minor release flips the default toerror(which the pre-run gate ofrun_testrefuses); split files that hold severaltest_*before then. -
New DSL function
readJsonLine(path, filter) → object: read a JSONL file that already exists and return the first line matching the key filter — no polling.waitForJsonLinewas the only structured input into a scenario, and for a fixture or a finished export its 20s timeout only masked a wrong path.readJsonLinetakes the same filter (strict equality per key, dot paths for nesting, unparsable lines skipped) and fails at once:file not found: <path>when the file is missing,no line matches {…} in <path>with parse stats and the closest line when nothing matches. Data-driven tests read their cases with it and name evidence after them (screenshot(q.id)). -
Data-driven steps:
step("label", {tag: q.id}, () => { … })names the case an iteration is on, andstep.soft("label", [{tag}], () => { … })records a failure inside instead of stopping the run — the rest of that body is skipped, the next case runs, and the test still ends failed with every soft failure listed (3 soft step(s) failed: Question [q17]: …, one line per case in the CLI output;run_testanswersnext.softFailures;inspect_runtime’slastFailurecarriesstepTagandsoft). The failure bundle shows the page at the moment of the first soft failure, withsoft: trueandstepTaginfailure.json; later soft failures live in the journal.step.softis allowed insidetest_*only; nesting is free, and the outer step is a group, not an assertion — a soft failure inside does not change its outcome. The run journal gainsstep-block:started/step-block:finishedenvelopes per block (label, tag, soft, outcome, whole-block duration). Editor typings declare the new forms. A worked example — cases from a table and from a JSONL file, each a tagged soft step: Port a @playwright/test spec. -
The run journal records every loop pass (
loop:iteration, with the loop statement’s position and the pass index), which the viewer’s new Trace view uses to show a loop as one group per iteration. Also: when an error unwinds through enclosing statements,lastFailure(the failure bundle’s position,inspect_runtime, the CLI’sin step "…" [tag]line) now names the innermost statement that raised it — the assert inside the tagged step — instead of the outermost loop that re-threw it. -
A bundle no longer declares the runner’s own
UNOTEST_*settings (UNOTEST_HEADED,UNOTEST_LOG_LEVEL, …) from the suite’s.env, so a box stops warning on every push that an environment “neither sets nor holds” namesenv pushnever sends. Both sides now share one rule (isRunnerSettingin@unotest/protocol): those variables configure the machine a run happens on, not the suite. A per-suite value such as the navigation timeout travels with the bundle throughunotest.config.mjs(defaultNavigationTimeoutMs), not through.env. -
unotest.config.*is merged with the defaults recursively. A partially written section —linter: { rules: { 'lint:deep-css': 'off' } },failureBundle: { tier3: { video: true } },viewport: { width: 1920 }— now keeps the rest of that section’s defaults instead of failing with “config validation failed” (nested sections require all of their fields at validation time, so a top-level replace made every partial section invalid). Arrays and scalars still replace as a whole:browsers: ['firefox']means firefox only. The setup manual’s example config, which was exactly such a partiallinter, loads as written; itstimeoutsblock — never a config field — is corrected todefaultTimeoutMs/defaultNavigationTimeoutMs. -
The viewer’s editor flags an undeclared external variable (
lint:external-variable-undeclared), the wayunotest-web lintand the pre-run lint already do: a bareUPPER_SNAKEidentifier that nounotest/.env/.secretsfile of the active environment declares is an error in the editor, naming the files searched. Protocol:DslValidateContextgainsexternalNames/externalSources; the viewer supplies them from the active target + environment’s layer files (the same files the variables panel shows) and@unotest/web’s language service turns them into the linter’s lookup. -
Viewer editor: lint markers appear as soon as a file opens (or the page reloads), not only after the first keystroke — the editor validates the source it mounted with instead of waiting for a change event that a reopened tab never fires.
-
Docs:
hovernow lists its options (force,position,timeout) in the DSL reference, the editor signature and the generated typings — it used to read as if it took none, whilepositionis exactly what a hover-revealed menu at one edge of a tall element needs. The agent integration guide gains “Watching a collection run”: the collection’ssteps.jsonljournal (path, the fourcollection-run:*events, how to tail it) as the progress source for a run started in the background, where the CLI’s per-scenario lines only arrive when the process ends. -
unotest-web lintnow receives its arguments: the bin dispatcher forwarded only the command name, solint --env <name>linted the base environment and an explicit file argument was ignored (every file was linted instead).UNOTEST_ENV=<name>worked all along, which is why the gap went unnoticed. -
lintworks on a project that has not migrated to the 0.28 layout yet. Aunotest.config.*still at the project root used to stoplintwith the same refusale2egives — right for a command that would RUN on defaults, wrong for one that only grades: nobody lints a suite during the very migration that touches every file.lintnow builds its context on the default settings (rule severities, helpers dir), readsunotest/.env,.secretsand_helpers/as usual, and reports the layout once as the newlint:legacy-layoutwarning (defaultwarn, configurable like any rule) with the migration on one line. The exit code is unaffected by it.e2e,collectionandbundle pushrefuse exactly as before. -
The MCP server says when it is running a stale build.
run_test’s pre-spawn lint, flow discovery (explore_start),explore_run_flow,generate_dsl_from_explorationandsave_exploration_as_testparse and render DSL in the server’s own process, on the modules it loaded at start — after a rebuild a new DSL feature came back as a false parse error until the server was restarted, while the runner child was already on the new build. Those replies now carryserverStale: trueand aserverStaleWarning(“restart the MCP server”) once the code on disk is newer than the server’s start; nothing changes while it is fresh. -
serverStalenow also rides onexplore_step/explore_steps(locator resolution and recording run in the server’s process),find_elementandground_elementreplies — the same flagrun_testand the exploration tools already carry once the build on disk is newer than the running MCP server. -
gotowith a relative path resolves against the runtime’s own base URL instead of the browser context’s. In a CLI run the two coincide; in an exploration recording against anenvoverride,explore_run_flowreplayedgoto('/_guard/')on the MCP session’s shared context — created with the server’s base — and landed on the wrong host. The replay now follows the session’sbaseUrl(and its variables, as before). -
screenshot(name)accepts any string value, not only a literal. The runtime always slugified whatever it received, but the validator (and thereforelintandrun_test) rejectedscreenshot(q.id)andscreenshot(textJoin(['q-', id]))— a data-driven test had to name every capture the same. Thenameslot is now string-like, as infill; a non-string argument is still anarg-kinderror. -
A section label reopened after another section is called out instead of silently splitting a step:
explore_step/explore_stepsreply withsectionHintthe moment it happens, and the generated draft carries a non-blockingSPLIT_SECTIONwarning naming the reopened entries. The recorded order of actions is never changed;autoRunandsaveare not held back by it. -
A scenario that finished in the same instant the viewer looked at it is no longer recorded as
interruptedwith a finish an hour before its start. The runner ends a run by appendingrun:finishedand then backdating the heartbeat; the viewer read those two files in the opposite order, so a steps snapshot taken a few milliseconds early could meet the backdated heartbeat, and the provisional verdict stuck in the run index while the collection reported the scenario passed. The heartbeat is read first now. A viewer restart (which rebuilds the index from disk) already corrected such records; runs indexed live are right the first time. -
Viewer, Home tab: the tile of a scenario that is running now breathes through colour and shadow only — the scale pulse is gone. It moved the tile’s box every frame, so an automation driver never saw the tile as stable and a click on it waited out its timeout. A tile that never ran no longer carries
data-age="fresh": it has no age, so the attribute is absent. -
Viewer, Home tab: clicking a scenario tile opens the scenario’s LAST RUN — the run whose verdict the tile’s colour is showing — instead of the test file. A tile that has never run still opens the test (there is no run to open), and so does every tile in schedule mode, where the schedule is edited from the test. The legend now reads “hover for history · click to open the last run”.
-
Viewer, Home tab: the tile field no longer refits itself in a loop. The header and legend take the grid’s width; when the legend wrapped at one width and not at the next, the field’s height changed, the fit followed, the width changed back — at 60 fps every tile’s box moved every frame, and an automation click on ANY tile (“element is not stable”) waited out its timeout unless forced. Header and legend now never wrap (they grow past the grid instead), so the fit converges and tiles stay put. A run opened from a tile is titled by its scenario, like one opened from the Runs tree, instead of by its run id.
-
Recording no longer bakes relative time or counters into a locator. An accessible name such as
Run smoke — passed, 7m ago,just now,yesterday,5 passed,7 runsor a trailing badge (Inbox · 3,Errors: 12) now counts as volatile: the recorder emits the stable prefix ({name: "Run smoke — passed", exact: true}, then/^Run smoke — passed\b/) instead of the literal, and the draft carries theDYNAMIC_TEXTwarning with that prefix. Bare numbers elsewhere stay stable (Page 2,Q4 2026,v2,2FA). -
Updated dependencies [b8e105b]
-
Updated dependencies [b8e105b]
-
Updated dependencies [b8e105b]
-
Updated dependencies [9ae6d59]
-
Updated dependencies [56784b3]
-
Updated dependencies [e048334]
-
Updated dependencies [dcf4c9e]
-
Updated dependencies [100ab9d]
-
Updated dependencies [100ab9d]
-
Updated dependencies [100ab9d]
-
Updated dependencies [100ab9d]
-
Updated dependencies [ad7c918]
-
Updated dependencies [ad7c918]
-
Updated dependencies [ad7c918]
-
Updated dependencies [9ae6d59]
-
Updated dependencies [13a2d51]
-
Updated dependencies [91fb562]
-
Updated dependencies [dcf4c9e]
- @unotest/core@0.31.0
- @unotest/protocol@0.31.0
- @unotest/viewer@0.31.0
- @unotest/dsl@0.31.0
- @unotest/grounder-client@0.31.0
0.30.0 — 2026-09-03
-
On a hosted viewer (a box), the project and environment you are looking at are now named and switched in the viewer’s own tree header, in place of the checkout directory it used to show there (
current, which told nobody anything). Previously the only way to another environment was logging out and using the picker page. The chevrons appear on hover, like the refresh button beside them, and only when something fronts the viewer and offers it a list; a plain local viewer keeps the folder name it always showed. Because each environment on a box is its own viewer process, picking one is a full page load into that viewer, and the menu shows which test bundle each environment is running. The status bar’s.env.<name>overlay switcher is now labelled “env overlay”, so the two senses of “environment” no longer share a name.@unotest/viewer/sessioncarries the two URLs this needs (environmentsUrl,switchEnvUrl) and theViewerEnvOptiontype, both optional — a proxy that does not offer them gets the old behaviour. -
A run started in the first moments after the viewer boots is now discovered: the run watcher finishes its initial scan of the day directory before reporting itself started, instead of filing such a run as history.
-
bundle pushandenv pushno longer send you to “the box’s admin page” for a project token — there is no such page; tokens are issued per project by the box’s operator, and the refusal hints and the missing-token message now say so. -
The vocabulary’s description of
evaluatenow says how extra arguments reach the body — none → nothing, exactly one → the value itself, two or more → one array — matching the runtime, the linter’s hint and the authoring skill; it used to read as if they were positional (evaluate(js, ...args)), and so did the generated DSL typings. -
A scenario that answers a native
confirm()— a “Remove” button that asks first, say — no longer fails whenrun_testauto-attaches the MCP session to the run’s browser, or afterattach_debug_session. The attaching client now applies the same dialog policy the run does (dialogPolicy, accept by default) to the page it watches; before, it listened for no dialogs at all, and Playwright dismisses every dialog on behalf of a client that has no listener — so the second pair of eyes was answering “no” before the run’s own accept could land. The same scenario passed with an exploration session open only because that session happened to keep the run’s browser to itself. Undermanualthe attached client leaves the dialog to whoever handles it instead of dismissing it. -
On a box, an administrator can now see a secret’s value from the guard’s values page — the eye reveals it in the row, the copy button puts it on the clipboard without showing it — and every look is a
secret.revealedentry in the box’s audit trail, naming who, which secret and whether it was shown or copied. The page is one table now (target, variables, secrets), edited in place, with a JSON view that changes the whole environment at once; a secret left as"••••"there is kept as it is. The token doorenv push/env settalk to is unchanged: secrets stay write-only on the wire.@unotest/protocolcarries the contract:boxEnvSecretRevealPath/boxEnvSecretsRevealPathwith their response parsers, the session-door batchBoxEnvValuesReplaceRequest(nullfor a secret means “keep”) andparseBoxEnvValuesReplaceRequest, and the refusal codeunknown-secretfor a reveal or a keep that names a secret the environment does not hold.isEnvVarNamenow refuses__proto__: it is spelled like a variable, but a plain object cannot hold it and the value was silently lost. The CLI explains anunknown-secretrefusal. -
evaluate(js, …args)has always handed the body its extra arguments in one of three shapes — none, the single value, or ONE array for two or more — while the authoring skill described them as positional. The skill now states the rule with both spellings (evaluate('function(n){…}', 21)/evaluate('([a, b]) => …', a, b)), and thelint:evaluate-discouragedwarning adds the same hint when it sees two or more extra arguments; the runtime is unchanged. The skill also gains the getters it left out (count,getInputValue,textContains), the note thatwaitForTextnever sees a textarea’s value, the parser’s refusal of!/indexOf/ string methods, the shared-stand rule against “the first row” (mark your own data, filter by it, clean up), that native dialogs are accepted by the runner, and what to do when the grounder is unavailable. -
While recording with
explore_step/explore_steps, the bare NAME of a scenario variable is substituted only where the step types a value into the page —fill’s value,press’s key,select_option’s value,goto’s url. In a locator’s text or an assertion’s expected text a bare name is now the literal it looks like:assertText(el, "BOX_LAB_PASSWORD")expects those letters, where before the live step silently waited for the secret’s value and the saved test said something else. To reference a variable in a matcher, write{{NAME}}; the reply warns when a literal happens to equal a variable’s name. And a secret’s value typed into a value field is recorded as the secret’s name (the reply says which), so the value never lands in the test file. -
run_testno longer reportsspawn_failedabout a runner that is still starting. Two runs launched together on a busy box took longer than the fixed 10-second wait to write their firstruntime.json, and the second was declared dead while it was booting a browser. The wait now lasts as long as the runner child is alive (up to 60 seconds), ends early when the child joins the run queue, and — when the child really dies — says so with its exit code and the last lines of its stderr, which the runner now writes tospawn.stderr.login its run directory.explore_startnow reports whether the grounder is usable for the session:grounder: {available: true}, or{available: false, reason, hint}when grounding is off, the backend is unreachable, or a required model is not pulled ("embeddinggemma" is not available there — pull it: \ollama pull embeddinggemma“). An intent step in such a session fails with the same words, not with the backend’s raw error; the probe is one short request per session.Secret masking no longer rewrites the runner’s own identifiers in
runtime.json: withLOGIN=admin, the scenario pathguard-admin.js, the test functiontest_guard_adminand the run id kept their names ininspect_runtimereplies instead of turning intoguard-‹secret:LOGIN›.js. Masking by value is unchanged everywhere else; the run id, scenario path, test function and call-stack names are an explicit exception. -
save_exploration_as_test(andgenerate_dsl_from_exploration) now write scoped locators — agetByRole/getByText/locator(css)step under a parent, such as the “Reveal” button inside one table row — as the chain the DSL already runs:locator('tr[data-row="A"]').getByRole('button', {name: 'Reveal', exact: true}). Before, any recorded step whose locator went below its root was skipped withNO_DSL_PRIMITIVE, and the save was refused, although the step had executed and the hand-written form worked. Every locator is now rendered in the same method-chain spelling — narrowing steps too (getByRole('row').filter({hasText: 'Alice'}).first()instead offirst(filter(getByRole('row'), {hasText: 'Alice'}))); both spellings remain valid input, the runtime is unchanged. -
Updated dependencies [23de03c]
-
Updated dependencies [9f2e244]
- @unotest/viewer@0.30.0
- @unotest/protocol@0.30.0
- @unotest/core@0.30.0
- @unotest/dsl@0.30.0
- @unotest/grounder-client@0.30.0
0.29.0 — 2026-09-02
-
New
env push,env setandenv rmcommands send an environment’s values to a box — the target, the variables and the secrets a suite runs with there — from the same files a local run reads (unotest/.env,.env.<env>,.secrets,.secrets.<env>), or from files named with--env-file/--secrets-file..env*become the box environment’s variables,.secrets*its secrets andAPP_BASE_URLits target;UNOTEST_*and empty values stay home and are listed as skipped. A push replaces the box’s layers and reports what it removed;env setreads one value from stdin, never from the command line. Values never print. The commands use the project token ofbundle push, minted on the box with the new--valuesscope — a token without it is refused, so a CI token that pushes suites cannot rewrite an environment’s credentials.@unotest/protocolcarries the wire (box-values.ts) andTARGET_ENV_VAR. The viewer’s messages about box-held variables now point at these commands instead of the box’s shell. -
The Variables panel of a viewer on a box now lists what the box injects into every run of the environment — the target URL, the operator’s variables and the names of the environment’s secrets — as read-only rows with a
boxbadge. It used to readunotest/.env*, which never travel in a bundle, and show an empty panel next to a full run history; a secret’s value is still never sent, only its name. The box passes the names throughUNOTEST_BOX_VARIABLE_NAMES/UNOTEST_BOX_SECRET_NAMES(constants in@unotest/protocol); writes to such a key are refused with 409 and point atboxd.config.mjs/boxd.mjs secret set. -
UNOTEST_BROWSERandUNOTEST_BROWSER_CHANNELnow exist as per-run overrides ofbrowsers[0]andchannelinunotest.config.*.UNOTEST_BROWSERwas shown in the CI manual and the.env.exampletemplate, but nothing read it — a browser matrix ran Chromium three times. A value outsidechromium | firefox | webkit(orchrome | msedge | chrome-beta | bundled) is an error, not a silent default. The config travels with the suite in a bundle, so achannel: "chrome"written on a laptop used to ask a box for a Google Chrome it does not have; a box now setsUNOTEST_BROWSER_CHANNEL=bundledfor every run and the run log names each override. Constants:BROWSER_ENV,BROWSER_CHANNEL_ENV,BUNDLED_BROWSER_CHANNELin@unotest/protocol. -
initwithout--forcestill leaves an existingunotest/.env.examplealone, but the skip now names the keys the current template declares that the file does not — a project seeded on an older version never learnt aboutUNOTEST_STEP_SCREENSHOTS, because nothing compared its file with the template. The file is not touched.declaredEnvKeys()in@unotest/protocolreads active and commented-out keys alike. The DSL reference now documentsUNOTEST_STEP_SCREENSHOTSnext toscreenshot(): readers of that page concluded the every-step mode did not exist. -
shell()tells a missing working directory apart from a missing binary. Node reports both asENOENT, and the old message sent people looking for a tool that was there all along while the real cause was asandbox.shellCwdthat exists on the laptop and not in CI or on a box. The directory is checked before the spawn and the newShellCwdMissingErrornames it and where it came from. -
Updated dependencies [ab296ff]
-
Updated dependencies [64108b8]
-
Updated dependencies [64108b8]
-
Updated dependencies [64108b8]
- @unotest/protocol@0.29.0
- @unotest/viewer@0.29.0
- @unotest/core@0.29.0
- @unotest/dsl@0.29.0
- @unotest/grounder-client@0.29.0
0.28.0 — 2026-09-01
-
BREAKING. The suite is its own npm package now:
unotest/package.jsonpins@unotest/web,unotest.config.*moved intounotest/, and a bundle carries that directory and nothing above it.A bundle used to carry the project’s root
package.jsonand lockfile, and a box rannpm ciover the whole product tree to obtain one dependency. On a real customer that failed outright: their own peer conflict (@nestjs/core@10against@nestjs/websockets@11) was hidden locally behindlegacy-peer-deps=truein~/.npmrc, which does not travel in a bundle and must not — registry tokens live there. The bundle arrived, the install died, the environment never got it. The same class of failure was waiting for anyone with a private registry, a native build or apreparehook. Fixing it by editing the customer’s dependency stack is not an option: a test runner has no business in the dependency graph of the application it tests.What follows from it: we no longer dictate your package manager (the “your project locks with pnpm, and a box installs with
npm ci” refusal is gone — npm is now only aboutunotest/), a box install shrinks from the product tree to the suite’s own dependencies, and upgrading@unotest/webcan no longer collide with your application.The CLI finds the project root by walking UP for
unotest/e2e, the way git finds a repository, socd unotest && npx @unotest/web e2e <name>works as well as running from the root. Relative path arguments resolve against the directory you typed them in.Migrating from 0.27:
npx @unotest/web initgit mv unotest.config.mjs unotest/unotest.config.mjsnpm install --prefix unotestand drop
@unotest/webfrom your application’spackage.json. The old layout is diagnosed rather than ignored: a config left at the project root fails the load with these instructions, because the alternative is a suite running silently on defaults — nobaseUrl, nosandbox, no schedules.Two traps worth knowing about, both named by that diagnostic:
unotest/jsconfig.json— anincludewritten for the old layout points at../node_modules, where the package no longer is, and editor autocomplete for the DSL dies silently. It is your file, so nothing rewrites it:../node_modules/becomes./node_modules/.- a config that derives paths from its own location
(
dirname(fileURLToPath(import.meta.url))) now sits one level deeper, so those paths need a...
On the box side: bundles pushed before 0.28.0 are refused with an explicit message instead of being installed — push them again from 0.28.0 or newer. Without that,
npm ciin aunotest/that has no manifest would walk up the tree (that is how npm behaves), install the old root manifest, and leave the boxunhealthyfor twenty minutes without saying why. -
@unotest/viewer@0.28.0
-
@unotest/core@0.28.0
-
@unotest/dsl@0.28.0
-
@unotest/grounder-client@0.28.0
-
@unotest/protocol@0.28.0
0.27.0 — 2026-08-31
-
viewerstarts quietly: the Nest boot log (a line per module and route) no longer floods the terminal — you get the viewer URL and nothing else. Errors and warnings still print;UNOTEST_DEBUG=1brings the full boot map back. -
The LOCAL viewer now shows the same effective schedule set a box would run — what you see is what a push ships, 1:1.
- Locally the schedule registry reads the merged set through
schedules --json(the same path a box uses): config-declared entries are finally visible, marked read-only (“declared inunotest.config— edit it there”); yaml entries stay editable as before. A runner without a schedules command falls back to the yaml half. bundle pushrecords a fingerprint of the effective set in the bundle manifest. The box re-computes the set in its own environment and — when a config that readsprocess.envcomputes something different there — warns in its log, and the deployed viewer shows a drift line instead of letting “what I see is what I pushed” stand.bundle pushnow refuses to pack a project whose schedules registry cannot be read — the same broken yaml used to upload fine and un-schedule the suite on the box.npx @unotest/web schedulesmarks entries outside the previewable cron subset (not previewable — an executor may drop it), and the new--checkflag turns that mark into a non-zero exit for a pre-push hook or a CI gate.
- Locally the schedule registry reads the merged set through
-
Schedules stop lying on a box, and a wrong cron is caught before the push.
npx @unotest/web schedulesnow previews every entry’s next run —next tomorrow 03:00— on this machine’s clock (an executor ticks in its own timezone, and the output says so). Expressions outside the schedule-builder subset are shown verbatim, never guessed at.- On a box the viewer shows the EFFECTIVE schedule set — the config’s
schedulesmerged withunotest/schedules.yaml, read through the project’s own CLI — as read-only, with a badge: schedules are declared in the repo, and the box runs whatever the deployed bundle declares. Editing there used to write into a disposable bundle copy nothing ever re-read; now the popup points at the repo and the API refuses the write (403).
-
Schedules from the viewer — for collections and single tests.
Right-click a test or a collection in the viewer → Schedule…: a human schedule builder (frequency select — every 5/15 min, hourly, every 2/4/6/12 h, daily, weekly — plus toggle badges for minutes, hours and weekdays; multi-select, so
04+16×00+30runs four times a day). Saved tounotest/schedules.yaml, versioned next to the tests; a box applies it after commit & sync. The Home screen gains alast run | scheduletoggle: in schedule mode tiles are tinted by how often a test runs (grey = not scheduled), with an upcoming list and per-tile next-run times in your browser’s clock.Under the hood:
unotest/schedules.yamlis a new schedule registry;unotest-web schedules [--json]prints it merged with the config’sschedules(the file wins on a duplicate target), so boxes pick both up unchanged.- A schedule entry may now target a single test:
scenario: <ref>instead ofcollection: <name>. unotest-web e2e <name> --scheduled[=index]runs a test the way its entry says (env + prepare, exit 94 when prepare fails) — the per-test mirror ofcollection --scheduled.- Viewer server:
GET/PUT /api/schedules(validated by the same parser the CLI reads the file with).
-
Updated dependencies [c843d21]
- @unotest/protocol@0.27.0
- @unotest/viewer@0.27.0
- @unotest/core@0.27.0
- @unotest/dsl@0.27.0
- @unotest/grounder-client@0.27.0
0.26.1 — 2026-08-30
-
A push now says which environments could not run the suite
The values a suite’s externals need live with the environment on a box — no bundle carries them, deliberately — so a suite pushed to a box that has none of them was accepted happily and went red an hour later, complaining about a missing login. The bundle now carries the NAMES its
.env/.secrets(or their.exampletwins) declare — names only, never values — and the box answers the push with what it cannot supply, per environment, plus the command that sets each one. -
One command cuts the whole release
publish:ecosystemnow also commits the docs pin and cuts the box release (theguard-v<version>tag) when something a box runs changed since the last one —--skip-boxrefuses,--boxforces. Two artifacts from one commit used to be two commands, and the second one is the one people forget: a fleet on yesterday’s daemon while npm has today’s. What is left for a human is onegit push --follow-tags. -
A collection prepares its own environment:
prepare:inunotest/e2e/_collections/<name>.yamlprepareused to live only in aschedulesentry, so it ran on a scheduled tick and nowhere else. A suite that needs a seeded database was therefore red for every other way of starting it — a CI check of a deployment, the Run button in a box’s viewer — with a message about a missing table rather than about an unprepared environment. It belongs to the suite, so it now lives with the suite: the collection’s ownprepare:runs however the collection was started, inside the same queue slot, and a failure still exits 94 rather than pretending the tests ran. Aschedulesentry may still name its own (it wins for that tick), and--no-prepareskips it for a run against an environment somebody else prepared. -
Fix: the mirror shipped the monorepo’s
.secretsverbatimpublish-examplescopiedunotest/.secretsinto the public examples repository as.secrets.example. That was harmless while the file held only the playground’s public demo login, and stopped being harmless the moment a real credential was added to it. Secret KEYS are now spelled out in the generator (values only where they are genuinely public), and a guard refuses to publish when the two lists disagree — the same discipline.envhas had. -
Fix:
bundle push --run --env <name>refused to run--envwas consumed globally, where it selects a local.env.<name>overlay, and never reachedbundle push— whose--envnames an environment ON A BOX. The command every CI job and the box manual use (“—run needs —env”) could not work as documented.bundlenow keeps its own flag; every other command is unchanged. -
Fix: a viewer started for an environment listed no runs at all
A box starts one viewer per environment and names it through
UNOTEST_ENV; the viewer’s launcher ignored that variable and came up on the base runs directory, while runs were filed under.runs.<environment>. Effect on a box: an empty run history right after a suite finished. -
Fix: screenshots stayed blank in the viewer when runs write to a separate artifact root
A run whose artifacts do not live under the working directory — every run on a box, where the sources are a disposable copy of a test bundle — emitted
screenshotevents whose path was relativised against the working directory. That came out as../../../…, which the viewer’s asset route refuses to serve, so every step preview rendered as a broken image. Artifact paths are now relative to the artifact root, which is still the project root on every local run.The failure bundle had the mirror-image problem: it was written to
.unotest/failures/under the working directory, so on a box it landed inside the bundle copy — outside the tree the viewer serves, and discarded on the next bundle push. It now hangs off the artifact root too, which leaves local runs byte-for-byte unchanged.The inspector’s Semantic DOM, Console, Network and Snapshot panes were dark for a third reason, and this one bit local runs as well: they composed the run’s directory in the browser and left out the date shard runs have been filed under since M-10, plus the environment suffix on a box. The viewer’s asset route now takes
?file=<name-relative-to-the-run-dir>and resolves the directory itself — runs root, target suffix, environment suffix and shard are all things only the server knows.?path=stays for the whole paths the server ships on run events. -
bundle pushstrips the project’s own install hooks (postinstall,prepare, …) from the packedpackage.json. A box installs the suite’s dependencies; the project’s hooks are not its business, and they point at files a bundle deliberately does not carry (build scripts, Makefiles), sonpm cion the box died with MODULE_NOT_FOUND. Dependency scripts — native module builds — are untouched, and the push reports what it removed on anote:line. -
Updated dependencies [9424f6b]
- @unotest/protocol@0.26.1
- @unotest/viewer@0.26.1
- @unotest/core@0.26.1
- @unotest/dsl@0.26.1
- @unotest/grounder-client@0.26.1
0.26.0 — 2026-08-30
-
The viewer can now be hosted: it shows which test bundle an environment is running, and lets you switch it.
This is the other half of
bundle push. Nothing changes for a local viewer — everything here appears only when something is hosting it.- The artifact root and the project root are now genuinely separate.
The viewer already took
UNOTEST_ARTIFACTS_ROOTfor the run queue; run discovery, the run index, snapshots, log capture and the viewer’s own lock file now honour it too. Locally the two are the same directory and nothing moves. Where they differ — sources in a disposable copy of a bundle, history in the environment beside it — the history is no longer read out of (or written into) the sources. - Bundle badge in the status bar.
main@1a2b3c4, orwipfor a bundle packed from an uncommitted tree, with the author, the pinned@unotest/weband who switched the environment last. Clicking it lists the bundles pushed to this environment and switches to one — that runs as a maintenance ticket on the host, so it waits for the environment to be free instead of pulling the code out from under a running suite. - Runs remember their bundle.
manifest.jsonandGET /api/runscarrybundleId(fromUNOTEST_BUNDLE_ID, set by the host); the runs list marks the ones that ran on another bundle, so “was this red run even the code I am looking at?” has an answer. - The contract is a file, not a mode.
@unotest/protocolgainsWorkspaceFile— the host writesunotest/.workspace.jsonbeside the artifacts and the viewer reads it, the same way the guard’s session header narrows the UI. No file, no bundle UI; the viewer never learns what a box is. Values of environment variables and secrets never appear in it — only their names. - Queue tickets can name a bundle.
QueueTicket.source.bundleIdis recorded when a run is queued, so a run executes the code it was ordered with even if the environment moved on while it waited.
- The artifact root and the project root are now genuinely separate.
The viewer already took
-
npx @unotest/web bundle push— send your suite to a box, instead of giving a box access to your repository.Terminal window npx @unotest/web bundle push --box https://tests.example.comIt packs
unotest/,unotest.config,package.jsonand its lockfile into one archive and uploads it. That is the whole channel: a box holds no deploy key and no token for anybody’s code and never pulls anything, so nothing is on it that was not pushed to it. (There is no path-scoped git credential anywhere — a key that can fetchunotest/can fetch the product source next to it, which is why this direction is the only one we offer.)- Uncommitted work travels. The manifest records
dirty: trueand the bundle shows up as wip, so pushing a fix you have not committed yet is a normal loop, not a release step. - What would fail on the box is refused here. A scenario that reads a
file outside
unotest/(MODULE_NOT_FOUNDon the box, hours later, on a run nobody is watching), a missing lockfile (npm ciwill not run without one), no@unotest/webin the dependencies, a symlink. Each problem names the file, the line and the fix. - Secrets do not travel.
unotest/.env*and.secrets*are excluded even if committed: environment values belong to the environment and are injected over the bundle when it runs. - In a git project, git decides what belongs. The file list comes from
git ls-files --cached --others --exclude-standard, so whatever your.gitignorekeeps out — generated databases, recordings,.runs— stays out. Content is read from the working tree, not the index. - The id is the content.
bundleIdis a hash of the packed files, not a commit sha (a dirty tree’s sha is not unique), so re-pushing an unchanged suite is an instant no-op and two people packing the same tree get the same bundle. --dry-runpacks and checks without uploading,--out <file>keeps the archive (plaintar.gz—tar -tzfshows what is inside),--jsonprints{ bundleId, status }for a CI job that runs it afterwards.UNOTEST_BOX_URL/UNOTEST_BOX_TOKENsupply the address and the token, which a CI job usually takes from its secret store. Tokens are issued per project on the box.
- Uncommitted work travels. The manifest records
-
Your CI can now ask a box to run the suite it just pushed, and a box can report the result back to GitHub.
Terminal window npx @unotest/web bundle push --run --env test --collection smoke--runsends the second half of the CI recipe: upload the bundle, then order a run of it. Two requests rather than one flag on the upload, because a bundle is content — pushing an identical tree twice is normal and answersexists— while “run it” is an event. The same split is what lets a deploy script run a bundle somebody pushed hours ago.- The box answers as soon as the runs are queued (202) with their run ids. A suite takes minutes to hours; an HTTP request held open for that long is a timeout in somebody’s proxy, not a result. The ids are real from the moment you get them, so a CI log can print a link into the viewer straight away.
--env <name>is required with--run. Which environments a box has is the operator’s decision, and guessing one would be guessing which system gets tested.- Without
--collection, the box runs what that environment normally runs — the collections itsschedulesname. Naming them explicitly is the deploy-check case; leaving it out is the “same as every night” case. --pr <number>coalesces. A newer push of the same pull request withdraws its older runs that are still waiting; a run that already started is never killed. Three pushes in five minutes cost one suite.--jsonreports how many were withdrawn, so “my run vanished” has an answer in the log of the run that replaced it.- Refusals are typed and say what to do: no such environment, an environment belonging to another project, a bundle the box no longer holds, nothing to run.
On a hosted viewer, the queue panel now names the change each machine ticket belongs to (
PR #412@1a2b3c4) instead of a row of identical “ci” entries, and the history gained a this bundle only filter for the moment you are judging the bundle in front of you rather than the last month of runs.Nothing changes for a local
npx @unotest/web viewer: there are no bundles there, so neither the badge nor the filter appears. -
Runs of one project now take turns instead of colliding.
Until now nothing coordinated them: the viewer refused a second run with HTTP 409, and that was the whole defence — a
npx @unotest/web e2ein a terminal, an agent’srun_testand a scheduled suite would all start on top of each other, driving the same browser and the same seeded database at the same time. The 409 protected the UI, not the machine.- A filesystem queue, in
unotest/.queue<target>[.<env>]/beside the runs root it belongs to. A run writes a ticket, waits until it is at the head, takes a slot, runs, and gives the slot back. There is no daemon: whoever holds the slot executes the run itself, so nothing new has to be installed, started or kept alive. Onlyopen("wx"), rename, unlink and mtime — noflock, which lies on network and container filesystems. A crashed producer is reaped by whoever comes next. - Every producer is in it: the CLI (
e2e,collection,author), the viewer’s Run button, and the MCPrun_test(which spawns the CLI). A collection is ONE run — itsworkersstill run in parallel inside it, because a child process carries its parent’s lease and skips the queue. - The viewer shows the queue: the Active panel lists what is waiting,
including tickets a terminal or an agent wrote, with a button to take
any of them back out.
GET /api/queue,DELETE /api/queue/:ticket, and aqueue:changedwebsocket message. - Config:
queue.concurrency(default 1) andqueue.enabled(default true) inunotest.config. Both the CLI and the viewer read the same file, so they cannot disagree about how many slots exist. - Escape hatches:
UNOTEST_NO_QUEUE=1runs without queueing; Ctrl-C while waiting gives up your place in line and runs nothing. - For boxes:
UNOTEST_QUEUE_GLOBAL_DIR+UNOTEST_QUEUE_GLOBAL_SLOTSadd a host-wide budget several environments share (a collection weighs its worker count), andUNOTEST_ARTIFACTS_ROOTputs.runs/.queuesomewhere other than the directory the tests live in — so the history outlives a throwaway copy of the tests.
Migration notes
POST /api/runno longer answers 409. Ordering a run while another one is active used to fail withActiveRunConflictError(HTTP 409); it now succeeds and the run waits. The response changed shape with it:{ runId, kind, ref, pid, startedAt }{ runId, kind, ref, status: "queued" | "running", ticket, acceptedAt }runIdis still final and immediately usable — open the tab on it as before — but the run may not have started yet, andpidis not known at that point.POST /api/runs/:runId/abortcovers both states: it withdraws a waiting ticket or SIGTERMs a running child.Anything that treated 409 as “busy, try later” should now simply order the run. Anything that relied on “only one run can exist” should read
GET /api/queue. To keep the old immediate-start behaviour (without the protection), setqueue.enabled: false. - A filesystem queue, in
-
Scheduled runs: declare what should run on its own, next to the tests it runs.
unotest.config.mjs export default {schedules: [{ collection: "smoke", cron: "0 * * * *" },{ collection: "nightly", cron: "0 3 * * *", prepare: "node seed.mjs" },],};schedules— a new config leaf: collection + cron, plus an optionalenv(which environment the run happens in) andprepare(a command run before the suite). Versioned with the tests, so “what is supposed to run nightly” is in the repo instead of on a machine nobody can see.npx @unotest/web schedulesprints what is declared;--jsonis the machine shape a scheduler reads back. Each entry also carries the collection’sworkersresolved from its yaml manifest, so an executor can weigh the run without parsing yaml itself.npx @unotest/web collection <name> --scheduledruns a collection the way its schedule does: in the entry’senv(unless you set one) and with itspreparecommand first, inside the same run slot (preparing touches the target, which is exactly what the run queue serialises). A failed prepare exits 94, not 1 — “the environment was not prepared, nothing was tested” is a different event from “the suite is red”, and alerting has to tell them apart. A collection listed inschedulesmore than once needs--scheduled=<index>(zero-based) — guessing which entry’s prepare to run is refused.
Nothing in this package ticks.
schedulesis data: no cron parser, no timers, no background runs. Cron is executed by whatever hosts your runs — your CI (on: schedule+unotest-web collection), or a box. A clone of a repo that declares schedules therefore never starts running suites by itself;unotest-web schedulessays so out loud instead of implying a timer that does not exist. -
Updated dependencies [3eede7c]
-
Updated dependencies [6d1c612]
-
Updated dependencies [dbfa36f]
-
Updated dependencies [c43aa00]
-
Updated dependencies [8ec4177]
- @unotest/viewer@0.26.0
- @unotest/protocol@0.26.0
- @unotest/core@0.26.0
- @unotest/dsl@0.26.0
- @unotest/grounder-client@0.26.0
0.25.0 — 2026-08-29
-
Viewer: a pinned Home tab, a multi-row tab strip, and live tiles.
The viewer ships with
@unotest/weband opens vianpx @unotest/web viewer, so the change lands here too. The project-health overview used to render only when no tab was open — it disappeared exactly while you were working. It is now a pinned first tab: always present, never closable, and where “Close All” leaves you. The tab strip wraps into up to three rows instead of scrolling sideways, tabs are pills (middle-click closes,⋮opens the right-click actions), and the strip is a propertablistfor screen readers. A scenario running right now breathes on the overview field: the tile fades under a warm ring, still when paused. Full notes in@unotest/viewer’s changelog. -
Judge: provider edge cases, a startup preflight, and logs.
Vertex
GOOGLE_CLOUD_LOCATION=globalcould not work. The endpoint was built ashttps://<location>-aiplatform.googleapis.com, soglobalaskedglobal-aiplatform.googleapis.com— not a Vertex host. Google’s edge answered 404 with an HTML page, which arrived as “model not found”. The global endpoint has no region prefix; it is now special-cased.globalis the default in Google’s own docs, so this was the first thing a Vertex user hit. A model id containing a slash (publishers/anthropic/models/claude-haiku-4-5) is now passed through instead of being forced underpublishers/google, and a pastedmodels/gemini-2.5-flashno longer doubles the prefix on the Gemini API.Backend replies are classified instead of lumped together.
401/403→configwith the variable to fix (OPENAI_API_KEY, ADC, an expiredUNOTEST_JUDGE_ACCESS_TOKEN), no longer indistinguishable from a model fault.429and5xx→ retried transparently with a short backoff that honoursRetry-After. A single rate limit used to kill a whole scenario. The retry policy is a decorator overJudgeProvider, not a loop inside the HTTP helper, so it covers every provider —claudespawns a process and therefore had no re-send at all. Aclaudeusage limit is deliberately still reported rather than retried: the window is seconds, the limit is not.- A non-JSON error body says the endpoint is wrong rather than quoting an
HTML page back as the backend’s opinion of the model. The body is kept
for
debuglogging. - OpenAI
finish_reason: lengthand Anthropicstop_reason: max_tokensare named as token-limit truncation instead of surfacing as “did not reply with the requested JSON verdict”. - The verdict parser accepts
"PASS"and a JSON object embedded in prose — providers without a JSON mode do both.
UNOTEST_JUDGE_VOTE=Nno longer fails on one bad ballot. The N calls ran underPromise.all, so voting — the reliability feature — tripled the chance that one transient error killed the request. Errored ballots are now dropped as long as a majority of the N calls still voted; a tie among the survivors re-raises the failure rather than picking a side.The service checks its credentials before it says “listening”. Each provider exposes a free probe (an ADC token exchange,
GET /models,claude --version) — no model call, no cost. A failing probe prints the reason and exits non-zero (UNOTEST_JUDGE_SKIP_PREFLIGHT=1waives it), andnpx @unotest/judge --checkruns just the probe for start scripts.GET /healthreports the same thing instead of an unconditional{"ok":true}:503+{"ok":false,"code":"config","error":"..."}, cached for a few seconds. The waiver covers the startup gate only — a service started under it still answers/healthfrom a real probe, and--checkstill answers honestly, because the operator who reaches for the flag is precisely the one whose credentials are dead. A staticUNOTEST_JUDGE_ACCESS_TOKENis validated too, against Google’s freetokeninfo: those tokens live about an hour and refresh themselves never, so “valid at startup” is a different question from “valid now”.ADC that expires on a daily reauth policy is a normal morning state, not a one-off misconfiguration — it should not cost a five-minute run to discover.
New:
UNOTEST_JUDGE_LOG_LEVEL(falls back to theUNOTEST_LOG_LEVEL@unotest/webalready reads). At the defaultinfo, one line per judged request — verdict, attempts, model, latency — with errors on stderr; a run of 21 assertions withVOTE=3is 63 billed model calls that previously left no trace. Atdebug, per call: the effective prompt (rubric including anyUNOTEST_JUDGE_PREAMBLE), each ballot with its reasoning, and the raw provider reply. The judged text is application content — debug writes it to stdout verbatim, which is why it is opt-in and never the default.An oversized request body now returns
413with a reason; the socket used to be destroyed while the client was still writing, so the client sawsocket hang up.@unotest/web: thejudge:verdictrun artifact recordspreamblebesiderubricwhen one is set. The model saw the concatenation, but only the split says which half came from the scenario and which from the environment — without it, “a red step is explainable without a re-run” held only for whoever also knew the env file. A judge request that outlivesUNOTEST_JUDGE_TIMEOUT_MSnow says so, and names the service-sideUNOTEST_JUDGE_CALL_TIMEOUT_MSit may be racing.The Claude CLI provider also stopped leaking a timer: the per-call budget is now our own timer, cleared on every exit path.
child_process.spawn’stimeoutoption does not clear itself when the spawn fails, so a missing binary held the event loop for the whole budget — 15s for a--versionprobe, 120s for a verdict.Breaking (0.x minor):
@unotest/judgeno longer exportsExecFileFn/ExecFileResult; the Claude CLI provider takesrunImpl: RunProcessinstead ofexecImpl, because the prompt moved fromargvto stdin — a long judged text used to overflowARG_MAXand fail as a bareE2BIG. -
Updated dependencies [70f2e11]
-
Updated dependencies [adb9d72]
- @unotest/protocol@0.25.0
- @unotest/viewer@0.25.0
- @unotest/core@0.25.0
- @unotest/dsl@0.25.0
- @unotest/grounder-client@0.25.0
0.24.0 — 2026-08-26
-
Judge: rendered text, one env namespace, preamble and majority vote.
Breaking (judge env, no aliases). Every
JUDGE_*variable of the@unotest/judgeservice is nowUNOTEST_JUDGE_*, matching the client-side names@unotest/webalready used.@unotest/judgeshipped a day before this change with no known installs outside the monorepo, so the rename lands without a deprecation window — the window would never be cheaper. Migration:Old New JUDGE_PROVIDERUNOTEST_JUDGE_PROVIDERJUDGE_MODELUNOTEST_JUDGE_MODELJUDGE_RETRIESUNOTEST_JUDGE_RETRIESJUDGE_TIMEOUT_MSUNOTEST_JUDGE_CALL_TIMEOUT_MSJUDGE_ACCESS_TOKENUNOTEST_JUDGE_ACCESS_TOKENJUDGE_CLAUDE_BINUNOTEST_JUDGE_CLAUDE_BINJUDGE_HOST/JUDGE_PORTUNOTEST_JUDGE_HOST/UNOTEST_JUDGE_PORTJUDGE_TOKENUNOTEST_JUDGE_TOKENTwo names were deliberately not a straight prefixing. The service’s per-call budget became
UNOTEST_JUDGE_CALL_TIMEOUT_MSbecauseUNOTEST_JUDGE_TIMEOUT_MSwas already taken by the client’s whole-request budget — same family, different question. And the service’s bearer token merged into the client’sUNOTEST_JUDGE_TOKEN: it is one secret, and in a shared overlay the two ends have to carry the same value anyway.Behavioral fix: rendered text.
assertJudgeand the DSL querygetInnerText(loc)now readinnerText, nottextContent. Both promised rendered text and delivered the raw source-order concatenation: line breaks gone,display:nonesubtrees and<script>contents folded in. A rubric about structure (“the answer is three bullets”) was unjudgeable, andgetInnerTextreturned text no user can see. Scenarios that relied on hidden text reaching these two functions will now see it excluded.Vertex fixes.
google-auth-librarymoved from optional peer to a regular dependency: as a peer it did not resolve undernpx @unotest/judgeat all, which broke the advertised keyless path for anyone who never installs the package locally. It is still loaded lazily, so non-vertex runs pay nothing. Expired Application Default Credentials now produce “rungcloud auth application-default login” instead of a raw Google OAuth blob, read off the structured response body rather than message text.New:
UNOTEST_JUDGE_PREAMBLE(web side) — text prepended to every rubric in the project, for standing context (“the app under test is a support bot, Russian UI”) that would otherwise be copy-pasted into each rubric. It applies in both local and remote mode; a remote judge stays project-agnostic.New:
UNOTEST_JUDGE_VOTE=N(service side, odd N, default 1 = off) — N independent calls decided by majority. It replacesUNOTEST_JUDGE_RETRIESrather than stacking with it: re-asking is a deliberate bias towardpass, a vote is deliberately symmetric, and running both would quietly restore the bias the vote was chosen to remove. Ballots run concurrently, so a vote costs N provider calls but roughly one call of wall-clock.Wire protocol. New error code
configon the judge’s error responses: the service’s own setup is broken (bad env, expired credentials) and the message carries the fix.@unotest/websurfaces it as aConfigErrorinstead of burying it as a generic “judge unavailable” fault. -
assertJudge local mode: the “needs @unotest/judge installed” ConfigError now appends the real import failure cause — a half-built dist failed the same way and read as a phantom install problem. Also: the viewer run-from-ui dogfood scenario accepts history’s run-length collapse row.
-
Updated dependencies
- @unotest/protocol@0.24.0
- @unotest/viewer@0.24.0
- @unotest/core@0.24.0
- @unotest/dsl@0.24.0
- @unotest/grounder-client@0.24.0
0.23.0 — 2026-08-26
-
LLM-judge assertion for free-form text:
assertJudge(locator, rubric).- New package
@unotest/judge— the judge service (npx @unotest/judge): judges text against a natural-language rubric and returns a structured{verdict, reasoning, model, attempts}. Providers:vertex(Google Vertex AI via ADC, no API keys; temperature pinned to 0) andfake(deterministic, CI-safe). Non-determinism policy lives in the service:JUDGE_RETRIESre-asks onfail, firstpasswins. @unotest/web: new DSL assertionassertJudge(locator, rubric)— the element’s rendered text + the rubric go to the judge; afailverdict fails the step with the judge’s reasoning. Wired via env so the config sits in the--envoverlay:UNOTEST_JUDGE_MODE=off|local|remote,UNOTEST_JUDGE_URL,UNOTEST_JUDGE_TOKEN,UNOTEST_JUDGE_TIMEOUT_MS. Every verdict (pass and fail) is recorded into the run’ssteps.jsonl. Also documented:shell()runs with cwd = the project root (sandbox.shellCwdto override) and returns{stdout, stderr, code}.@unotest/protocol: judge wire types (JudgeRequest,JudgeVerdict,JUDGE_ROUTES) and the newjudge:verdictrun-artifact event.
- New package
-
Judge provider matrix: four new
JUDGE_PROVIDERbackends.claude— spawns the local Claude Code CLI (claude -p, no shell interpretation): auth comes from the Claude Code session, so a subscription works with no API key.JUDGE_MODELis passed as--model(aliases likesonnetwork);JUDGE_CLAUDE_BINoverrides the binary; defaultJUDGE_TIMEOUT_MSis 120000 for this provider.gemini— the Gemini API withGEMINI_API_KEY(same wire format asvertex, temperature pinned to 0; default modelgemini-2.5-flash).openai— OpenAI chat completions withOPENAI_API_KEY(response_format: json_object; default modelgpt-5-mini).anthropic— the Anthropic API (/v1/messages) withANTHROPIC_API_KEY(default modelclaude-haiku-4-5).
openaiandanthropicdeliberately send no sampling params — current reasoning models rejecttemperature; determinism relies on the strict JSON verdict prompt plus theJUDGE_RETRIESpolicy. Still zero provider SDKs: everything is raw HTTP or a local process.@unotest/web: docs only — provider matrix in theassertJudgereference and the.env.examplejudge block. -
Updated dependencies
- @unotest/protocol@0.23.0
- @unotest/viewer@0.23.0
- @unotest/core@0.23.0
- @unotest/dsl@0.23.0
- @unotest/grounder-client@0.23.0
0.22.0 — 2026-08-26
- Updated dependencies [80619a9]
- @unotest/viewer@0.22.0
- @unotest/core@0.22.0
- @unotest/dsl@0.22.0
- @unotest/grounder-client@0.22.0
- @unotest/protocol@0.22.0
0.21.0 — 2026-08-25
-
feat: environments are first-class in the viewer — a switcher, per-env run history
Run artifacts are laid out per environment, as folders. Each environment’s history lives in its own root, mirroring the env-file scheme:
unotest/.runs(base),unotest/.runs.<env>(e.g..runs.stagingfor--env staging/UNOTEST_ENV=staging); the axes compose —.runs-mobile.staging. Indexes (_day.jsonl,_latest.json) are per-env automatically: switching environments is a change of root, not a filter and not a rebuild. Old runs stay in.runs(= base), no migration needed.runsDirFor/projectRunsRoot/projectRunDirForgained an optionalenvNameparameter; the run manifest carriesenvfor self-description. Make sure your.gitignoreuses the wildcard form —unotest/.runs*/(initalready writes it that way for new projects).The viewer knows about environments. A new switcher sits at the top of the Variables panel and in the status bar: base plus every environment discovered from
unotest/.env.<name>/.secrets.<name>(.env.exampleand other templates do not count). The active environment is server-side (GET/POST /api/environments), and all tabs converge via theenv:changedWS message:- the Variables panel shows layers WITH the active environment’s
overlay (previously base only); values coming from
.env.<name>carry a badge; an edit goes to the file where the key is defined; new variables go to base; - Overview and the Runs list show only the active environment — tiles are colored by the latest run in that environment;
- a run started from the viewer gets the active environment’s
UNOTEST_ENV(RunRequest.envis a per-request override); - a run opened from another environment still resolves by runId across
all
.runs*roots.
MCP:
run_test {env}already switched the child process’s environment; its artifacts are now correctly found by the server in.runs.<env>(inspect/step/attach/list_runtimes scan all roots).Four fixes uncovered while shaking this down:
- The
--envoverlay actually reaches the run. Long-lived hosts (viewer, MCP server) flattened the base.envinto their ownprocess.envonloadConfig; children inherited it as ambient (ambient beats files) — the overlay’sAPP_BASE_URLwas silently clobbered by the base value. The viewer now rolls its env back after loading the config; MCP spawns children from a clean pre-flatten snapshot. - Pause/Abort from the viewer work again. Debug commands were
written to the flat
<root>/<runId>/commands.jsonl, while runs have lived in date shards since 0.19 — the write 404’d and abort was silently ignored. Order fixed too: SIGTERM to the own process first (tests AND collections), then the command file. - A collection no longer “dies” in the UI after 30 seconds. The
parent run wrote no heartbeat — the monitor declared it interrupted,
the row vanished from ACTIVE and the tail detached. The orchestrator
now maintains the heartbeat (shared machinery in
@unotest/core), and the viewer additionally treats a flowing steps.jsonl as a sign of life (compatibility with older runners). - No phantom
failedafter a clean exit. The “child exited without artifacts” check looked at the flat path and fired bogusrun-finished: failedevents plus an error toast on every exit.
- the Variables panel shows layers WITH the active environment’s
overlay (previously base only); values coming from
-
Updated dependencies [174d27e]
- @unotest/protocol@0.21.0
- @unotest/core@0.21.0
- @unotest/viewer@0.21.0
- @unotest/dsl@0.21.0
- @unotest/grounder-client@0.21.0
0.20.0 — 2026-08-24
-
Value helpers:
returnis no longer tied to theflow_prefixA helper that reads state and returns a value had to be named
flow_*— the only place the validator acceptedreturn. Butflow_*is an executable entry: the runner accepts it as a scenario and flow discovery offers it to the agent to replay while recording, so a width getter showed up in the list of reusable flows.The boundary now keys off
test_*instead.returnis legal in any helper —flow_*composites and plainsnake_casevalue helpers alike — and still rejected in atest_*entry, where it would mean the scenario silently ended halfway.unotest/e2e/_helpers/viewer.js function overview_header_width() {return evaluate('(() => document.querySelector("header").getBoundingClientRect().width)()');}New linter rule
lint:flow-returns-value(warning) flags aflow_*that returns a value: legitimate when the flow acts and yields its result (email = flow_signup()), a rename hint when it only reads. Override it underlinter.ruleslike any other rule.No migration needed — existing
flow_*helpers that return keep working. -
A failure in the first test of a file is no longer lost, and the dashboard no longer waits for F5.
One run, one
run:finished. The event was written by the executor’s per-entry tap, so a file with fourtest_*()functions left four terminal events insteps.jsonl. Every consumer reads the last one — socollectionreported a scenario green whenever the final function passed and the first one failed. The runner now writes the event once, folding the outcomes of every entry:failed>interrupted>aborted>completed. A run that never got started (no entry function, browser failed to launch) writes a terminal event too — its artifact used to read as “the process died”.The viewer asks for the index again. The first
load()could land while the server was still rebuilding the run index: the answer was “no runs”, and nobody asked again — WS only reports a run starting and finishing. On a project with history the whole overview showed “never run” until a page reload.indexReady: falsenow schedules a retry with a 0.5s → 30s backoff, until the first ready answer. -
Updated dependencies [7957469]
-
Updated dependencies [aab65a5]
-
Updated dependencies [c6efd92]
- @unotest/protocol@0.20.0
- @unotest/viewer@0.20.0
- @unotest/dsl@0.20.0
- @unotest/core@0.20.0
- @unotest/grounder-client@0.20.0
0.19.0 — 2026-08-23
-
The CLI passes termination on to the process it runs.
unotest-webis a dispatcher that spawns the real entry (viewer, a scenario run, the MCP server) and waits; killing the dispatcher used to leave that process alive, reparented to init and still holding its port. Ctrl-C hid it — a terminal signals the whole foreground group — so it only bit tooling that kills the process it started: each such call leaked one server. SIGTERM / SIGINT / SIGHUP now reach the child, and the exit code still comes from it. -
Per-collection parallelism via a
workers:field in the collection YAML.workers: N(integer ≥ 1) inunotest/e2e/_collections/*.yamlsets how many scenarios of that collection run concurrently; omitted = serial.- Precedence: CLI
--workers=Nflag > manifestworkers:> 1. - Viewer: the collection header shows an editable
workersvalue; the setting is stored in the YAML, so CLI and CI runs pick it up too. CollectionMeta(protocol) gains aworkers: number | nullfield.
-
Run history scales: runs are filed under daily shards and the viewer stops polling history it is not showing.
- New on-disk layout
unotest/.runs/<YYYY>/<MM>/<DD>/<runId>/(UTC date the run started). The shard is a pure function of therunId, which already carries the timestamp — a direct link to a run keeps working even with the index deleted. Existing flat.runs/<runId>/history is migrated automatically (see theruns migrateentry). - The viewer’s watcher polls only LIVE runs; finished history never touches
it. Discovery watches the current day’s directory with native
fs.watchand re-attaches at UTC midnight. - A JSONL index per day and per scenario backs the history list. It is fully rebuildable — rebuilt on start, non-blocking — so a corrupted or missing index degrades the listing, never the runs themselves.
@unotest/protocol: newrun-shardandrun-indexmodules (shard path derivation, index record shapes) exported from the root entry.
- New on-disk layout
-
Step screenshots are stored once and shared, and old runs expire on their own — a long history stops growing without bound.
- Identical frames are content-addressed in
unotest/.runs/_blobs/and hardlinked into each run.duand ordinary copies see whole files;cp -a/rsync -Hpreserve the sharing. On one dogfood suite: 45 frames, 2.04 MB by apparent size, 0.96 MB on disk. - Reference counting is the filesystem’s (
st_nlink), so nothing to keep in sync. Blob generations keep the dedup honest past ext4’s 65 000-link ceiling, and a fresh blob is untouchable for an hour so the window between writing it and linking it is never mistaken for garbage. - Retention:
UNOTEST_RUNS_RETENTION_DAYS(whole days, default 180) sweeps runs older than the window at the end of a collection run. It never changes the exit code — a failed sweep is reported, not fatal. @unotest/protocol: newrun-blobsmodule exported from the root entry.
- Identical frames are content-addressed in
-
Upgrading moves existing run history to the new layout by itself, and Playwright moves to 1.62.
- The flat
.runs/<runId>/history is migrated on the first run of the new version. Default is to remove old runs (nobody asked to keep them);UNOTEST_RUNS_LEGACY=keepre-files them into date shards instead. The completion marker is written last, so an interrupted or failed move is retried on the next start rather than silently marked done. - New command
unotest-web runs migrate [--dry-run]— the same migration, runnable FIRST, with a report-only mode. A migration nobody can look at before it happens is one that runs unannounced on someone else’s machine. playwrightmoves to^1.62.0in@unotest/weband@unotest/viewer. Runnpx playwright installonce after upgrading if your browser cache predates it.- Frame capture is now format-parameterised internally, still PNG. WebP was
measured and rejected: Chromium’s lossless
type: "webp"is pixel-exact but 1.5× LARGER than PNG on real frames (3.17 MB vs 2.04 MB on one suite). - Fix: the viewer served step screenshots as 403 after the layout change — its asset allow-list still expected the flat layout. The route now has tests.
- The flat
0.18.0 — 2026-08-23
-
Editor typings for scenarios —
.jsfiles stop looking like broken JavaScript in VS Code / WebStorm: autocomplete, hover docs, signature help and locator-chain resolution, without turning on a second type-checker (checkJsstays off; diagnostics remain the DSL validator’s job).- New shipped file
types/unotest-dsl.d.ts— ambient declarations for every DSL function, generated from the same contracts the validator uses (buildDslVocab()), so it can never drift from the installed version. - New CLI command
unotest-web types— createsunotest/jsconfig.jsonif missing and regenerates the.unotest/types/env.d.tscache (variable NAMES fromunotest/.env/.secrets; values never leave those files). initscaffolds the jsconfig + env cache;scaffoldWorkspace()now includes the jsconfig too.- Upgrade path is automatic:
lintande2ecreate a missingunotest/jsconfig.jsonon the fly (one announcement line) and silently refresh the env cache. Skipped under CI. No action needed after upgrading — run any test orunotest-web lintonce and the editor comes alive. If you keep a customunotest/jsconfig.json, it is never overwritten (re-runinit --forceto reset it). @unotest/protocol: additive optional fields on the DSL vocab —DslVocabArg.enumValues(literal members behindkind: "enum") andDslVocabEntry.variadic.
- New shipped file
-
New package
@unotest/eslint-plugin— unotest DSL scenarios as first-class ESLint citizens. One rule (@unotest/dsl) delegates every check to the validator inside the installed@unotest/web(parser, vocabulary, linter rules,linter.rulesseverities from unotest.config), sonpx eslint unotest/e2eandunotest-web lintreport identical diagnostics; a new validator rule needs zero plugin changes.configs.recommended()(async factory, flat config) wiresunotest/e2e/**/*.js, turnsno-undefoff (implicit globals are the DSL’s design; unknown names are the engine’s job) and declares globals for the vocabulary, your helpers and your.env/.secretsvariable names.@unotest/web: the@unotest/web/dslsubpath now exportsloadLintProjectContext()(config severities + helper discovery + variable names) andlinterRulesToSeverity()— the same loader the CLIlintcommand now uses, so the two frontends cannot drift. -
screenshot()appears in the DSL vocab (completion, hover, signature help, generated editor typings). It always had a validator contract, but its diagnostics contract group was missing from the vocab builder, so every vocab consumer silently lacked it. A parity test now pins the vocab to the contract registry, so a future contract group cannot be forgotten again.@unotest/protocol: newDslVocabCategoryvalue"diagnostic".
0.17.0 — 2026-08-20
-
Upload/API-step DX, driven by 0.16.0 consumer feedback (a multipart upload authored as
apiCall(m, p, {file: …})failed only as a remote 400, with nothing local pointing atupload()):- New DSL function
json(value)— serialize any value to a JSON string for readable assert messages:assertTrue(res.status == 202, json(res.body)). Objects concatenated with+collapse into[object Object]; this is the readable form. - New linter rule
lint:api-call-file-body(warning) — anapiCallbody object with afilekey is sent as plain JSON, no file attached; the diagnostic shows theupload(...)shape to use. Deliberate JSON fields namedfileare kept with// lint-ok: <reason>. - The CLI
e2ecommand now runs the same pre-run lint as the MCPrun_testtool and prints diagnostics before the run (advisory — the run still proceeds). Previously authoring mistakes the linter knows about surfaced only throughunotest-web lint. - Reaching for a JS global in a scenario (
JSON.stringify,Date) now answers with the DSL alternative (json(value),nowMs()) instead of the misleadingadd it to unotest/.enverror. - The generated
unotest.config.mjstemplate documents thesandboxsection (apiBaseUrl,uploadDir,database) —uploadDirwas referenced by the guides but absent from the template.
- New DSL function
0.16.0 — 2026-08-19
-
File oracles as first-class citizens, plus an interaction-state wait (bot-service feedback, round 4).
A background process that speaks by appending JSONL was only half supported:
waitForFilematches a substring against the WHOLE file, so a multi-key event filter was inexpressible — JSON key order is not guaranteed, and"userId":"x","isBot":truebreaks the first time the writer reorders its fields. Every positive wait therefore went through a hand-written node script. Four functions close that:waitForJsonLine(path, filter, {timeoutMs})→ the matching line, parsed. Strict equality per key, dot paths ('user.id') for nesting, unparsable lines skipped (a file caught mid-append is normal). The timeout says how many lines were read, how many parsed, and which one matched the most keys.assertNoJsonLine(path, filter, {withinMs})— the negative twin.waitForFileCount(path, pattern, count, {timeoutMs})→ the count. Waits for at leastcountmatching lines: between two polls a writer can append more than one, and a strict wait would then never see its target.patternis a substring, a regex, or the same key filter.assertFileCount(path, pattern, count)— snapshot, strict equality, for “exactly N happened” after the window has been awaited.
Counting is per LINE (
grep -csemantics), not per occurrence.waitFor(loc, {state})also accepts'enabled'/'disabled'now, alongside the DOM-presence states. It polls the same probeisEnabled/isDisableduse, soaria-disabledwidgets and<fieldset disabled>inheritance count — Playwright’s own wait knows neither. This replaces theforloop aroundpause()that a widget which unlocks on a timeout otherwise forces.Two smaller things that came with them:
- Options objects on the file oracles reject unknown keys. A misspelled
{timeout: 5000}used to be ignored and the step silently waited the default instead. UNOTEST_DEBUG=1gained asteplayer: every executed statement is bracketed withenter/exit(file, line, step label, ms, ok) and every nesteddriver/resolverevent carries the step it ran under. A CLI failure can now be diagnosed by reading the log as step → driver call → answer, instead of matching timestamps by eye.
-
unotest-web viewerlearnsUNOTEST_VIEWER_HOST/UNOTEST_VIEWER_PORT.By default the viewer still binds
localhostplus a free port the OS picks. Set the variables for headless / containerized setups where a reverse proxy reaches the viewer at a fixed address (e.g.viewer:4000inside a compose stack). An invalid port fails fast with an actionable message instead of silently falling back.UNOTEST_VIEWER_NO_OPEN=1still skips opening the browser. -
UnotestError— the typed-error base class — now lives in@unotest/coreand is part of its public API.@unotest/webre-exports it unchanged, so existing imports andinstanceofchains keep working. Motivation: other packages need the same base without copying it, and a published package is the only home they may all import. -
Unknown function: indexOfnow says what to write instead.The DSL has no string or array methods, so a JS habit (
out.stdout.indexOf(x),s.toLowerCase(),JSON.parse(line)) failed with the same bare message as a typo in a helper name — indistinguishable, and the reason bot-service asked for a linter hint back in 0.10.0.The engine stays vocab-agnostic:
FunctionContractRegistrygained an optionalsuggestForUnknown(name)hook, and the web vocab supplies the table (indexOf→textContains,concat→textJoin,JSON.parse→waitForJsonLine,Date.now→nowMs, and so on). A registry without the hook — or a name that is genuinely just misspelled — keeps the old message unchanged.
0.15.0 — 2026-08-17
-
Report collection runs scenario by scenario.
collection <name>used to print a single line —pass=21, fail=1— which told you a scenario failed but never which one, and the child processes’ stdout was piped and then dropped on the floor, so nothing else survived either. A CI job that went red was undiagnosable without downloading the run directory.Each scenario now gets a start line and a result line; anything that doesn’t pass also gets its own output quoted (last 16 KiB, a dropped head is marked) plus the path to its run directory. Failures go to stderr, progress to stdout.
Draining those pipes also removes a hang that was waiting to happen: an unread pipe blocks the child once the OS buffer fills.
Rendering sits behind a
CollectionReporterseam — the orchestrator still only emitscollection-run:*events. -
The examples mirror now tops up
unotest/.envandunotest/.secretswith keys a release added, instead of skipping the files because they already exist. Your values are never touched — only missing keys are appended.The old behaviour broke every upgrade that introduced a variable:
.env.examplegrew the key,.envstayed as it was, and the run died withexternal variable "CATALOG_SEED" not found. The workaround was to deleteunotest/.envby hand — on the runner, and for anyone who forked the examples.
0.14.0 — 2026-08-16
-
Negative window assertions + a string probe (bot-service feedback, round 3):
assertNeverAppears(loc, {withinMs})— watch the window and FAIL the moment the locator becomes visible; pass only when the whole window elapsed with zero sightings. The opposite of everywaitFor*— for “must NOT happen” requirements (a private message must not leak into a public feed, a disabled bot must not reply).withinMsis mandatory: absence is only provable within an explicit window, and the step always costs that long.assertNoFile(path, pattern?, {withinMs})— the file-side mirror ofwaitForFilewith inverted polarity: fail as soon as the file exists (and matchespattern), pass when the window elapsed clean. Replaces the fragile “helper worker + assertTrue(res.code != 0)” inversion.textContains(haystack, needle) → boolean— substring probe for strings that came back as data:assertTrue(textContains(out.stdout, 'ready'), out.stderr).
-
evaluate(js, ...args)now actually passes its arguments. The documented multi-arg form —result = evaluate(`function([a, b]) { return a + b; }`, 10, 20);— reached the page with
undefinedinstead: the driver’sevaluatesignature had no parameter for the argument at all, so the registry handed it over and the driver dropped it. Silently wrong answers, not an error. Whenjsevaluates to a function it is now called with the argument.Also adds the seven functions that were missing from the editor vocabulary (
randomFirstName,randomLastName,randomEmail,randomText,randomNth,waitForCount,screenshot) — they were documented in the DSL reference but invisible to hover and completion. -
Fix
assertValue,isDisabled/isEnabled,setLocalStorage/getLocalStoragein the published build.These four went through functions that Playwright serializes into the page. They were written inline in the driver, so the obfuscation step of the release build rewrote their string literals and member access into string-array decoder calls — and that decoder only exists in Node. In the browser they died with
ReferenceError: _0x… is not definedon every call.assertValuewas affected on ANY element, not just checkboxes: it probes the checked state first.Broken since 0.12.0 (
assertValue,isDisabled,isEnabled) and since the first published version (setLocalStorage,getLocalStorage). Only the built package was affected — running from source was always fine, which is why the test suite stayed green.The functions now live in the un-obfuscated
page-injectentry, next to the snapshot algorithms. Two new guards keep them there: a source check (check:no-inline-evaluate, part ofcheck:surface) rejects any browser-bound function outside that entry, and the bundle smoke — the one step that runs AFTER obfuscation — now exercises all four against a real page.
0.13.1 — 2026-08-15
- Docs: the
textJoinsection overstated the+rejection.+with a string literal on either side ('prefix-' + name) concatenates and lints clean; the validator rejects only literal-free string addition (a + bbetween string-typed variables/calls), which reads as arithmetic. Existing literal-based+code needs no rewrite (bot-service feedback on 0.13.0).- @unotest/core@0.13.1
- @unotest/dsl@0.13.1
- @unotest/grounder-client@0.13.1
- @unotest/protocol@0.13.1
- @unotest/viewer@0.13.1
0.13.0 — 2026-08-15
-
Per-call environments for explore sessions + init guidance (bot-service feedback, round 2):
explore_startacceptsenv: that session’s variables, secrets andAPP_BASE_URLresolve against the.env.<env>/.secrets.<env>overlays, without touching the server’s own environment or other sessions. Overlay secrets are also masked in all log sinks. Theexplore_stepsautoRun finale runs the saved test with the sameenv. Symmetric withrun_test’s per-callenv.init“Next steps” now says to restart an already-open agent session (MCP clients load.mcp.jsonservers at session start — no hot-connect in the protocol); same recipe added to the troubleshooting manual.
-
New DSL function
textJoin(parts) → string— concatenates an array of parts (numbers coerced). The validator has always rejected+on strings with a hint pointing attextJoin([...]), but the function did not exist in the web registry, leaving scenarios with no legal way to build a dynamic string. Found by the dogfood suite on its first lint. -
waitForTextnow respects the activeenterFramestack — it used to build its locator from the top document, so inside a frame it waited on text the frame content could never satisfy. Found by the dogfood suite (nested-iframes scenario).
All notable changes to @unotest/web are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.12.0 — 2026-08-14
-
The active variable environment is now agent-visible instead of docs-only.
explore_startandrun_testreplies carry anenvironmentfield: the active env name (UNOTEST_ENV,null= base) plus every.env/.secretslayer file with anexistsflag. A missing external variable error now also lists the searched layers (Looked in environment "dev" (UNOTEST_ENV): unotest/.env, unotest/.env.dev (no such file)). Tool descriptions forrun_test/explore_startexplain the env mechanism — MCP sessions inheritUNOTEST_ENVfrom the server process (set it in the client’senvblock); CLI runs take--env <name>. The shipped agent guide gains a “Variable environments” section. -
Feedback batch from a real-world integration (bot-service): shell() safety, run diagnostics, boolean-attribute probes, linter ergonomics.
BREAKING —
shell()fails the step on a non-zero exit code. Previously a non-zero exit resolved silently and a forgottenres.codecheck produced false-green tests. Migration: calls where non-zero is expected (probes,grep -q) pass{allowNonZero: true}as the last argument and keep checkingres.code; calls that assertedassertTrue(out.code == 0, ...)can drop the assert. More shell() hardening: a missing binary is a distinct “failed to start” error (was a fakecode: 1), output over 10 MiB fails loudly (was silent truncation), and every call has a wall-clock budget (sandbox.shellTimeoutMs, default 120 s, or per-call{timeoutMs}) — a detached grandchild holding the stdio pipes no longer hangs the scenario forever; the timeout error explains the detach recipe.New capabilities:
waitForFile(path, pattern?, {timeoutMs})— poll a file until it exists / contains a substring or regex; the file-sidewaitForTextfor background processes. Returns the content.sandbox.exportSecrets: [names]— explicit allowlist of.secrets*variables exported intoshell()subprocess env (still masked in logs). Secrets stay out ofprocess.envotherwise.isDisabled(loc)/isEnabled(loc)— honest disabled probe (native:disabledincl. fieldset inheritance, oraria-disabled);hasAttribute(loc, name)— presence probe for boolean attributes (getAttributereads""for present and absent alike).- Generators:
randomWord(len)(letters only — digit-free markers),nowMs(),today(),daysFromNow(n)(previously documented in the skills but not implemented). - Failed runs report the enclosing step:
✗ test: … in step "…"in the console,stepLabelin the failure bundle andinspect_runtime. - Every CLI run prints a one-line header: active environment, layer
files (missing ones flagged) and effective
baseUrl;run_test/explore_startreplies echobaseUrlnext toenvironment. run_testaccepts per-callenv— the spawned runner child getsUNOTEST_ENV=<env>for that run only.- Linter:
// lint-ok: <reason>silences warning/info diagnostics on its line (reason required; errors never suppressible); newlint:echo-assertwarns whenwaitForText/assertTextoverlaps a value the scenario itself filled (chat echo trap);lint:goto-concat/lint:regex-invalidare now configurable inlinter.rules(they were emitted but missing from the config schema). - A
_helpersfile that fails to parse is reported as the root cause inunotest-web lint(and explore replies listunparsableHelperFiles) instead of silently cascading into “Unknown function” at every call site. The viewer logs the same condition (patch).
0.11.0 — 2026-08-13
-
e2eCLI argument diagnostics: extra positional arguments are rejected instead of silently ignored (with a “did you mean: unotest-web e2e” hint for a duplicated subcommand); an argument that resolves to a directory gets its own message instead of the misleading “.js extension” error; unknown scenario names now suggest close matches from unotest/e2e/**(newsuggestClosest/levenshteinDistanceutilities in@unotest/core). Thecollectioncommand also rejects extra positionals. -
Failure artifacts are always on, and steps can carry screenshots.
- On any scenario failure the runner writes
screenshot.png+page.html(runtime DOM, open shadow roots included, secrets redacted) tounotest/.runs/<runId>/failure/— noUNOTEST_DEBUGneeded. The.unotest/failures/bundle gains the samepage.html; its screenshot keeps honouringfailureBundle.tier2. - New env flag
UNOTEST_STEP_SCREENSHOTS=1captures a PNG after every executed step intounotest/.runs/<runId>/screenshot/. - Protocol:
screenshotevents may carry the step’sfile/line/col. - Viewer: a step row with a captured screenshot shows a camera icon and
toggles an inline preview on click (both
screenshot()DSL captures and per-step ones).
- On any scenario failure the runner writes
-
Parse errors now carry an exact position and a caret code frame.
- Every lexer failure is a
ParseErrorwithline/column(previously bareErrorwith no position). - New
formatCodeFrame(source, line, column)/formatParseErrorexports in@unotest/dsl. - The web runner’s “parse error in
” message now includes <file>:<line>:<col>plus the code frame — no more hunting for an “unexpected LBRACKET” by eye. - Token columns are now uniformly 1-based at the token start.
Previously identifier/number/string tokens were 0-based while
operators were 1-based, so carets and editor squiggles missed by one.
Migration note:
line:colbreakpoint keys stored in.debugger[-suffix].jsonagainst word-initial statements shift by +1 — re-set those breakpoints once.
- Every lexer failure is a
-
BREAKING: multipart uploads in
apiCallare now opted into explicitly with the newupload(path, {field?, fields?})DSL function — the{file, field?, fields?}body-shape magic is removed.A JSON body containing a
filekey now always posts as JSON (the old key-name detection broke JSON APIs with a legitimatefilefield —{file: 'x.jsonl'}tried to read a file from disk and died with ENOENT).Migration:
// beforeapiCall("POST", "/documents", {file: "fixtures/doc.pdf",fields: { source: "e2e" },});// afterapiCall("POST","/documents",upload("fixtures/doc.pdf", { fields: { source: "e2e" } }));upload()must be the body itself (a nested{doc: upload(...)}is rejected with a pointer to the correct usage). Path containment insandbox.uploadDirand the Content-Type rules are unchanged. -
Fixes and DX follow-ups from the real-time-polls field check:
- Fix:
unotest/.runs/<runId>/failure/was never written. The runner derived the run dir from its internal runtime id (r-N…) while the on-disk dir is named bymakeRunId(scenario), so the always-on failure artifacts silently skipped. The CLI now passes its run dir into the runner (RunJsScenarioOptions.runDir), and the failure screenshot + page.html land where documented. initre-syncs tool-owned skill copies..claude/skills/*and.claude/hooks/*shipped by the package are overwritten on a plaininitre-run when they diverge from the installed version (statusupdated) — stale skills after a package upgrade no longer require--force. User-scaffolded files keep the skip-unless---forcebehaviour.- The MCP
agent-test-authorprompt caught up with the runtime: multipart viaupload(...)and theobj.prop[i]workaround are now documented there too, and both facts are enforced bycheck-skill-prompt-sync.
- Fix:
-
Screenshots are no longer saved as blank white PNGs.
goto()resolves on theloadevent, which on any client-rendered app fires before the first frame carries content — so a per-step capture (UNOTEST_STEP_SCREENSHOTS=1) of a step that ended ongoto(), or ascreenshot()call placed right after one, froze the pre-render frame. Both paths now settle first: wait for the network to go quiet, then for a painted frame, capped at 2s total. A page that never goes quiet (polling, live feeds) spends the cap once per capture and is then shot as-is; the wait never fails a run. The failure screenshot deliberately skips the settle — it must show the page as it was at the failure.Adds
DriverPage.waitForLoadState(state, opts?)— diagnostics-only, backing the settle. It is not exposed to the DSL: a test that waits on “the network went quiet” instead of on visible app state is the flaky pattern the linter rejects.
0.10.0 — 2026-08-06
-
API-setup primitives for pure-
_helpers/test setup (multi-host RAG smoke flows and similar):apiCall(method, path, body?, headers?, options?)gains{base: 'API_BASE_X'}— the NAME of aunotest/.envvariable holding an http(s) base URL. Default behavior (path againstsandbox.apiBaseUrl) is unchanged; URLs still never appear in scenarios.apiCallmultipart upload: a body of the exact shape{file, field?, fields?}postsmultipart/form-dataviafetch+FormData. The path is relative and confined tosandbox.uploadDir(new optional config field; defaults to the project root) — absolute paths and../escapes throw, and a manualContent-Typeheader with a multipart body is rejected.- DSL validator now allows
breakinside loop bodies (break-outside-looperror elsewhere) and index access on data variables (docs[0].objectsCount); indexing a Locator is rejected aslocator-index-access, andwhile/do…while/continue/arr[i] = vstay unsupported. Parser and executor already supported these forms; only validation changed.
-
Assert steps ground as whole units.
Intent.prefergains"unit": the grounder returns the semantic unit itself (heading / record / text) and suppresses the record→control hop — the profile for read-only targets. Web assert actions (assert_text/visible/hidden/value/count) declareintentTarget: "unit"on their plugins, and the intent resolver materializes a DOM ref for unit resolutions via the newmaterializeRefScript(nodeId)(grounder-client dom-walker): one lazy attribute write on the unit’s own element, reused by later captures. Previously an assert intent like “the page heading” was rejected with a RECORD error and forced afind_elementdetour. Control-target errors now name the unit kind correctly (heading / record / text block) and point at assert steps for check-not-click intents.Materialized record containers now resolve to a stable locator. A record row / card / tree-item (
<li>,<tr>,<article>) carries no accessible name of its own — its text lives in children — soRefResolvergains a container strategy:getByRole(role).filter({hasText}), keyed off the container’s short descendant text (capped so a whole table is never captured) and verified for uniqueness + ref identity. Fixesassert_visibleon a grounded tree item failing withno stable identifier. -
assertValueon a checkbox / radio / switch now asserts its checked state instead of the DOMvalue. Their value is the submitted payload ("on"by default) and is identical whether the box is ticked or not, so the old behaviour made a correct test fail on DOM trivia (live agent run:assertValue(checkbox, "true")→expected "true", got "on").Pass
'true'/'false'; any other expectation on a checkable element is rejected with a usage error naming the rule. Text inputs, textareas and selects are unchanged. Applies to theassert_valueMCP action too.Migration: a test asserting the literal payload —
assertValue(checkbox, 'on')— now fails with that usage error. Assert the state ('true') instead; to read the raw attribute usegetAttribute(loc, 'value').The
WebDriverpage contract gainscheckedState(loc)(boolean | null, null for non-checkable elements) — relevant to custom driver implementations.assert_valuealso grounds intent locators as a control now (intentTarget: "control"). Under the previous"unit"profile an intent like “selection checkbox of the Pending row” resolved to the ROW, materialized a ref on the<tr>, and failed withNode is not an <input>. The read-only asserts (assert_text/assert_visible/assert_hidden/assert_count) keep the unit profile. -
Chat-feed-friendly wait primitives (ai-support plan, tasks 5–6):
waitForTextnow waits for ≥1 VISIBLE occurrence instead of failing strict mode on duplicated text;{first: true}becomes a no-op. New{exact: true}option and regex matchers (waitForText(/\bhi\b/)) fix substring-inside-a-word false matches.- New
waitForCount(loc, n, {timeout, exact})DSL primitive andwait_for_countexplore action: polls until the locator matches at leastnelements (exactlynwith{exact: true}) — the “wait for reply №N” pattern without index locators; counts as a verification step for autoRun / NO_VERIFICATION.
-
Shared injected DOM helpers: one
createDomHelpers()factory (visibility, ARIA roles, W3C accname, state flags, ref minting) now backs the grounder-client DOM walker and the web snapshot/find algorithms, composed into the page bycomposeInjectedScript— replaces four hand-maintained copies of the same in-page logic.Breaking (
@unotest/grounder-client):captureRawTreetakes the helpers object as its first parameter, so passing it straight topage.evaluate(captureRawTree)no longer works (evaluate arguments are JSON-serialized and cannot carry the helpers). Migration: inject the serialized script instead —page.evaluate(domWalkerScript())— which composes the helpers automatically. New exports:createDomHelpers,composeInjectedScript,InjectedDomHelpers,DomHelperOptions,InjectedScriptOptions.@unotest/webbehavior deltas from unifying on the walker’s ARIA mapping:get_aria_snapshotnow mapsdltolist(dt/ddstay name-only) andtbody/thead/tfoottorowgroup, and never names structural containers from their text content;find_elementmatches against the full W3C accname (label-for lookup, composed multi-child names) instead of a reduced approximation. -
Recorded assertions and one-call test authoring in exploration mode. Six new recordable actions —
assert_text,assert_visible,assert_hidden,assert_value,assert_count,assert_url— available inexplore_step,explore_stepsandexplore_record; they execute live through the same poll-until-timeout cores as the runtimeassertText(...)family (extracted todsl/assert-core.ts), so a recorded assert has already passed against the real page and the generated DSL carries the matching assert lines. Assert steps count as verification for theNO_VERIFICATIONsave gate. NewautoRun: trueflag onexplore_steps(requiresexplorationId): when every step succeeds, a verification step is recorded, the draft has no blocking warnings (FRAGILE_LOCATORis informational — it passes and rides along in the reply;DYNAMIC_TEXT/NO_DSL_PRIMITIVEblock, mirroring save’s policy) and the target file is new, the same call stops the session, savesunotest/e2e/<scenarioName>.js, resets the browser context and runs the test withrun_testsemantics (autoRun.run.next.outcome); any gate failure degrades toautoRun.status: "skipped"+ reason with the session left active. The green happy-path shrinks tonew_context→explore_start→explore_steps {…, autoRun: true}.UNOTEST_TOOLSET=coreunchanged — asserts are actions, not tools. -
Two MCP round-trips removed from every authoring flow.
explore_step/explore_stepsnow reply withurlwhenever the step CHANGED the page address — the same curegroundedTogave intents, applied to navigation: after a batch the agent reads where it ended up instead of spending a call onget_active_context/get_url. Unchanged addresses print nothing, so a batch of ten clicks on one page adds no lines.explore_startopens the browser context itself when none is open, so thenew_contextprologue is gone from the happy path.new_contextkeeps its role as the explicit RESET (between the recording and the first run, and insideautoRun); an existing or attached collaborative context is never re-created. -
Steps whose locator was an intent now report
groundedTo— the element line the grounder resolved it to (checkbox "Select row R-00147" (in row "Row R-00147: … Pending …")), per locator argument. Present onexplore_step, every step of anexplore_stepsbatch, andexplore_record.Until now a step’s reply said only
executed / recorded / success, so an agent could not tell WHICH element a positional intent hit without a separateground_elementprobe — grounding the same intent twice. A live run spent 4 extra calls and doubled grounder time doing exactly that. The skill and the tool descriptions now point atgroundedToinstead. -
New
screenshot(name?, {fullPage?})DSL command — on-demand PNG capture of the active page intounotest/.runs/<runId>/screenshot/<NNN>-<name>.png(returns the project-relative path). Evidence tool for green runs: fixes what the page actually looked like at a chosen point. Each capture is also mirrored as ascreenshotRunArtifact event, so the viewer’s event stream links to it. In runtimes with no artifact dir (exploration) the call logs a warning and returnsnullinstead of failing the run. -
The
write-e2e-test-groundskill now records batch-first: when the task brief already spells out the step sequence, Phase 2 sends it as oneexplore_stepscall (goto included, intent locators ground at execution time) instead of oneexplore_stepper action, with a repair recipe for mid-batch failures. A/B across four eval tasks showed equal-or-better scores with fewer calls, lower cost, and 9–38% less agent time. The snapshot-mode skill is unchanged — its ref discipline requires a snapshot before locator steps, so pre-discovery batching does not apply. -
Dedup pass across the tree (jscpd 0 clones, ratchet threshold 0). New public export in
@unotest/grounder-client:intEnvInRange(env, name, def, min, max)— theUNOTEST_GROUNDER_*integer-env parser shared by the consumer side and the grounder server. Everything else is internal refactoring with no behavior change: protocol gains a shared fs-safe-name validator behindvalidateCollectionName/validateSegmentNameand a shared env-file assignment iterator; core’sRuntimeInspectionbecomes aPickofRuntimeStateFile; dsl’sExecutionWalkerthreads oneWalkCtxobject instead of an eight-argument clump and AST nodes use constructor parameter properties; web deduplicates the MCP tool scaffolds, action plugins, DSL contracts, perception LRU caches, and semantic-dom grouping passes. -
Grounder infra resilience. The MCP server now runs a cheap grounder reachability ping at startup when
UNOTEST_GROUNDER_MODEislocalorremote(/api/tagsfor local ollama,/healthfor a remote grounding server) — non-blocking, warning-only. Network-level failures on the intent /ground_elementpath are wrapped into a typedGrounderUnreachableErrorciting the backend URL, a mode-specific fix hint and the boot-time health verdict, instead of surfacing undici’s rawfetch failed. The ground-mode skill now spells out the exact fallback recipe: a single{kind:"intent"}step, thereffromfind_element, or the ready-madelocatorobject fromground_element— hand-assembledgetByRole/csslocators are rejected in recording mode.
0.9.2 — 2026-06-16
Added
- Run snapshot export. A finished run can be exported as a portable
*.unotest.zipbundle (events + captured artifacts) for a teammate to open in their own viewer. The runner now recordsconsole/networkcapture flags in the run manifest so a re-rendered snapshot shows “capture was off” rather than an empty console, andunotest/.snapshots/is added to the generated.gitignore.
Fixed
- Locator picker survives navigation and reaches into shadow DOM. A navigation
that tore down the JS context mid-drain now surfaces as a typed
ExecutionContextDestroyedError, so the picker overlay reports not-alive and self-heals (re-stamp refs + re-inject) instead of logging a dead end. Hit-testing descends through open shadow roots and ref-lookup climbs back out across shadow boundaries, keeping the picked element and its stamped ref symmetric with the snapshot capture.
0.9.1 — 2026-06-14
Added
check_locatorMCP tool + runtime locator probe. Evaluate a DSL locator string against the live page and see what it actually matches — count, the N a trailing.first()/.last()/.nth()silently collapsed, and where each match points — instead of guessing or re-running the whole test. Same parser/core (probe-locator) the runner uses, so a result here equals the runtime locator.- Interactive debug + auto-attach.
run_test --debugauto-attaches the MCP session to the paused run’s shared browser, soget_page_snapshot/find_element/check_locatorhit the page being debugged. Collaborativeprobecommand (the human’scheck_locatortwin) is handled by the runner. - Agent tool calls in the viewer’s System console. A new
StepsToolEventSinkpublishes each MCP tool call + result (secret-redacted, capped) into the attached run’ssteps.jsonl, so the human sees what the agent is doing. - Run breakpoints published to
runtime.json(.debugger.jsonseed +--breakpointsoverride), so the viewer paints gutter dots for non-persisted breakpoints.
Changed
- Shared runner machinery extracted to
@unotest/core(run-artifact / run-manifest / runtime-state writers, debug-commands watcher, env reader) and consumed via thin web bindings.
Fixed
abortnow actually terminates a paused run. A{cmd:abort}on a run paused mid-statement could not unwind the suspended executor, leaving the process + headed browser alive with a fresh heartbeat (the viewer showed a ghost “active” session). Abort now force-terminates the runner the same clean way Ctrl-C / SIGTERM does, emitting a terminalrun:finishedand closing the browser.
0.9.0 — 2026-06-11
Added
- Opt-in capture artifacts for the viewer inspector.
UNOTEST_NETWORK=1records each request/response to the run’snetwork.json;UNOTEST_CONSOLE=1records the browser console toconsole.json. Written per run (any outcome), redacted of secrets. Off by default — zero overhead. runtime.jsonfor every run. The runtime-state writer (vars / scope / call stack) now runs for plain headless runs too, not just--debug, so the viewer’s Vars inspector is populated on any run.- Friendly relative-
gotoerror.goto('/path')with nobaseUrlconfigured now throws a typedNavigationErrorthat names the URL and the fix, instead of Playwright’s “Cannot navigate to invalid URL”. WhenbaseUrlis set, Playwright resolves the relative path as before.
Changed
- MCP collaboration is discoverable.
get_active_contextnow reports an attachable live debug session (attachable+ a hint); the no-active-context error points atattach_debug_session; the serverinstructionsmention joining a human’s live session.findShareableRunis shared betweenattach_debug_sessionandget_active_context. initdefaults. Generatedunotest/.envsetsAPP_BASE_URLto the unotest playground and the example scenarios use relativegoto('/...');API_BASE_URLandPROJECT_ROOTare dropped;UNOTEST_NETWORK/UNOTEST_CONSOLEare added under Runner config.
0.6.2 — 2026-06-05
Added
- MCP server
instructions. The server now ships start-of-sessioninstructions(injected by the MCP host into the agent’s context the moment the server is approved), pointing the agent at thewrite-e2e-testskill and the canonical MCP tools before it free-researches the repo. initwrites/merges Claude Code config.initnow copies thewrite-e2e-testskill as a real skill directory (.claude/skills/write-e2e-test/SKILL.md, so/write-e2e-testand auto-invoke work) plus an agent-trace hook (.claude/hooks/log-agent.sh, a no-op unlessUNOTEST_DEBUGis set), and merges managed blocks into the consumer’sCLAUDE.mdand.claude/settings.json(pre-approve MCP tools, register the trace hook) without clobbering user content.
Changed
- CLI help uses
examples/01-smokeas thee2eexample (runnable example scenarios live underunotest/e2e/examples/).
Added
- External variables and secrets. Scenarios reference external values
by bare
UPPER_SNAKEidentifiers (APP_BASE_URL,TEST_USER_EMAIL) instead of hard-coding them. Non-secret values live inunotest/.env, secrets inunotest/.secrets(gitignored); both are created byinitand loaded fresh on every run. Secret values are masked as‹secret:NAME›in logs, debug output, and recorded artifacts, so they never reach a saved file in plaintext. - Reusable flows. Recorded exploration steps can be tagged with a
flowgroup and extracted on save into aflow_<name>()helper underunotest/e2e/_helpers/; the generated test calls the helper instead of inlining the steps. The newexplore_run_flowMCP tool replays an existingflow_*helper without re-recording it, andexplore_startnow surfaces the available flows and the declared variable names. - Run source snapshot. Each run writes a
sources.json(the entry scenario plus every loaded helper as they were when the run began), so a run always renders against the code that actually executed even if the file is edited afterward. (Consumes@unotest/protocol’sRunSources.) - New scenario linter rules:
scenario-in-root(scenarios must live in a feature subfolder) andmustache-in-dsl(flags{{NAME}}template syntax inside DSL string literals).
Changed (BREAKING)
- Scenarios must live in a feature subfolder. A scenario placed
directly in
unotest/e2e/is now rejected by the linter — useunotest/e2e/<feature>/<name>.js(thee2e/root is reserved for_helpers/). The MCPscenarioNameargument (explore_start/save_exploration_as_test) likewise requires<feature>/<name>form, andinitscaffolds the welcome smoke test atunotest/e2e/welcome/smoke.js. {{NAME}}inside a DSL string literal is now a lint error. That form is only for MCP recorded values; in scenario code, reference a variable as a bare identifier (fill(loc, PASSWORD)).
Fixed
- Windows: path handling. Run-artifact paths (entry,
sources.jsonkeys, stepfileevents) are normalized to project-relative posix, so the viewer’s asset allowlist and source mapping match on Windows — a native\previously broke them. - Actionable error when no browser is installed. Playwright launch now
preflights the browser binary and fails with a clear message hinting
npx @unotest/web install-chromium, instead of a raw stack trace; launch failures are printed to the console instead of exiting silently.
0.5.0 — 2026-05-29
Added
- Recording mode (exploration sessions). Eight new MCP tools —
explore_start,explore_stop,explore_state,explore_step,explore_record,explore_remove_step,generate_dsl_from_exploration,save_exploration_as_test. The agent drives the browser throughexplore_step, which both executes the action and records it into a session log. When done,save_exploration_as_testgenerates a DSL scenario and runs it viarun_test. Session data persists as JSONL underunotest/.explorations/(override viaEXPLORATIONS_DIR). - Ref resolve-on-record. Wire-only
{kind:"ref",ref:"eN"}locators emitted byget_page_snapshotare resolved to stable forms (getByTestId/getByRole/getByLabel/getByText/locator) at record time. The persisted scenario contains no stale refs; execution still uses the original ref-locator for precise targeting.
Changed (BREAKING)
- 14 per-action MCP tools removed.
click,double_click,fill,hover,press,check,uncheck,select_option,scroll_into_view,goto,reload,wait_for,wait_for_text,wait_for_urlare replaced byexplore_step { action, locator, … }. Without anexplorationId,explore_stepruns the action ad-hoc with the same semantics as the removed tools.
0.4.0 — 2026-04-25
Added
- Debug logging v2 — folder per call (
UNOTEST_DEBUG=1). Each MCP tool call gets its own directoryunotest/.debug/<session>/<NNN>-<tool>/containing:call.json(args, events, result),before.html/after.html(full shadow-pierced DOM),before.txt/after.txt(outline),diff.txt(line-level LCS diff of outline),aria.yaml(ARIA tree),resolved/<ref>.html(outerHTML of the resolved element). On failure:page.html+screenshot.png. Levels:=1/=fullall artifacts;=summarytool + snapshot only, no HTML; unset — no I/O. audit_last_runMCP tool. Deterministic rule-based audit of the last debug session: catchesref-before-snapshot,resolve-loop, missingsectiondescriptions, and selector downgrades.get_last_mcp_logfilters.errors,layer:N,tool:N,exploration:<id>— narrow the JSONL log to relevant events.- Resolver:
data-*stable-id hints.data-id,data-guid,data-keyand similar attributes are detected and used as primary CSS selectors ([data-id="…"]), making table-row locators stable across re-renders. - Resolver: tooltip / label
data-*hints.data-tooltip,data-shadow-title,data-original-titleand similar attributes provide display names for icon-only controls. - Resolver: clickable-row detection. Non-control elements with
cursor:pointeroronclickand a stabledata-*id appear in the outline as[clickable]entries, collapsing large grids to the meaningful rows. - Ancestor-scope disambiguation. When a best locator candidate is
ambiguous (multiple matches), the resolver climbs to the nearest
unique ancestor and emits a single combined CSS selector
(
<ancestorSel> <childCss>) rather than failing.
0.3.0 — 2026-03-18
Added
UNOTEST_DEBUGlogging. SetUNOTEST_DEBUG=1to write a structured JSONL log tounotest/.debug/mcp-latest.jsonl. Captures tool calls, resolver decisions, and driver events. Readable via theget_last_mcp_logMCP tool.get_last_mcp_logMCP tool. Returns the last N lines of the debug log. Agents use it to self-diagnose stale refs and resolve failures without human intervention.- Shadow DOM full-capture.
before.html/after.htmlartifacts pierce shadow roots viagetComputedStyle— plainouterHTMLmisses shadow-hosted content.
Fixed
- Outline renderer truncated role names longer than 32 chars; now preserves full name.
RefResolveremitted duplicategetByTextselectors when two adjacent elements had identical visible text; now falls back to positional nth-selector.
0.2.0 — 2026-01-08
Added
- Local results viewer (
unotest-web viewer). Opens a browser UI showing live test results, step logs, and screenshots. Backed by@unotest/viewer— installed automatically as a dependency. open_viewerMCP tool. Starts the viewer server in the background and returns its URL. Subsequent calls reuse the running instance.- Collection runner (
unotest-web collection <name>). Runs every scenario listed inunotest/e2e/_collections/<name>.yamlunder a shared parent run-id. Supports--workers=Nfor parallel execution and--bailto stop on first failure. install-chromiumcommand. Downloads Playwright’s bundled Chromium (~150 MB) for environments without a system browser.list_runtimesMCP tool. Returns all active scenario runtimes in the current session (run-id, scenario name, status, step count).
Fixed
unotest-web initfailed silently when the target directory existed but was missingunotest/; now creates missing subdirectories.run_testreturned a stale run-id when called concurrently with an in-progress run; each call now allocates a fresh id atomically.
0.1.0 — 2025-10-01
Initial release.
Added
- DSL — JavaScript scenarios on a sandboxed AST engine. Scenario
files are
.jsinunotest/e2e/using Playwright-vocabulary primitives (goto,click,fill,getByRole,getByTestId,assertText, and ~54 others). The AST parser (now published as@unotest/dsl) supports method chains, options-object literals, and raw backtick strings. - MCP server with 37 tools. Bundled server (
unotest-web mcp, default subcommand): 6 debugger tools (run_test,step,resume,inspect_runtime,abort_runtime,list_runtimes), 21 browser interaction tools, 4 multi-context tools (list_pages,switch_page,new_context,close_context), 6 failure-bundle tools, andagent_fix. - Semantic DOM canvas. Inspection layer producing a compact, agent-readable view of the page (role / name / relevant attributes). Used for both live snapshots and failure bundles.
- Failure bundle — tier 1 + 2 capture. On test failure the runner
writes
failure.json,console.json,snapshot.json, andscreenshot.pngtounotest/.runs/<runId>/. Accessible vialist_failures,get_failure_trace, and related MCP tools. agent_fixworkflow.mcp__unotest-web__agent_fixbuilds a structured fix-context bundle (last failure, relevant DOM, scenario excerpt). The package does not call an LLM or auto-apply patches — human review is required.- AST scenario linter (
unotest-web lint). Six rules: deep CSS selectors, XPath, obfuscated class names, explicitpause()calls, disambiguate-by-index, and discouragedevaluate()patterns. Severity is config-driven viaunotest.config.*. - Project-agnostic sandbox primitives.
shell(cmd, …args)(execFile, no shell interpolation),dbQuery/dbExec(lazy peer dependency — pg / mysql2 / better-sqlite3 resolved fromsandbox.databaseURL),apiCall(method, path, body?, headers?)(againstsandbox.apiBaseUrl). Consumer-specific seeders live inunotest/e2e/_helpers/. - Cross-browser support. Chromium, Firefox, WebKit via Playwright.
unotest-web init. Bootstrapsunotest.config.mjs,unotest/e2e/smoke-welcome.js,unotest/e2e/_helpers/,unotest/.env, MCP config, and Claude Code skill into any project.