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

Changelog

For the complete documentation index, see llms.txt

The packages are pre-1.0 and move fast. This page is the changelog of @unotest/web@0.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, so bundle push stops warning that the repository “maps to no project on this box”. box create has 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 one bundle push uses: the token decides the project, and a repository another project on the box already holds is refused with that project’s name. The bundle push warning now ends with this command. The MCP tool box_repo_add takes {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/web 0.54.0 included: box create without --ttl, the MCP tool cloud_box_create without ttl, 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 with npx @unotest/web box extend <name> --ttl none.

  • New: a rented box has a lifetime. box create --ttl 8h (or 2d, up to 30d) rents a box that is deleted by itself at the end of it, and charging stops there; without --ttl the cloud gives a new box 24 hours, and --ttl none keeps it until you delete it. box extend <name> --ttl 4h moves the end later (--ttl none takes the lifetime away); nothing is charged at once, the box keeps being charged by the hour while it runs. box status and box list show when a box ends, and within the hour before the end box status prints 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 tool cloud_box_create takes ttl, and the new cloud_box_extend takes {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 approvals and 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 8h
    npx @unotest/web box extend shop --ttl 4h
    npx @unotest/web box create ci-box --ttl none

    replaces: remembering to run box destroy when 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 through globalThis while the list of variables a bundle reads (readVariables, the gaps push reports) did not name it, so a push let through a suite whose variable the box did not have. globalThis.APP_URL is now undefined unless the test also reads APP_URL itself; lint gives no error on the member form.

    Migration: read the variable directly: goto(APP_URL) instead of goto(globalThis.APP_URL). For a name only known at run time, use env(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 a pause: 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} (from nowMs()) 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 with assertNeverAppears. The lint hint on pause now names waitForResponse. networkEntries() entries carry the response headers too. replaces: pause after a click, and waitUntil over networkEntries

  • Fixed: click, doubleClick and press on a control that closes its own page (a button that runs window.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 --help no longer says the base unotest/.env is an environment of its own. Box environments are the unotest/.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 create now says what to do: with a public address in unotest/.env next to a localhost overlay it explains the layering, and it names where the public APP_BASE_URL goes (e.g. unotest/.env.staging). env set refusing a loopback or private-network target and cloud_box_create (its next) give the same step.

  • Fixed: the way to a CI box now says the box needs --ttl none. The hint init ends with reads box create --ttl none, box create without --ttl names 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 with box create <name> --ttl none, or to take the lifetime off one you already rented with box 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 github writes gives its token read access only (permissions: contents: read) and pins actions/checkout and actions/setup-node by commit, with the version in a comment; Dependabot’s github-actions keeps 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 --force writes the new one and keeps yours as .bak.

  • Fixed: a scenario that catches a stack overflow (deep recursion, or JSON.stringify of 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 whole e2e command with Maximum call stack size exceeded and 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 and inspect_runtime show such a value cut at 1000 levels, marked …. JSON.stringify is the engine’s own and throws its own RangeError, as in any JavaScript. @unotest/dsl/value exports the bound and the check, MAX_VALUE_DEPTH, nestedDeeperThan and ValueTooDeepError, and its snapshot cuts a value at that depth.

  • Fixed: dsl_lookup no longer answers a task the DSL has no function for with functions that share one word with it: “sort table rows” got texts and openTerminal, “run the suite in GitHub Actions on every push” got clockRun and vmRun. 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 init gives right after install. The .bak that init --ci github --force keeps of .github/workflows/unotest.yml is now in the .gitignore lines init writes, so git status no longer lists it. The hint to download the bundled Chromium reads cd 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. And unotest/.env.example, like the dbQuery error for a missing driver, installs the database driver into the suite with npm (cd unotest && npm i -D pg @types/pg) instead of pnpm 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) in unotest/.secrets or unotest/.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 reached assertJudge was 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’s shell() or a child process inherits. When no key is found, the refusal names every place it looked.

  • Fixed: login on a machine whose key was granted less than a sign-in asks for today — a key from 0.51.0 or earlier, or one with billing:read unticked 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 through UNOTEST_CLOUD_TOKEN is left alone as before. whoami and the price line of box create / box picker on now say what to do when a key cannot see the money: run npx @unotest/web login, or, for a key from the environment, create one with billing:read in 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 a unotest/.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 create leaves out an environment whose APP_BASE_URL is localhost, 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 --env that cannot be served is refused the same way rather than dropped. env push sends 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 tool cloud_box_create now refuse a target of that kind instead of storing it. cloud_box_create answers {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 of envs and --env.

  • New: npx @unotest/web init --ci github writes .github/workflows/unotest.yml at the root of your git repository. Its steps run in unotest/: on every push it runs npm ci, so the version pinned in unotest/package.json is used, and then npx @unotest/web bundle push --run --collection examples --json, with the box address and push token read from the repository secrets UNOTEST_BOX_URL and UNOTEST_BOX_TOKEN. In a monorepo the steps run in the project’s unotest/. Outside a git repository no workflow is written. A re-run leaves the file as it is; --force takes 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 push after box create no longer warns that the repository “maps to no project on this box”. box create (and the cloud_box_create MCP tool) now maps the project’s git origin, 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: lint no longer reports lint:external-variable-undeclared for 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 lint prints when it skips the type check now says where typescript is 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 where typescript belongs. init also lists unotest/unotest.config.mjs under “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 tool dsl_lookup find a word only where a word begins. api lint no longer prints scrollIntoView (“scrollintoView”), and “sort table rows” no longer suggests dragAndDrop. A task without a negation no longer finds a check by the words it negates: “wait until the success toast appears” gives assertVisible, waitFor, waitUntil, not assertNeverAppears; “an error toast never shows” still finds it. dsl_lookup now returns up to four functions instead of three, and every reply carries commands: where the CLI commands are listed, since a task such as “a typo in a variable name” is what lint reports. api says the same when no function matches.

  • Fixed: a run of a whole file plays its test_* functions only. A flow_* 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 (including init --ci github --force) no longer rewrites unotest/package.json from the template, which dropped every dev dependency except @unotest/web so the next npm install removed them. The existing file is now only completed with what the template has and the file lacks; your dependencies and your @unotest/web pin 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: init run from inside the suite (cd unotest && npx @unotest/web init --ci github) no longer creates a second suite in unotest/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, init sets the project up in place, as before. An explicit [target] is unchanged.

  • Fixed: the MCP server now sees a login made 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 answer not-logged-in with the login command.

  • Fixed: a linter.rules entry for validator:implicit-declaration or validator:unsupported-statement no longer loses its level. lint now 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 schedules prints for running a test entry now reads npx @unotest/web e2e <feature>/<name> --scheduled. The old e2e <name> pointed at a file in the root of unotest/e2e/, which lint rejects.

  • Fixed: a teardown(…) or step(…) written without its label or its function, such as teardown(() => { … }), stops the test at that line with teardown 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 displayMode set, WebKit pages see navigator.standalone on Linux too, as the reference says — true for standalone, false for 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 no navigator.standalone. Chromium still has none; without displayMode the 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 login mints 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 destroy prints when billing stopped (destroyedAt in --json and in the cloud_box_destroy answer). 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.txt and page-html-error.txt that 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; fetch and xhr responses of a text or JSON type come with responseJson and responseText (up to 64 KB), and requestJson is 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_NETWORK on, network.json now holds the last 500 requests of a context instead of 2000.

    replaces: evaluate with a fetch wrapper

  • New: openTerminal() with no app opens the shell terminal — 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: terminalType and terminalPress send what an xterm sends (the arrows follow the program’s cursor mode, Alt is ESC before the key; Meta is refused), terminalResize reaches the program, terminalScreenshot draws the screen in its colours with the headless Chromium, and terminalClipboardRead / terminalClipboardWrite use the terminal’s own OSC 52 clipboard. openTerminal('Terminal') and openTerminal('iTerm2') open a macOS window as before. The shell terminal needs node-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 same node-pty version 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 — over ssh -tt, and the same terminal* 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) and terminalClipboardRead() 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}, where signal is the signal’s name such as SIGTERM, or null. 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, locale and cleanProfile — so a run does not depend on the machine it is on. cleanProfile: true starts the shell without your rc files, with only PATH, HOME, USER and TMPDIR of 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: terminalWaitForText returns what it saw and terminalText() 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} gives null. 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 on launch.userDataDir and 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-budget now reports in the viewer’s editor and in the ESLint plugin as well, not only in lint, e2e and the MCP tools. The editor takes the budget from the project’s config when the viewer starts, so a changed testTimeoutMs shows after the viewer is restarted.

  • Fixed: with UNOTEST_CONSOLE=1 a run’s console.json now holds the console of every browser context of the test, each line with its contextIndex, not only the active one. box_run_download and box run --download also unpack a passing run’s console.json and the full texts of its long notes into .unotest/box/<runId>/, and the reply names that directory as capturesDir.

  • Fixed: note() and log() 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 --notes output and in a failed step’s notes ends with … (+N chars, full: <path>), and the event carries full and fullLength.

  • 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 with osascript for 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/.secrets is 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 no terminal.png in that case and says why in terminal-frame-error.txt. A secret the terminal wrapped onto the next row is found and masked too, in terminal.txt as well, and in what terminalText() and terminalWaitForText hand to the scenario, so a note() or log() of the screen shows no part of it. Whether a wait or terminalAssertText sees 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 terminalWaitForText or terminalAssertText runs 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 .secrets is masked in that excerpt, also when the terminal wrapped it, and in the screen excerpt of a terminalType echo 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.example declares 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 of ReferenceError: <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 same ReferenceError.

  • Fixed: terminalWaitForText and terminalAssertText no 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 literal timeoutMs in a test that is as long as the test’s whole budget, whether the one setTestTimeout() sets or the project’s.

  • Changed: assertScreenshot now warns, once per run, when the browser is WebKit and the page has an element with backdrop-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: e2e with no scenario name, and --help, now show e2e <feature>/<name>, the path lint accepts. They offered unotest/e2e/<name>.js first, a file directly in unotest/e2e/, which lint refuses as lint:scenario-in-root and run_test will not run.
  • Fixed: with TypeScript 7 installed, the line lint prints when it skips the type check now says Install a supported one (npm i -D typescript@5) instead of Install it, which read as if TypeScript were missing. The README runs schedules, collection … --scheduled and viewer from unotest/, 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. lint and run_test no longer refuse it as lint:external-variable-undeclared (“PAGE is 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 remains validator:top-level-statement.
  • Fixed: lint no 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, so lint now prints one line, for example type 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 now typescript >=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, instanceof and 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, pause and waitUntil stand in for them, and console prints 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 let and Continue entries of this release:

    • keys(), values() and entries() of a Map or a Set, and matchAll, were arrays and are now iterators. for…of and 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), else testTimeoutMs or UNOTEST_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 the let lines 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: AstExecutor is no longer exported from the package root. The UNOTEST_ENGINE switch 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 (lint still names it, with the advice for wait / sleep / waitForTimeout), and a member read on null or undefined fails with JavaScript’s TypeError. An object handed to a DSL function with a key it only inherits, written under __proto__: or made by Object.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_ENGINE from your environment. Replace rows.sortBy('order') with rows.sort((a, b) => a.order - b.order) (strings: a.name.localeCompare(b.name); {desc: true}: swap a and b). A test rig that built AstExecutor runs scenarios through npx @unotest/web e2e or the MCP run_test tool instead.

  • BREAKING: a variable assigned without a declaration (i = 0 with no let) 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_test refuses the scenario on it, as on any lint error; e2e prints it and runs. The diagnostic carries a fix — let i; at the start of the test_*, flow_* or helper, where every step sees the name — and lint --fix or eslint --fix with 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-member and validator:unsupported-string-member report nothing. A member a string, an array, a number, a Map or a Set does not have in JavaScript is validator: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 is validator:top-level-statement (it was validator:unsupported-statement).

    Migration: run npx @unotest/web lint --fix once and commit the let lines it adds. In linter.rules, rename validator:unsupported-statement to validator:top-level-statement and validator:implicit-declaration to validator:implicit-global, and drop validator:out-of-language, validator:library-member and validator:unsupported-string-member. A config that still names one of the five loads: the entry is ignored, and lint warns 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, and applyFixes(source, fixes) from @unotest/dsl/validator.

  • New: npx @unotest/web lint type-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 under ts:<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 needs typescript in the project (npm i -D typescript, an optional peer dependency); without it lint says once that the layer was skipped. And lint --fix writes the fixes the diagnostics offer before it reports. replaces: reading a scenario by eye for a typo of a name or a key

  • BREAKING: async and await in a scenario are a lint error, validator:async-await. Before, await was allowed in an async function and changed nothing. Now that scenarios run as JavaScript, an async function returns at its first await, 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_test and e2e refuse to start on it, and a run that gets there anyway fails: a test, a step, a rollback block, a waitUntil or an assertNever body 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, so lint --fix and eslint --fix with the plugin remove every async and await the 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. MCP resume on a paused-failure runtime 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 resume per failure no longer walks the test to its end.

  • Changed: viewer opens a browser tab only when a person is at the terminal — stdin and stdout are TTYs and CI is unset (or 0 / false). Launched by an agent, a script or CI, it prints the URL and opens nothing. viewer --open opens the tab anyway; UNOTEST_VIEWER_NO_OPEN=1 still turns it off and beats --open. login follows the same rule, so under CI it no longer opens a browser even on a pseudo-terminal, and the commands that ask a question no longer ask it there: init uses the system browser when it finds one (as in a pipe), box create leaves the Picker off unless --picker is given, and box destroy needs --yes. viewer now refuses an argument it does not know (exit 2) instead of ignoring it.

  • Fixed: login now also asks for billing:read, so whoami shows 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 earlier login keeps the permissions it was given: run login again (after logout) to get one that sees the balance.

  • Changed: a test that never ends is cut by its time budget (setTestTimeout(ms), else testTimeoutMs), 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/executor exports the budget readers a second engine shares with the interpreter: durationBudget, timeLeft, withinDeadline, runHeapLimitBytes and the BudgetOptions / BudgetSite types.

  • Changed: @unotest/dsl/executor exports what a second scenario engine needs to behave like the interpreter: the waitUntil and assertNever option readers and verdicts (readWaitUntilOptions, waitUntilVerdict, readAssertNeverOptions, assertNeverVerdict), a synchronous polling loop (pollWindowSync), tagText, toExecutionError and softFailuresError. A rollback frame is now TeardownFrame<B>, generic in what the engine that registered it plays it with (ScenarioTeardownFrame is TeardownFrame<Closure>); an engine handed a frame another engine registered refuses it with ForeignTeardownFrameError. 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/dsl exports pageCodeArgumentOf(call, pageCodeArg), the argument in a call’s page-code position as written, and @unotest/dsl/executor exports 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), else testTimeoutMs or UNOTEST_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 with budget exhausted at the next line. On a failure pause, Continue finishes the test as failed.

  • Changed: keys(), values() and entries() of a Map or a Set, and matchAll, give iterators, as in JavaScript. for…of, a spread and Array.from read 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 of apiCall it 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}} or shell(); 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, and debuggerFileNameFor(suffix) the store’s file name inside it. @unotest/core: debuggerFileIn(stateDir, projectRoot, suffix) takes that directory explicitly; debuggerFile and readDebuggerBreakpoints take 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. name is the keyframes name, the property a transition moves, or a script animation’s id. Infinite and paused animations are listed too, marked by infinite and playState; hasAnimation and waitForAnimations count 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 animation gives waitForAnimations, api not appear gives assertNeverAppears, api sleep gives 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 as llms.txt at 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) and assertFileHas('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. assertFileHas waits up to {timeoutMs} (default 20 s). assertFileLacks searches 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 as readFile; symlinks inside a directory are not followed.

  • New: assertNever(() => apiCall('GET', '/me').status == 200, {withinMs: 5000}), a negative window for a condition, the pair of assertNeverAppears for anything that is not a locator. replaces: pause. The body (an expression, or a block that returns 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. withinMs is required, intervalMs defaults to 250. A malformed call is reported under the new validator rule validator:assert-never-shape. A suite function of its own named assertNever keeps running as before.

  • New: assertClipboard(/\/invite\/\d+$/) waits until the clipboard holds the text a “copy” button wrote, and clipboardRead() 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}) with e holding the string "no" matched exactly and failed seconds later as locator 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 in an 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 setViewport and emulateMedia arguments before the run, as the run already did: a misspelt key ({heigth}), a value emulateMedia does not take ({colorScheme: 'blue'}) or anything but a size or null for setViewport is named on its line. The editor types follow: setViewport(size: {width, height} | null), and emulateMedia offers its values.

    Signatures, the API index (npx @unotest/web api, llms.txt) and the DSL reference now name the time limit timeoutMs, the key the option lists and refusals already used. timeout still 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 (waitUntil on goBack, goForward and waitForUrl; force on selectOption; position and modifiers on doubleClick), and waitForText no longer offers first, 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 takes null as one member of a {oneOf}; hint on 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 collection run 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 in time and 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 as npx @unotest/web api, which now also matches a longer form of a word (animating finds waitForAnimations).

  • New: lint:external-variable-no-value reports a variable that only unotest/.env.example or .secrets.example declares while no file, overlay or the shell gives it a value. replaces: lint:external-variable-undeclared for 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 of e2e); the lint command 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-undeclared now 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.example apart as declared 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; browsers or device next to it is refused. replaces: browsers: ["webkit"] with device and a page-side navigator.standalone stub. Config key displayMode: '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 WebKit navigator.standalone is true. 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 getComputedStyle in page code (use computedStyle(locator, property)), naturalWidth / complete (use getImageSize(locator)), a pause before assertHidden or assertCount(…, 0) (use assertNeverAppears(locator, {withinMs})), and a pause whose step label or comment mentions a fade, an animation or a control being enabled (use waitForAnimations(locator) or waitFor(locator, {state: 'enabled'})). Each message names the section of node_modules/@unotest/web/guides/dsl-reference.md that describes the step. Set the rule to off in linter.rules to 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 marked isError unless {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. env passed to mcpStart is named in the report, never its values. replaces: bun scripts next to the suite.

  • BREAKING: page code given to evaluate, evaluateOnLocator or evaluateInServiceWorker as a string is now parsed by the validator. A string that is not one JavaScript expression is refused before the run as validator: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 use String.raw`…` . Set validator:page-code-string to off in linter.rules to 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) makes document.hidden true and visibilityState 'hidden', document.hasFocus() false, and fires blur then visibilitychange; setPageHidden(false) fires visibilitychange then focus. The state holds across navigations and in frames until the tab is shown again. replaces: overriding document.hidden by hand in evaluate, 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. setPage and bringToFront do not show a hidden tab.

  • New: TestRuntimeManager.inspectState(id) returns a runtime’s snapshot without vars — state, last event, failure, result, rollback — and does not walk the scenario’s variables. replaces: inspect(id) for callers that never read vars. 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} is validator:options, before the run and during it, and a number is validator:arg-kind. The explore_step MCP tool takes the same forms for select_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 named unotest-<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, vmStop and vmDelete take only a name vmClone returned in the same test — other VMs on the machine are refused, never touched. A boot that fails errors vmRun at 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-hints finds the places in unotest/e2e/ that do by hand what a typed step does. replaces: reading lint info 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: a pause before checking that an element is absent (assertNeverAppears), a pause to let an animation settle (waitForAnimations) or a control become enabled (waitFor {state: 'enabled'}), getComputedStyle or naturalWidth in page code (computedStyle, getImageSize), and page code written as a string (a function literal). It shows these rows even when linter.rules turns their rule off; a line marked // lint-ok is not counted. The first run on a new version shows the counts once: e2e prints them, and the MCP run_test reply carries them as upgradeHints {state: “typed-steps-available”, version, hints, next}.

  • BREAKING: waitForRequest with a ws:// or wss:// mask is refused before the run as validator:arg-kind, and by the run, with WebSockets 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: waitForRequest on a socket mask, which waited its whole timeout and never saw the socket.

    Changed: mockRoute with a ws:// or wss:// 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: refuse on 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 answers navigator.credentials.create() and .get(). virtualCredentials(id) lists what it holds, by base64url id, rpId and sign count, and removeVirtualAuthenticator(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 and setOffline(false) connects it again. replaces: nothing. Authenticators, permissions and offline are all undone when the context closes.

  • New: @unotest/dsl/validator exports chainReceiverIsArgument(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 as filter declares the receiver in its signature. A chainable builder such as getByRole keeps the arguments as written. The validator and tools that read a scenario line arguments up by this one rule.

  • Changed: dsl_lookup and npx @unotest/web api weigh 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 finds setPageHidden first instead of newPage, goBack and dragAndDrop. 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)” finds filter and locator. New search words: background, visibility and tab hidden for setPageHidden, faded in for waitForAnimations.

  • 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 … — so new Set([1]) running beside a refused new 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-changed no longer warns on an escape already migrated. It now reports only a backslash escape JavaScript drops — \s reads as s, \. as . — the regex text a backtick template used to keep and now silently loses. \\s, \n, A and the other escapes JavaScript reads as meant stay quiet, so doubling the backslash clears the warning without String.raw or switching the rule off.

  • Fixed: openTerminal on 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 an ssh-copy-id to the clone’s address, which the run has already deleted, and says that ssh-copy-id asks for the image’s password.

  • Fixed: terminalType typed the letters of the Mac’s current keyboard layout, so under a Russian or another non-US layout the terminal got different text (clear arrived 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. terminalPress with 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 bare Permission 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' or throw {message: 'x'} — instead of the general new X(…) advice about object literals and regex literals, in lint and in the run alike.

  • Fixed: lint:typed-step-available reads page code written as a string too. evaluate("getComputedStyle(document.body).color") now gets the hint for computedStyle(locator, property) in the same pass as the validator:page-code-string info, 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: e2e and lint in a project without unotest/jsconfig.json say once, in one line, to run npx @unotest/web types, which writes it so the editor completes the scenario functions. replaces: finding the types command 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 waitUntil reference 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 a let inside the test_ function or the step, and the body writes it; a top-level let is 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: apiCall takes at most five arguments: method, path, body, headers and options. 0.49.2 dropped a sixth one without a word, so a call such as apiCall('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 apiCall is 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 letter s, and the check gives a wrong answer with no error. The lint warning template-escape-changed marks every such template.

    Migration: in a template, write a backslash meant for the page as \\ (/\\s+/), or wrap the template in String.raw to 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.

    evaluate and evaluateOnLocator take 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’s fn.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"}) reads API_BASE_X from the project’s environment only (unotest/.env, .secrets and 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_test checks a scenario with the same diagnostics as lint before the browser starts, the vocabulary checks (validator:*) included, and answers any error with lint_failed before anything runs. Info-only diagnostics never hold a run back. What e2e stops 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"}: put API_BASE_X=https://… in unotest/.env, or in the overlay of the environment that needs it.
    • Code that imports the engine from @unotest/web directly: lintAst takes an ESTree node (parsed.program); AstExecutor runs a parsed scenario, run(parsed, helpers, testName, runtime, options); the node of an ExecutionEvent is an ESTree node; TestRuntimeManager.register takes scope, the executor’s inspectScope. Parse with AcornScenarioParser from @unotest/dsl.
    • @unotest/web/dsl: createWebDslLanguageService() also has validateParsed(parsed, context), the same diagnostics for a file already parsed.
    • lint:filter-unknown-option and lint:role-unknown-option are deprecated and report nothing: validator:options reports a key that filter, locator, getByRole or any other call does not read, with the sentence the run refuses it with. lint:regex-invalid is deprecated too: a regex that does not compile is a syntax error of the file. The three ids still load from linter.rules; drop them from your config, they go away in the next major.

    @unotest/dsl gains walk and childNodes, 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’s Program), and the host hook pageCodeArg(name) that names the argument a function may be handed to as page code. @unotest/core reads a run’s last event by its shape, so it takes the events of either executor.

  • 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 while or for stops with maxSteps budget (100000) exhausted within about 20 seconds.
    • A call of an arrow function with an expression body spends no step. items.map((x) => x.id) or rows.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.
  • Breakpoints set in the viewer no longer dirty the work tree. The store moves from unotest/.debugger.json to .unotest/debugger.json (.unotest/debugger-mobile.json for mobile). init already lists .unotest/ in .gitignore. The viewer, the CLI (e2e --debug) and the MCP run_test read and write the new file.

    Migration: nothing to do by hand. The first viewer start or debug run moves an existing unotest/.debugger.json to the new place, keeping its breakpoints. If both files exist, the new one is used and the old one is left alone. Git then shows unotest/.debugger.json as deleted: commit the deletion.

    @unotest/protocol: debuggerFileFor(suffix) returns the new path; legacyDebuggerFileFor(suffix) names the old one and LOCAL_STATE_DIR is .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 ‼, not failed ✗. A wrong value in an options slot now stops the run before the browser starts.

    • maxSteps budget (100000) exhausted and the run holds … MB more than at its start count 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 the e2e and collection summary. A spent time budget (testTimeoutMs, setTestTimeout, UNOTEST_MAX_DURATION_MS) is still failed ✗: 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 with run 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. Other arg-kind findings 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-kind to quiet this finding should set validator:options instead.

    @unotest/dsl/executor: BudgetExhaustedError is now an abstract base, and instanceof it still finds every spent budget. A spent step budget throws StepBudgetExhaustedError, a spent time budget throws DurationBudgetExhaustedError, which is the only one marked as a failed check (isCheckFailure). MemoryBudgetExhaustedError is no longer a failed check either.

  • e2e stops before the browser on the scenario errors the run would refuse anyway. It still prints every diagnostic of the pre-run lint, as lint does, and now refuses to start on a validator:* 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})), a const assigned again, a member a value does not have, a scenario variable read inside the function handed to evaluate (the page has no such variable) — on page code for evaluate built from scenario values (a ${…} in it, or a string put together at run time), and on a rule the project raised to error in linter.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 to lint. The refusal exits 1 after the line run stopped: N error(s); no browser starts and nothing is written under unotest/.runs/. Errors of shape — validator:arity, validator:step-shape, a call outside step(...) — and lint:* 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 lint to see the same list and fix the call. Setting the rule to warn in linter.rules lets the run start again, and it stops with an error where it did before.

    @unotest/dsl: a ScenarioDiagnostic the run would refuse as well carries failsRun: true.

  • Four messages a new user meets on a first run now say what to do.

    • A test’s result line in the e2e and collection output 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 with error, and only the sign told them apart. The line still goes to stderr. A CI step that searched the output for error ✗ or error ‼ should search for failed ✗ / errored ‼ instead.
    • {timeout} and {timeoutMs} are one option: lint now 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.), and e2e stops on it before the browser starts. A list of the keys a function takes names one key per option: Usage: {timeoutMs?} for assertUrl('/', '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: testTimeoutMs in unotest.config, UNOTEST_MAX_DURATION_MS, or setTestTimeout(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?, in lint and in a run alike. A name more than two edits from any function gets no guess.

    @unotest/dsl: an options argument kind may carry same (an older key to the name it stands for); validator:options refuses a call that gives both, and optionKeysLabel, sameOptionPair and sameOptionMessage are exported for a vocab’s own guards. BudgetExhaustedError carries at (file and line) for a spent step budget, and its message says what to do.

  • collection, box runs and box run now use the same signs and words as e2e and the viewer: ✗ failed for a check that did not hold, ‼ errored for a script that could not do what it was asked, and ■ aborted for 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: … — errored and is labelled errored. 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 for fail=0 reads 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 reads 1 of 3 errored.
    • box runs and box runs --latest show ‼ errored, ■ aborted and ✓ failed on purpose (a test marked @expect-fail) in their status column. Before, all three were shown as ✗ with the stored status. box run names the same word in brackets, for example ‼ checkout (errored).

    box run --follow still prints an errored scenario as ✗ … — failed until the box itself reports errored runs.

  • validator:if-shape checks the condition of while, do...while, for and the ?: operator as it checks if: 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_test does 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.json that init writes sets noLib: true and 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 offers Date, setTimeout, document or s.big(), which lint refuses. What to do: an existing project runs npx @unotest/web types once. A jsconfig written by an earlier init and left untouched is brought up to date; lint and runs print a one-line reminder until then. A jsconfig you edited by hand is never rewritten: the reminder says to set "noLib": true and add ./node_modules/@unotest/web/types/unotest-lib.d.ts to include.

  • A DSL function no longer drops an extra argument (#428). Before, the run ignored it: assertEqual(1 + 1, 2, 'math works') was flagged by lint (validator:arity), yet e2e ran the test and passed it; a call whose arguments come from a spread, such as note('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, dbQuery and the like) are unchanged.

    Migration: a suite whose CI runs e2e without lint, and that has a call with an extra argument, passed before and now goes red on that test. Run npx @unotest/web lint — every such call written out is listed under validator:arity — and remove the extra argument, or pass the values as one array.

    goBack, goForward, selectOption and dragAndDrop take their options in lint too. The calls always read {timeoutMs} and the browser’s own keys, as the DSL reference says, but lint called goBack({timeoutMs: 5000}) an extra argument. Lint now accepts these options and names a misspelled key, as it does for click.

    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 with Maximum call stack size exceeded and 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, Object or Array. Object.keys(Math), Object.entries(Math), Object.assign(Math, …), Object.hasOwn(JSON, …), Array.from(Math) and for (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 through Object.entries(Math) broke Math for every later test of the same process. typeof Math is 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) or Array.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 a RangeError that 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 ...items used to fail at about 120,000 elements with Maximum 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 lint before 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.

    • doubleClick no longer takes button or clickCount: they never reached the browser.
    • waitForText no longer takes first: the wait always looks at the first visible match.
    • waitForNavigation no longer takes waitUntil: it waits for the navigation the page starts.
    • scrollIntoView takes 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 read exact), screenshot (fullPage), setCookie (domain, path, expires, httpOnly, secure, sameSite) and apiCall (base).

    In the scenario type declarations an options parameter is Record<string, unknown> instead of any.

    note(label) without a value is an arity error in lint.

    @unotest/dsl gains validateScenario, the validator over the new scenario parser. It reports the old rules with their ids and texts, and adds out-of-language (JavaScript the scenario language leaves out, with what to write instead), implicit-declaration (an info: a variable assigned without let / const), evaluate-interpolation (${…} in code handed to the page) and template-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_test and explore_run_flow now check the helpers of _helpers/ for it before anything runs: evaluate('1 + ' + t) or evaluate(() => document.title + t) inside a helper stops the run, and the diagnostic carries the helper’s file (the CLI prints unotest/e2e/_helpers/h.js:2:10). explore_run_flow runs the same pre-run lint as run_test before 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 with lint, so a run that passes today does not stop on them.

    evaluateInServiceWorker takes a function as well as a string, as evaluate does — 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 lint shows the same lines.

    @unotest/dsl: a ScenarioDiagnostic whose finding the run refuses in any file it loads, helpers included, carries harmsRun: true.

  • validator:evaluate-interpolation now judges the code handed to evaluate / evaluateOnLocator / evaluateInServiceWorker by 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 by lint and 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 with let or const, or assigned without a declaration (js = `...`; evaluate(js)), or the variable of a for…of over 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 data randomWord(n) and randomEmail() still read better.

    new Map(entries?) and new Set(items?) are part of the scenario language, with their members as in JavaScript: get, set, add, has, delete, clear, size and forEach. keys(), values() and entries() return arrays, so m.keys().map(...) works directly; for...of, a spread and Array.from iterate a Map or a Set as in JavaScript. In note, log and the viewer’s Vars a Map reads as Map(1) {"a" => 1} instead of {}. The validator names a member a Map or a Set does not have (m.length is m.size), and the editor typings declare the array results. new stays out of the language for anything else, including a Map the scenario declares itself; WeakMap, WeakSet and WeakRef remain 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 ‼ with the 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 for e2e, run_test and explore_run_flow. Before, a statement such as Array.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, match with a global pattern, matchAll, Array.from, Object.keys, Object.values and Object.entries of a string, and the arrays JSON.parse makes now respect the 1,000,000-element array bound. Past it they fail with a RangeError such 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.entries of a 10,000,000-character string took 1.5 GB in one call, Array.from of one made an array of 10,000,000, and JSON.parse of a text within the string bound made an array of 5,000,000. Array.from makes one element per character of a string, an emoji included. JSON.parse checks 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 split a limit, or read the text or the data in parts.

    @unotest/dsl/executor: RunOptions gains an optional memory, a MemoryProbe (heapUsedBytes(), heapLimitBytes(), and an optional collectGarbage(); 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 exports MemoryBudgetExhaustedError and MAX_RUN_HEAP_GROWTH_BYTES.

  • 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, so BASE_URL or 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’s UNOTEST_GROUNDER_REMOTE_TOKEN, _PROVIDER_KEY, _PICKER_KEY, _EMBED_KEY, _SHARED_TOKEN, _METRICS_TOKEN and _REVOCATIONS_TOKEN, and the judge provider’s API key (ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY), the last only while UNOTEST_JUDGE_MODE=local and 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}} in explore_step and sandbox.exportSecrets give the same sentence.

    @unotest/protocol exports JUDGE_API_KEY_ENV and judgeSecretEnvNames(env). @unotest/grounder-client exports GROUNDER_SECRET_ENV and GROUNDER_SECRET_ENV_NAMES.

    Migration:

    • A scenario or a shell() command that read one of these names on purpose: give the value its own name in unotest/.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 run is now counted failed or errored by the class of its error, not by the words in its message. A failed check is failed; a script that could not do what it was asked is errored. 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 be errored, with the browser library’s “strict mode violation” text.
    • A test that runs out of its budget (setTestTimeout, testTimeoutMs, UNOTEST_MAX_DURATION_MS) is failed. It used to be errored.
    • A stale or unresolvable element reference is failed. It used to be errored.
    • A shell command that runs out of time is errored, like any other shell command that does not finish. It used to be failed.
    • A wrong call of the script (a missing file, a path outside the project, two names of one option) stays errored, and // @expect-fail does not excuse it: the marker expects a failed check.

    @unotest/dsl: ExecutionError.checkFailure carries the error’s kind across the executor, with the CheckFailure interface and the isCheckFailure guard for error classes of a host vocabulary.

  • 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 — is errored.

    • Home has an errored card 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-finished and collection-run:scenario-finished carry errored: true, and RunMeta carries errored. The outcome stays failed, 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.

  • The viewer reads a scenario the way the engine runs it. Its step tree shows try / catch / finally and switch as control rows: the header row carries its own status, and catch (e), finally and each case …: open a branch beneath it, as else does for if. 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 a do … while row shows its condition. A finished step whose else, catch or other case never ran reads passed, not running. await step(…) in an async test 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-level const, async tests, try or switch lists 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.sortBy and 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/dsl checks the tree ESLint already parsed instead of parsing the file a second time, with the same diagnostics as unotest-web lint. A file that defines one function twice gets the parse diagnostic lint prints rather than an exception out of ESLint, and the rule runs under ESLint’s RuleTester. @unotest/web/dsl exports what such a frontend needs: scenarioFromProgram, ProgramComment, ScenarioSyntaxError and parseDiagnostic(error), the diagnostic lint reports for a file that does not parse.

    The function handed to evaluate / evaluateOnLocator is code of the page, and lint no longer checks it as the scenario: document, getComputedStyle(el), new RegExp(re) or an upper-case page global in it are not refused. What lint does 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, assertAttribute or assertValue now 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 with expected equal "S/ 500", got "S/ 500" — two lines that look the same. The message now reads got "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 as 1 errored, the count reserved for a run that broke rather than a check that did not hold.

  • The type declarations of @unotest/web/config are 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/dsl reports the engine’s errors, and the new rule @unotest/dsl-warning reports its warnings. recommended() sets them to error and warn. A suite with only warnings (lint:pause-explicit, say) no longer fails npx eslint. The file is still validated once.

    Migration: with recommended() there is nothing to do. A config that names @unotest/dsl by 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 a step(...) 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.

  • lint reports 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, lint passed 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 undefined sent its pause without any variables. It now sends the others and leaves that one out, as the live view does.

    @unotest/core exports cappedJson(value, minBytes, replacer?): the text of JSON.stringify, or a prefix of it longer than minBytes bytes, read only that far. It also exports serializeVars. The stringifyVar hook of buildRuntimeInspection gets the byte count as its second argument, and may return undefined for a variable with no JSON. snapshotOf in @unotest/dsl/value takes an optional maxNodes, 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 let and const declared inside the failing step(...). 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 once runTeardowns is over.

  • Viewer fixes:

    • Run again tab. After Run again on an errored or // @expect-fail test, 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 check when a check did not hold, script error when the script could not do what it was asked. The error’s class name, such as NullMemberError, now appears on hover. Before, the badge showed only the class name.

    @unotest/protocol: step:started gains optional endLine and endCol, the end of the statement. endCol is the 1-based column just past its last character.

  • Viewer: a statement that hands a function to a call, such as xs.forEach((x) => { ... }), now opens into the statements of that function, as an if does. Each of them shows its status and notes and takes a breakpoint. Page code passed to evaluate / evaluateOnLocator stays closed: the page runs it, not the scenario. The DSL vocabulary entries carry pageCodeArg for that, and @unotest/dsl exports calleeName.

  • 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=1 reads 1 ✓ 2 ✗ (1 ‼) in the row with the failed-on-purpose count beside its purple dot, and 1 passed, 2 failed (1 errored), 1 failed on purpose to a screen reader. An @expect-fail test that passed counts as failed, as everywhere else.

    The viewer’s Vars tab shows a Map and a Set as 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 type string. 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, step and run_test print each variable byte for byte as before. The run’s runtime.json and the paused events 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/dsl gains 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 $type key 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 own timeout on such a page: the page’s reads count against it, so assertText(loc, x, {timeout: 400}) fails after about 400 ms and says the page gave no answer, instead of waiting out the action timeout. assertNeverAppears no 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.json when the browser console was not recorded. The console is recorded only with UNOTEST_CONSOLE=1; without it, the file is left out, get_failure_console answers entries: null with a message naming the variable, and the prepare-fix prompt says the console was not recorded instead of “no console entries captured”.

  • env(name, fallback) returns fallback when the variable is not set, a default in one line: api = env('API_URL', 'http://localhost:3000'). Before, it took an if / else: the DSL has no ??, and env('X') || 'default' gives true, not the value. Without a fallback env(name) still returns null for 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. evaluateOnLocator already 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 evaluate or evaluateOnLocator is now answered with what to write: the JavaScript goes in as a string, so quote the lambda — evaluate('() => new Date().toISOString()'). lint and 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 optional suggestForLambdaArgument(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 keeps bundlePath and screenshotPath relative to the project root (the directory holding .unotest/ and unotest/), and the agent integration guide now says so.

  • getByRole(role, options) now reads Playwright’s state options: checked, disabled, expanded, pressed, selected, level and includeHidden, next to name and exact. Before, every key but name and exact was dropped without a word, so count(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 rule lint: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 getByRole step 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-base now finds the host variable of a goto inside a ternary and in a longer sum: goto(ready ? APP_URL : '/x') and goto(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 --help prints 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, lint and MCP run_test used to write it into your tree when it was missing; now only init and types write it, and init adds unotest/jsconfig.json to .gitignore. A run still refreshes the gitignored .unotest/types/env.d.ts with 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. A jsconfig.json you already commit stays as it is; remove the new .gitignore line if you want to keep committing it.

  • locator(selector, options) now reads its options: hasText, hasNotText, has and hasNot narrow the matches exactly as filter() does, and any other key is refused at lint time and at run time. Before, the second argument was dropped without a word, so count(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 reading false.

    check / uncheck now 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 / uncheck on an element that is not a checkbox, radio or switch is refused; use click for it.

    A <label>, or anything inside one, stands for the checkbox it names, as in Playwright: check, uncheck and isChecked on getByText('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) and filter(loc, options) now refuse options that are not an object, naming the function: locator('option', 5) and filter(loc, null) used to build a filter with no conditions and match everything, and locator('option', null) failed with a bare TypeError. filter(loc) with no options is unchanged. getByRole(role, {exact}) now takes only true or false, like its state flags; {exact: 'yes'} used to count as true.

  • MCP locator input now refuses a regex source that 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 --notes and collection --notes print every note() of every test under its line, a passing test’s too, as note <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 \n and \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 read name through 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.userDataDir or UNOTEST_USER_DATA_DIR) no longer prints a line about it on every run. The run journal records it instead: the run:started event in steps.jsonl carries browserProfile, 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, a return, a lambda body. Only the branch the condition picks runs, so x == null ? '' : x.name is a safe guard. lint accepts a ternary in a slot when both branches fit it. ?. and ?? are still refused, and the parse error now points to the ternary or if / else.

  • New texts(loc) returns the textContent of every element the locator matches, in page order, and [] when nothing matches. It is one read with no wait: the list to branch on, where assertTexts is the waiting check. Before, the only way to get that list was count plus nth(i) in a loop, since textContent on several matches is a strict-mode error.

  • The viewer no longer paints a test marked @expect-fail as 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 shows expected in the same purple as the tree, instead of failed.

  • 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 --test give. 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, since bundle push now lists each scenario file’s tests in the bundle. An older bundle is not checked.
    • box runs, box run and notifications name such a run file › test, as bundle push --follow already did. This also applies to a file with a single test, whose runs used to show only the file. box queue names a waiting or running run of one test the same way. box run --json and box queue --json gain a test field 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.
  • 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 runs and box run print run labels with control characters shown as escapes, so a name written by an older box cannot drive the terminal.

  • A const assigned again (K = 2 or K++) now fails the run at that line with the same message lint gives, as JavaScript does. Before, lint refused it but e2e and run_test ran the assignment and passed. let reassigns as before, and a const in a loop body is declared again on every pass.

  • Scenarios can declare variables with let and const, and pass a lambda to four array methods. let x = … and const x = … work like the bare assignment, in the function’s scope, and for (let i = 0; …) declares the loop variable; assigning a const again is a lint error (const-assign). At the top of a file a const holding a literal is visible in every function, as helper functions are. a.filter(x => …), a.map((x, i) => …), a.find(x => …) and a.some(x => …) take an expression or a block with return; 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 — and filter with an options object is still the locator step. The viewer shows scenarios written with the new syntax.

  • lint refuses 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: lint passed and the test failed with Variable "BASE" is not defined. It is now validator:unsupported-statement, an error at its line, with the way out: a function that returns the value.
    • let x = 1 and const x = 1 get the same answer as var (“use direct assignment”) instead of unexpected WORD.
    • An arrow with parameters (a.map(x => x * 2)), an arrow or a function as a value, a spread, for…of / for…in, let in a for loop, n += 1, o.a = 2, try / throw / switch, typeof and ? (ternary, ?., ??) each name the DSL way, at the token where parsing stops.
    • a[0] = 2 says to build a new array with push. .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 / ceil and indexOf on 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 into test_* or into a function.

  • Scenarios can now write into arrays and sort them. arr[i] = v replaces an element (nested indexes and object keys too); an index past the end fails the step instead of leaving holes, and arr[arr.length] = v appends. The keys __proto__, constructor and prototype cannot 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 for a.sort() now points to sortBy, and its message for a non-list assertTexts argument names it expected, 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 as x != null && x.a == 1 no longer fails with “Property access on non-object value” when x is null, including inside waitUntil. The result stays a boolean.

  • The expected value of assertTexts, assertText and other assertions can now be any expression of the right kind: a variable holding a list, an index read such as labels[i], or a helper’s result. Previously the linter accepted only an array literal for assertTexts and rejected arr[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 for split no longer points to a shell script.

  • @expect-fail covers 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-fail now reads as failed on purpose on its own line too, under bundle push --follow and in the run index, instead of a red failed above the collection’s green verdict.

  • // @expect-fail now reaches the runner, not only the viewer. A marked test that fails is reported as failed on purpose in e2e and in a collection (N of M failed on purpose, expected=N in the summary), and every test_* of a file whose leading comments carry the marker is marked. A collection whose only failures are on purpose exits 0; e2e of 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 records expectFail on such runs.

  • lint knows 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 lint now does not report it as validator:unknown-function. Helpers in unotest/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 lint and now fails it. That call would have failed the run.

  • A collection whose prepare failed on a box is no longer just failed (exit 94) under bundle 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), so box run <id> finds it and gives the same reason instead of “no such run”.

  • bundle push --follow now reads tests marked @expect-fail the 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 --follow also uses the local run’s words: ✗ login — failed instead 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 optional expectFail field; older clients ignore it.

  • env push / env set: a box that refuses a name of the run’s identity (UNOTEST_RUN_ID and the rest) no longer gets the advice about APP_BASE_URL. The box’s refusal now says which kind of name it reserved, under the same reserved code, 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”.

  • lint names a scenario the way e2e does.

    • lint values/string-escapes checks unotest/e2e/values/string-escapes.js, as e2e values/string-escapes runs it. It used to fail with ENOENT and exit 1.
    • A folder, which failed with EISDIR, checks every .js under 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 e2e gives.
  • A run paused on a failure (e2e --debug, an agent’s run_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_MS sets the timeout, and the expiry line says how long it waited.

  • The DSL reference shipped with the package now links readJson to 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 an if block, 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-test now reports only a skip() 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: true next to ok: true, so readers that do not know the field see the same events as before.

  • getImageSize(loc) reads an image’s natural size without evaluateOnLocator.

    • It returns {width, height} of the <img> file, or null when the image is broken or has no src. 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 a LocatorError naming its tag; an inline <svg> is pointed to boundingBox. An image that never finishes loading fails with a TimeoutError; for a loading="lazy" one the message says to scrollIntoView() it first.
    • It works inside iframes, cross-origin ones included.
    • The lint:evaluate-discouraged hint now names getImageSize for images, and count / assertCount over .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 CSS locator(...) for the element’s own class or attribute.
  • 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. R now runs the selected scenario headless, the same as “Run again”, and is shown only where the viewer may run. ⌘K focuses the filter in every section. Hints that had no handler are removed: arrows, N new collection, O open .zip, ⌃C abort, ⌥ 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’s failure/screenshot-error.txt holds the same reason. Closing the browser context has the same limit.

    Killing unotest-web outright (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: null for the real browser window, {width, height} for an emulated size. It replaces the project’s viewport for every scenario the collection runs; the same scenario run on its own keeps the project’s window. It works with a browser profile, where setViewport(null) is refused.
    • A wrong value is refused with what to write instead. viewport: null over a project device is 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: and prepare: when it saves a collection.
  • A generate command keeps the scenarios’ copy of application values in step with the source.

    • generate in the config: a shell command that writes unotest/generated/ from your application’s source (constants, label tables, schemas). The scenarios read the files with readJson(). It runs from the project root before every local e2e and collection (once for the suite) before bundle push, and in box create before 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. lint does not run it.
    • A failure refuses the run with exit 95 and names the command. A failure in box create creates no box. --no-generate on e2e, collection, bundle push and box create skips it for one run.
    • init adds unotest/generated/ to the project’s .gitignore, and bundle push packs the folder although git ignores it.
  • A box keeps the names it sets on every run to itself.

    • env set and the values page refuse a value named UNOTEST_RUN_ID, UNOTEST_ENV, UNOTEST_BUNDLE_ID, UNOTEST_EDITOR_TYPES, UNOTEST_BOX_SECRET_NAMES, UNOTEST_QUEUE_LEASE, UNOTEST_RUN_TRIGGER or UNOTEST_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 run generate.
    • 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_CHANNEL included.
  • 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 named file › test. The webhook fields keep their names: scenariosTotal, scenariosFailed and failedScenarios now count and name tests.
    • bundle push --run --follow prints each test of such a file between the file’s start and end, with its own failed steps, log and note lines, under file › test.
    • A test that skipped itself prints in --follow as 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 its test_ prefix. A name the file does not have is refused with exit 2 and the list of its tests.

    // @expect-fail can mark one test: put it in the // comments directly above that test’s function line, 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_test takes test too. A debug session follows one test, so for a file that holds several, run_test without test starts 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 test answers status: "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 collection output says ✗ <file> — 1 of 3 failed and 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-file no longer reports anything: several test_* 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 run prints (skipped) with the reason, and its --json answer carries skipReason. Like a local e2e, it exits 0 on a skip.

    Migration: RunStatus has a new value, "skipped", and RunMeta and the scenario index entry carry the reason (skipReason, lastSkipReason). Code that switches over RunStatus exhaustively 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-started and file-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 and run:started carry test (the function name). Its tests point at the file’s run through parentRunId, as a collection’s scenarios point at the collection’s.

    Migration: lint:one-test-per-file is 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 its step(...) block: printed under the error in the e2e output as note label: value, and returned as lastFailure.notes (the error of a run_test reply). Secrets are masked as in the run journal. A nested step reports its own notes; a step that noted nothing reports an empty list.
    • assertEqual shows every line of its diff. The … N unchanged … folding is gone from its failure message.
  • filter({...}) refuses a key it does not read.

    • It reads hasText, hasNotText, has and hasNot. Any other key (hasClass, visible, a typo of hasText) used to be dropped without a word, so count(getByRole('row').filter({visible: true})) counted every row and the check passed.
    • lint reports it as lint:filter-unknown-option (an error), and the run fails the step with the same message: the key, and the keys filter takes. visible is not supported yet.
    • The MCP structured locator refuses such a filter step too, instead of stripping the key.

    Migration: a scenario with an unknown or misspelled key in filter that 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 CSS locator('tr.done') for a class or an attribute of the element itself.

  • Quieter linter on two rules.

    • lint:echo-assert no longer fires when the text you fill is only part of the text you wait for. fill(box, 'helper') followed by waitForText('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-discouraged gives 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 bare v0.47.0 that read as the box’s version while it was the suite’s @unotest/web. When the suite runs an @unotest/web older 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 in unotest/package.json before 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/web to 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 same bundle rm removes 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 --help now also says that env push merges into the box’s values and lists --prune.

  • env push now 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 with env set or 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 as NAME: variable → secret. --dry-run says 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’s status is now "merged", and it carries set, kept, removed and moved.

  • bundle push --run --follow now 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’s log and note lines. With several collections each line starts with [collection]; with --json the 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 !x and append to an array with arr.push(x).

    !x works as in JavaScript: it returns true or false, treats 0, "", null and false as falsy, the same values if treats as false, and binds tighter than && and ||. Negations no longer need x == 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: after b = a; a.push(1), b sees the new element, and a helper that pushes onto an array it was given changes the caller’s array. Calling push on a value that is not an array fails the step with the value’s type in the message, and the linter flags push on a string. Other array methods are still not available, and the “arrays are literals” hint for push is gone.

  • setViewport(null) runs a test on the real browser window, as Playwright’s viewport: null does, 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: null in the config remains the way to run a whole suite on the real window.

  • assertCount, assertVisible, assertHidden and assertValue accept their options object in a scenario: assertCount(loc, 3, {timeout: 8000}) (or timeoutMs) now passes lint and the run_test check. 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, --concurrency and --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), and environment remove takes 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 push or bundle push --run names 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 push prints 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 in unotest/.env.example. A name read as a bare identifier or as an apiCall base is said to stop a run at the first step that reads it; a name read with env() is said to give null there. 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 evaluate warning 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 for evaluate, evaluateOnLocator and evaluateInServiceWorker: no extra argument gives undefined, one gives the value itself, two or more give one array. Behaviour is unchanged.

  • The lint that runs before a scenario now honours linter.rules from your config, the same as unotest-web lint. A rule set to off (lint:disambig-by-index is off by default) no longer prints on e2e or run_test, and a rule set to warn or error is reported at that level. run_test refuses to start on a rule your config raises to error. e2e prints it as an error and still runs the scenario.

  • The lint:one-test-per-file warning now shows the replacement: each former test becomes a step.soft(...) of one test_*, 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-web now warns when it is not the copy your suite installed. Run as npx @unotest/web lint from 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 run npm i in unotest/. The run goes on in both cases. From inside unotest/, 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) and waitForTimeout(ms) in a scenario are now answered with use pause(ms) for a fixed delay, or a waitFor* for a state, as sleep already was. The hint now appears on the run too, not only in lint: a scenario that reaches the runner with an unknown function name fails with Unknown 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 as getAttribute does. Replaces reading styles through evaluate.

  • New DSL call emulateMedia({ reducedMotion?, colorScheme? }) changes the media features the current tab reports mid-scenario — prefers-reduced-motion ('reduce' / 'no-preference') and prefers-color-scheme ('light' / 'dark' / 'no-preference'), as matchMedia and CSS @media see them. It applies to the current tab only; a tab or popup opened later starts without it. Replaces overriding matchMedia through evaluate.

  • New DSL functions for numbers, so a scenario no longer shells out to node -e to compare one. toNumber(text, {decimal?}) reads a number as a page writes it: spaces and NBSP group digits, 1,234.56 and 1.234,56 both read as 1234.56, and an ambiguous 1,234 fails 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, so round(1.005, 2) is 1.01. formatNumber(n, {locale?, minFractionDigits?, maxFractionDigits?}) formats through Intl.NumberFormat, en-US unless a locale is given, whatever the machine’s language. The lint answer to parseInt, parseFloat, Number, toFixed and toLocaleString now points at these functions.

  • Quoted DSL strings now decode the ES5 escapes \r, \f, \v, \0, \xHH and \uXXXX. Behaviour change: '\u00A0' used to be six characters and is now one non-breaking space, and a malformed \u12 or \xG1 is a parse error instead of being kept as text. In a string a regex is built from, \x2e / \u002e now give the character itself — a . that matches anything — not an escaped one; for a literal dot write \\.. Any other backslash pair stays as written, \b included (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), and hasAnimation(locator) says whether one is running. Infinite animations such as spinners, and paused ones, are not waited for. Replaces waiting on getAnimations() through evaluate.

  • 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 “follow again”, which moves the environment to the branch’s newest bundle and keeps it following. The viewer’s workspace file gains an optional follow field (path, branch), present only while such a selection holds; older viewers ignore it, and an older box never writes it.

  • A scenario that calls assertJudge now 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. e2e refuses with exit code 78 and the message that names what to fix: the mode is unset, a provider variable is missing in local mode, or in remote mode the service does not answer its /health (start one with npx @unotest/judge). A collection asks once, before any scenario starts. MCP run_test answers such a refusal with the new status: "config_error" rather than spawn_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 push names 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. --json carries project, tokenLabel and, when it applies, unmappedRepo. A box older than these fields gets the previous line, without the project.

  • bundle push --run prints the id of every run it queued and, when the box reports one, the run’s link in the box’s viewer. --json carries the link as runs[].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 --follow waits for the runs it ordered and exits with their verdict: 0 when every suite passed, 1 when one failed, 2 when 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. --json adds follow. The box’s guard lets a run’s state through on the project token without a session for GET only; every other method on that path still needs a session.

  • assertUrl and waitForUrl take a /regex/ as well as a string. A regex is tested against the whole URL, unanchored, so assertUrl(/\/cars\/\d+$/) checks a path with an id in it; a failed assertUrl names 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 and lint.

  • A collection whose webServer sets reuseExistingServer: false no 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, waitForJsonLine and 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 then wraps onto a second line instead of being cut off with an ellipsis. The bundle rows above it are unchanged.

  • The lint:evaluate-discouraged warning now names the typed calls that replace the common evaluate uses — computedStyle for styles, boundingBox for geometry, hasAnimation / waitForAnimations for 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_test on 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 in chrome-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=/tmp beside HOME=/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 a shell() 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 no Origin is refused with 403 origin required before 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 MCP config_error reason, a refused collection and a failed assertJudge name 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 to UNOTEST_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/health still answers 200 and now carries runIndex: building, built, or failed with 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. New assertTexts(loc, [...]) checks a whole list in order, with the element count, and assertAttribute(loc, name, expected, {not}) waits for an attribute value such as aria-expanded. A string still compares whole by default, {exact: false} for a substring, so existing scenarios behave as before. The “From @playwright/test” page maps toHaveText, not.toHaveText and toHaveAttribute.

  • 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 with z.toJSONSchema and the scenario reads with readJson. 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 $ref inside the schema (#/$defs/...) is followed; a $ref to another file is refused, as is a schema that declares another draft or is itself invalid. format is not checked. @unotest/web now depends on ajv.

  • 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}], and assertNoConsoleErrors({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; since takes a time from nowMs(). UNOTEST_CONSOLE still decides alone whether a run writes console.json into its artifacts and failure bundles.

  • New DSL function env(name): returns an environment value as a string, or null when 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 bare UPPER_SNAKE name reads: the environment the run started in, then unotest/.env. A secret, from unotest/.secrets or 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 each test_* in a new browser on a new, empty profile directory, so an extension loads again, its service worker starts from scratch and nothing in chrome.storage, localStorage or 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 with launch.userDataDir, and mockRoute answers the worker’s own requests in every test. It cannot be combined with launch.userDataDir, and it is refused with storageState, with more than one worker and on a box; UNOTEST_USER_DATA_DIR overrides 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 example skip('no live API configured', env('LIVE_API') == null). Without a condition it always skips; with a false one the test goes on. skip is a test guard: it stands in the test_* body next to the steps, like step(...) and teardown(...), and the validator accepts it there. The run reports skipped with the reason, neither passed nor failed: the CLI prints ○ test: … skipped: <reason> and counts 1 skipped, a collection counts skipped=N(test=N), and run_test answers outcome: "skipped" with skipReason. 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), reports skip() anywhere else: inside a step or a block, in a helper or a flow_*. Validator contracts gain an optional testGuard flag: a function marked with it may stand as a direct child of a test_* body. On the wire a skipped run is completed with a new optional skipReason field on run:finished and 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 naming testTimeoutMs, and its teardown blocks still play. 30000 gives every test 30 s. The new DSL function setTestTimeout(ms), the counterpart of Playwright’s test.setTimeout, gives one test its own budget, longer or shorter, counted from the test’s start; the error then names setTestTimeout(ms). UNOTEST_MAX_DURATION_MS still overrides the config for a run and keeps its name in the error. A new lint rule, lint:set-test-timeout-outside-test (error), reports setTestTimeout() outside a test_* 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. noWaitAfter on click and fill never had an effect; it is now refused, so remove it.

  • The file oracles now read only where readFile does. waitForFile, assertNoFile, waitForJsonLine, readJsonLine, assertNoJsonLine, waitForFileCount and assertFileCount take a path relative to sandbox.shellCwd, or an absolute one inside the project, the run’s downloads or a directory listed in the new sandbox.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.

    readFile and readFileInfo are 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 from sandbox.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'] }.

  • webServer in the config takes one server or a list — a front and the fake backends it talks to, each with its own readiness url. A list starts all at once, the first scenario runs once every url answers, 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 one url are refused. app-server prints one event per server. Type change: UnotestConfig.webServer is now always a list once the config is parsed (an object in the file still works).

  • Stopping a webServer the 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 to chrome.storage, chrome.tabs and chrome.runtime, with the same argument and return rules as evaluate — and extensionId() gives the id for chrome-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 with launch.userDataDir; without it the calls fail at once and say so. mockRoute also 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, or null when 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 as evaluate.

  • 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, so mockRoute('**/*', {abort: true}) followed by mockRoute('**/localhost:*/**', {continue: true}) is a network allowlist. unmockRoute(glob?) takes mocks off. waitForRequest(glob, {timeoutMs?}) returns the request with url, method, headers, postData and postDataJson; on a mocked mask it also finds a request sent before the call. {abort: true, record: true} keeps what it stopped, and assertNoRequest(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 carry mocked: true.

  • New DSL functions newPage(url?) and closePage(index?): a scenario opens and closes its own tabs. newPage opens 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 in goto. waitForPage never returns a tab newPage opened. closePage closes the active tab, or tab index. 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 with cannot close the only open tab.

  • Four new DSL functions for reading and comparing values inside a scenario:

    • textMatches(text, /re/) returns false on a miss, or [whole, group1, …] for the first match. A group that took no part is null. A string pattern is refused with a pointer to textContains.
    • 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: null when 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 a null one. 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}) and readFileInfo(path). readFile returns a file’s text, or its bytes as base64 with {encoding: 'base64'}. readFileInfo returns {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 to sandbox.shellCwd (the project root by default), an absolute path inside it, or the path that waitForDownload() 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. readJson and upload now 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 to sandbox.shellCwd (the project root by default), the same base readJsonLine uses, 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 option setPage behaves as before and brings the tab to front. New DSL function bringToFront(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-boolean activate fails the step instead of being ignored.

  • viewport: null in the config turns size emulation off: the page is as wide as the real browser window (headless: the size from --window-size in launch.args). A device next to it is refused, and so is setViewport(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}. path is the absolute path of the saved copy; read it with waitForFile. name is 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 new download event 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, when setPage switched to it, or when an earlier waitForPage returned it. {timeoutMs} defaults to the navigation timeout. When no new tab arrives in time, the step fails with no new tab within N ms, N tabs open. This replaces the pause(ms) workaround the docs used to recommend.

  • waitUntil(() => condition, {timeoutMs?, intervalMs?, label?}) polls a condition until it is truthy and returns that value — Playwright’s expect.poll. The body is an expression or a block that returns its value; calls inside it (apiCall, evaluate, textContent) run on every try. timeoutMs defaults to the run’s wait timeout, intervalMs to 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 with Expected property key (word or string), and so did any key such as if, for, return or step. Quoting the key ({"continue": true}) is no longer needed. Dot access by a reserved word (a.continue) still does not parse; use a["continue"].

  • timeoutMs is now accepted wherever timeout is: in actions (click, fill, press, hover, selectOption, dragAndDrop and 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 both timeout and timeoutMs in 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(). clockFreeze stops time on the page: Date reads the given instant, and setTimeout, setInterval and requestAnimationFrame stand still. A “typing…” indicator or a countdown then gives the same screenshot on every run. clockTick moves frozen time forward and fires the timers that fall due. clockRun lets time run again. clockFreeze works at any point, including after goto, 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_context and explore_start take clock: true. explore_step gains clock_freeze {time}, clock_tick {ms} and clock_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 timezone and locale ("Europe/Madrid", "es-ES") for every page. UNOTEST_TIMEZONE and UNOTEST_LOCALE still 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.
  • A second browser context in one test: two clients of the same app.

    • New DSL functions newContext() and setContext(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 is 0). 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 next newContext() 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 a ctx N chip. A failure bundles the active context’s page, console and network, with contextIndex in failure.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_step gains newContext and setContext {index}, recorded as the same DSL calls. The new_context tool 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 newContext or setContext: 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-function points at the definition.

  • Strings, object literals and indexes in scenarios.

    • A string value answers s.length, s.trim(), s.includes(search) and s.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 its arg-kind error.
    • 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)).
  • 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 gets teardown:started / teardown:finished / teardown:skipped events and a teardown flag 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_runtime answers status: "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, no return / break out of the block, no nested teardown) and teardown-name-conflict.

    Breaking for scenarios that define their own teardown. A function, parameter or variable named teardown is now a lint error (teardown-name-conflict), and a statement-level call teardown(x) no longer parses as a call. Rename it, e.g. to undo_setup.

  • assertScreenshot — the page, or an element, against a PNG baseline in the repo.

    • assertScreenshot(name, options?) / assertScreenshot(locator, name, options?) compares a snapshot with unotest/e2e/<scenario>.screenshots/<name>.png — a Figma export or a snapshot you accepted. A pixel differs when any channel differs by more than threshold (default 32); the assert fails when the share of differing pixels is more than maxDiffRatio (default 0) — exactly the limit passes. mask: [locator…] and ignore: [{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: true compares 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, actual and diff pictures (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, and run_test returns them in next.visual. box run --download / box_run_download unpack 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-assert event per assert; runtime.json carries the last failed one as visual.
    • 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: pngjs 7.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.deviceScaleFactor in unotest.config. { width: 390, height: 844, deviceScaleFactor: 2 } makes every screenshot 780×1688, the scale design mockups are usually drawn at. Every viewport field is now optional: a missing one comes from device or from the 1280×720 default. A config without viewport opens at 1280×720, as before.
    • device in the config: a Playwright device preset by its exact name ("iPhone 14", "Pixel 7"), applied whole — size, scale, touch, mobile mode, user agent. viewport next 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, set viewport explicitly.
    • MCP new_context takes viewport and device, and opens the context at that window. explore_start takes them too when no browser is open yet. With a browser already open it refuses and points to new_context. Before this, new_context accepted 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. deviceScaleFactor is 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:skipped from 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 says teardownTimeoutMs (…ms) exhausted — the rollback did not finish, as before, not the run’s budget.
  • readBounded(handle, size, max) reads an open file into a buffer of its measured size plus one byte: a file over max is 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.

  • resume and step now 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), and resume / step wait 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 now ScreenCaptureError, 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 teardownTimeoutMs plus 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_runtime now 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 its unotest-web e2e process 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_runtime on a runtime id with no run behind it at all now answers no-runner, marked as an error, as take_wheel and release_wheel already do, instead of ok. A finished run still answers ok. 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 decidedAt to an approved, declined or failed request. The cloud_approval_wait MCP tool returns it, and the CLI prints it — “approved in the cabinet at 2026-09-27 09:14 UTC” — in approvals <id> and while box create, box destroy and box picker on wait; with --json it is an approval field 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 runs and 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, bundle and env commands 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 a UNOTEST_CLOUD_URL pointed elsewhere, could clear your screen or retitle the window. They are now shown escaped (\x1b), and line breaks stay line breaks.

  • explore_run_flow now 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, at with 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, and artifacts with a capture of the page as the flow left it.

    What changes for you: a failed explore_run_flow answers JSON instead of plain text, with the old text under error. Helpers now load the way run_test loads them, so a function defined in two helper files is refused, naming both files, in explore_run_flow as it already was in run_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’s grounder and a failed intent step carry {state: "no-grounder", next, orElse?, why}: next is the Picker command (box picker on for a project with a box, login && picker on without one), orElse is ollama serve in local mode, and why says 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. With UNOTEST_SNAPSHOT=0, explore_start now 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; the error text is unchanged.

  • A run_test that waits in the queue now says who holds the machine. The queued reply 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 carries heldBy: 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, the abort_runtime call that stops it. A blocking run_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 answers queued with heldBy, the same as wait: false; the run stays in the queue and starts on its own. abort_runtime takes a queued run back out.

    What changes for you: a blocking run_test behind a busy machine may now answer status: "queued" after about 30 seconds instead of waiting for the run to start; call inspect_runtime on its runtimeId to 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 an Error already did.

  • resume and step now 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), and resume / step wait 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 resume and step wait, and adds no new tool, flag or input. pauseSeq is 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.html saved 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 noembed and noframes, no longer reaches element names.

    What changes for you: if you read page.html for script data or hidden field values, set failureBundle.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 (and assert_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.jsonl and failure.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 read got "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.jsonl only on file-change events, and two appends a millisecond apart (take_wheel, then abort_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_runtime no 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 next run_test could 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 a stop-not-accepted error naming the state it is stuck in and what to do next.

    What changes for you: abort_runtime may now answer stop-not-accepted instead of ok; when it does, do what its next says.

  • box runs, box run, box queue, box screenshot and bundle push --run no longer need --env when the box serves a single environment: that one is used. With several, the refusal lists them and asks for --env <project>/<environment>; an explicit --env works as before. The MCP tools box_runs, box_run, box_run_download, box_queue and box_screenshot follow the same rule: environment may 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_test pauses and auto-attaches, explore_start now opens the agent’s own browser instead of recording into the run’s (which answered every step with not_your_turn), and after release_wheel the agent can still take screenshots, assert and wait on the run’s page.

  • Secret field values no longer leak into failures and replies. An assertValue failure on a field marked as a credential (type="password", or autocomplete of current-password, new-password or one-time-code) reports only the lengths of the two values, for example expected ‹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 example assert_text, check_locator, assert_value and the get_failure_* tools), in the text of an assertValue failure, and in the page.html and failure.json saved 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_wheel and release_wheel now answer ok only 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 answer wheel-not-accepted with what to do next. From the moment release_wheel is called, the agent’s page-changing steps answer not_your_turn until take_wheel succeeds again.

    What changes for you: an agent that waited for ok from take_wheel or release_wheel may now get wheel-not-accepted instead, and should do what its next says — the wheel may simply have gone back to the human, which inspect_runtime shows in its wheel field. A run with no runner left now answers no-runner instead of ok. Both are marked as errors, like abort_runtime’s stop-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 .env

    Environment files layer: unotest/.env first, then the unotest/.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 points API_BASE_CHAT at production, and whose dev overlay does not mention it, ran --env dev and sent its API calls to production. Nothing in the output said so: the reply reported the dev environment, 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 relative goto resolves against; the name passed as apiCall(..., {base: "NAME"}); and a variable used as the URL in goto(NAME). When the value still comes from the base file, the run is refused before the browser starts, and lint, run_test and explore_start refuse 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 under env: { shared: [...] }. A suite that never uses --env is 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_screenshot replied “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_a11y and the rest answer on the first call. Each record carries runState: paused while 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 — then finished or aborted once 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 destroy and box picker on print 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. --yes does 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 create with a short balance points to the dashboard instead of starting a top-up

    The 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 create used 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 login issues cannot) gets the price and the page, and no invented shortfall. --json prints { "needs": "top-up", "firstHourCents", "balanceCents", "missingCents", "currency", "url" }, with null for a figure the key may not read.

    The cloud_box_create MCP tool answers the same case as { state: "top-up-needed" } with the same figures, instead of an error.

    Migration: the --topup <eur> flag of box create is 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 nine cloud_* tools

    An 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. --json prints the same objects the cloud_boxes_list tool 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 core toolset: 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_access writes the fresh pair into the project the way box access does and answers with the names it wrote, never the values. They answer states an agent can act on instead of errors: not-logged-in with the command that fixes it, approval-pending with the request to wait on, insufficient-scope, and declined, expired or still-waiting from 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 --help and box --help list the new commands, say which commands wait for approval, and give one table of exit codes. box <command> --help now prints that command’s own flags wherever --help sits on the line, and approvals refuses an option it does not know with exit 2.

    Compatibility: every call to the cloud now carries an X-Unotest-Client header. 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 — with 426 client-too-old and a message to update. Update @unotest/web before 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, printed 1 passed and exited 0, so the one setting you changed was the one setting that did not apply. Every section of unotest/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 release

    The config unotest-web init writes no longer offers a viewer section: the viewer’s port has always come from UNOTEST_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 new explore_step_result({ taskId }) — running, done with the reply the step would have given, or failed. A batch of steps goes to the background as one task, for one id. The durationMs of 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_stop and close_context answer busy and name the task to wait for, and explore_state lists 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 said ok either way.

    take_wheel now also attaches this session to the run’s browser, so the next screenshot, snapshot or step reads the page being debugged. Every explore_step reply carries context — {kind: "run", runtimeId} or {kind: "own"} — so the source of a frame is stated rather than guessed. A session has exactly one browser: take_wheel and attach_debug_session point it at a run, explore_start and new_context at your own, and explorationId chooses recording and where a file is filed, never which browser a step runs on. release_wheel hands the token back and deliberately leaves the browser on the run.

    Behaviour change for anything parsing the reply: take_wheel no longer always answers ok. When it cannot join the browser it answers recording_open (a live recording would be dropped — nothing is changed, not even the wheel), no_browser (the run has not published one yet) or no_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 503 with Retry-After and unavailable, 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 create and box picker on quote 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 under read, which every key from login carries — 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. login and whoami print 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: whoami says 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-balance with 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-balance carries topupUrl, the dashboard page where a person adds credit. A key that may read the account’s money also gets firstHourCents, balanceCents, missingCents and currency; any other key gets no figures.

    box create prints 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 channel takes the same words everywhere

    Any bad value in unotest/unotest.config.mjs used 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 string

    And channel: "bundled" is accepted. UNOTEST_BROWSER_CHANNEL has always taken that word — the .env template says the variable merely overrides channel — while the config refused it. Both inputs now share one vocabulary, and bundled means 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_step and explore_steps treated explorationId: "" 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 at explore_stop, with an empty recording and the work already done. The same held for explore_record, and for runtimeId: "" 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

    initialize answered serverInfo with a hard-coded 0.1.0 whatever version of the package was running, while --version printed 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 status stop 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 status now 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>/update with the version it was offered. box status prints 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 launch block in unotest.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_DIR overrides 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, launch on 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 new channel: "chromium" (Chromium’s new headless mode, which loads them), or UNOTEST_BROWSER_CHANNEL=chromium; locally, run headed. Without launch nothing changes.

  • box envs and box run show 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@1a2b3c4
    installed 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_step with exploration_id (instead of explorationId) used to run the step ad hoc, answer success and record nothing — the empty session only showed up at explore_stop. Every explore_* tool, generate_dsl_from_exploration and save_exploration_as_test now refuse a field they do not declare, including inside an explore_steps step, 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 says recorded: 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 status says until when the balance keeps a box, not when its paid hour ends

    box status used to print paid until — the end of the hour already bought. With credit left that read as “switched off within the hour”. It now prints funded 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 fundedUntil to each billed box and to GET /balance (absent when nothing is billed); paidUntil stays, 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 status no longer prints a paid until line. A script that read it should take fundedUntil from box status --json for the deadline, or paidUntil there 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 status show.

    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_modules untouched 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 run and 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 the box_* 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 --run is 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/envs answers 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, the box_envs MCP tool) now also names environments that have no suite yet, with the note no suite yet. Reading one of them answers unavailable until 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 files

    The values commands now resolve the box address and the project token exactly the way bundle push does — a flag, then UNOTEST_BOX_URL / UNOTEST_BOX_TOKEN, then unotest/.env and unotest/.secrets, where box create and box access wrote them. A project set up with either of those commands no longer answers “no box address” to env push while bundle push works, 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-run now 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 --json carries the same under values. 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 create pushes 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 envs on 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, 200 with 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 ci is slow, and stops retrying in a loop

    A 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 ci was 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 init produces 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 envs listed nothing, and box 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 of init, 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 examples

    The command is no longer written out on each surface that shows it. The box’s screen, the last line of box create, and the collection init writes all come from one place, so the next flag to change changes in one edit rather than in three. examples is the collection init creates, 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/.secrets is 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/.env is configuration rather than secrets and keeps the width your project gave it.

  • Exploration tools refuse an unknown {{NAME}} instead of typing it

    A {{NAME}} the session could not resolve was passed through as text, so explore_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_NAME is unchanged: a reference where it is registered, a literal otherwise. A variable declared with an empty value still substitutes as empty and is reported in variableWarnings; 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/.secrets owner-only

    The 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/.env is 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 login
    npx @unotest/web box access <box>
    npx @unotest/web bundle push --run --env <environment> --collection examples

    The 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 init has no schedule: without the flag the run is refused before it starts. examples is what init writes, 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 create sends 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 access without a name lists the boxes the account already has, instead of advising a second one

    Run in a directory that does not know a box — a fresh checkout, a second project — box access, box status, box destroy and box picker refused with three things to try, and the only one anybody can follow without thinking was box 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 prod has 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 full acme/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 queue and box screenshot — and box_runs, box_run, box_run_download, box_queue, box_screenshot — translate the name against the box’s own list before the request, exactly the list box envs prints. 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 environment
    called "test" (acme/test, beta/test) — name the one you mean in
    full.

    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 push that refuses leaves nothing behind in the project

    The 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.json yet 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 next git status showed 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-run still 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 --run checks the environment name before uploading, and names the environments itself

    Pushing 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 envs uses. 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 test

    Both spellings the box itself gives are accepted, acme/test and the bare test. 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 at box envs instead of an operator.

  • A fresh project can start a remote run: init writes a collection, and the refusal says what a box runs

    A 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.

    init now writes unotest/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 examples

    A 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 prod and 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 off says how long the withdrawal takes, as one number

    Switching the Picker off — a box’s with box picker off, a project’s with picker 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 stops
    accepting it within about 2 minutes, once it refreshes its
    revocation list

    The 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 --env now 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 every box subcommand 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 on
    https://larch.box.unotest.com — nothing was uploaded.
    acme/prod and beta/prod are both called "prod", and a run is
    ordered by the bare name inside the project your token belongs
    to — push with --env prod if that project is yours

    A 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 box

    An account can have the Picker on without renting a box. npx @unotest/web picker on fetches this project’s own access and writes the three grounder keys — mode and address into unotest/.env, the token into unotest/.secrets, with .gitignore guarded — so an agent grounds intent locators remotely from the next restart. picker off gives 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 off leaves 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 stays box 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 ENVIRONMENT

    In a project that has just been created, the command refuses with no box address before 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 larch
    npx @unotest/web bundle push --run --env ENVIRONMENT

    The box’s own name is filled in when its licence carries one. box access with 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 access replaces 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

    --env takes the environment’s own name, prod rather than project/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 acme gets acme/prod and acme/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 picker tells you to restart your editor

    The 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 on now says it in a line of its own, and box picker off says 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 created reported 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-init as 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 it

    box picker on writes the grounder’s mode and address to unotest/.env and its token to unotest/.secrets — and the values in .secrets are deliberately kept out of the environment, so subprocesses and shell() do not inherit them. The MCP server, however, looked for the token in the environment alone. The command reported success, the ground_element tool 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), then unotest/.secrets, then unotest/.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 with box create the two happen to match; on a box created in the cabinet they do not, and every bundle push was 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 access and box picker on send 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 on box picker on) says which one the pair is for. Tokens minted before this release keep the old scope until you run box access again — 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 status lists 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 .env was 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 .env is 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.
  • box status answers for a box that never came up

    A 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 status showed 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 null as 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 --json staying 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 --help now prints the usage and exits instead of starting a viewer and scaffolding a unotest/ project into the current directory. Asking any subcommand for help is answered before anything is read from or written to disk.

  • lint and a pushed bundle now agree on what “declared” means

    A bundle has always read unotest/.env.example and .secrets.example as the suite’s declared externals; the linter read only unotest/.env and .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 --help now lists box picker on|off alongside 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 inside explore_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.code names it, and gains revocation-list-unavailable at 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. retryAfterSeconds joins the shape for it, matching the Retry-After header 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 have

    The 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. on mints the box’s tokens the way box access does and writes the Picker’s three keys — UNOTEST_GROUNDER_MODE=remote and UNOTEST_GROUNDER_REMOTE_URL into unotest/.env, UNOTEST_GROUNDER_REMOTE_TOKEN into unotest/.secrets. off clears 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 create asks about the Picker on a terminal when --picker was not given, quoting the hourly surcharge from the cloud’s own price list rather than a number kept in the CLI. --yes, --json and a run with no terminal answer no without asking.
    • box status says whether the Picker is on.
    • run_test offers it once. A project that has a box but grounds intents nowhere remote gets pickerSuggestion ({state: "no-picker", next, why}) after the first run that passes — exactly once per MCP server session, on the same terms as boxSuggestion, 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, or box picker off to 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.

    BoxReadErrorCode gains revocation-list-unavailable for it, and a refusal body may carry retryAfterSeconds (the box sends Retry-After with the same number). The push and run answers gain the same code, plus ambiguous-project for 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 status says where the Picker grounds

    A box whose Picker is on now prints one more line: Grounding: Our GPU or Grounding: 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, not grounderMode. UNOTEST_GROUNDER_MODE already means something else and belongs to the reader — whether THEIR machine grounds locally, remotely or not at all — and in box status the 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 unauthorized it 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 expired for 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 answers expired rather 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-unavailable and ambiguous-project.

  • Fake cloud: two access pairs that arrive without a client_name no 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|off and box status all 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, so GET /rates sends both: picker is what will be added to the bill (zero while it is included) and the new pickerList is what it lists at, beside pickerIncluded.

    A cloud that does not send the two new fields is quoted the old way, from picker alone — nothing to strike through, and no invented number.

  • box picker off now takes the token away, not just the surcharge

    Until now off stopped 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. off asks 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 init to 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:

    • login runs 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_HOME honoured) and is never printed, not even with --json. UNOTEST_CLOUD_TOKEN in the environment wins over the file, which is how a CI job signs in. logout revokes the key on the server when the cloud can be reached and removes it locally always; whoami says 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/.env and every unotest/.env.<name>, their APP_BASE_URL or the config’s baseUrl as 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, writes UNOTEST_BOX_URL to unotest/.env and UNOTEST_BOX_TOKEN + UNOTEST_BOX_READ_TOKEN to unotest/.secrets (through the same structure-preserving writer the viewer’s Variables panel uses, with .gitignore guarded the way init guards 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. --picker rents the box with the Picker; UNOTEST_GROUNDER_MODE=remote and UNOTEST_GROUNDER_REMOTE_URL then go to unotest/.env and UNOTEST_GROUNDER_REMOTE_TOKEN to unotest/.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 of create is the next step: the complete bundle 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 status and box destroy (asks on a terminal, --yes elsewhere) are thin wrappers. The name defaults to the box UNOTEST_BOX_URL points at (acme.box.unotest.com → acme).

    Cloud-signed box tokens live thirty days. When a box answers that one has expired, bundle push and the box … read commands re-mint the pair through the cloud and retry once, provided a cloud login is at hand; without one they say to run login && 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 default create used — so a lab box re-mints like any other. A --box naming 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 push now also reads the push token from unotest/.secrets, where box create puts it (then unotest/.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_run and the other box tools answer {state: "no-box", next, why} instead of an error, and run_test carries the same boxSuggestion exactly 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, and unotest/.secrets is read on every call: a pair written by box create while 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 /access call 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.

    init ends 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 at login and box access instead. UNOTEST_CLOUD_URL points 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, and UNOTEST_BOX_URL, UNOTEST_GROUNDER_MODE, UNOTEST_GROUNDER_REMOTE_URL listed among the runner-config keys so they file under the Runner section of unotest/.env.

    @unotest/grounder-client: GrounderHttpClientOptions.token may be a function yielding the current bearer; GrounderRemoteError carries the grounder’s retryAfterSeconds, and the error codes gain revocation-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. expired still re-mints through the cloud and retries once, revocation-list-unavailable still means the same token, later. Everything a retry cannot fix — revoked, bad signature, wrong door — arrives as one unauthorized, 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} — why is what the box said, next is 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_NAMES and unotest/.secrets masked what the runner said out loud — logs, the debug log, the recorded session — but not the capture ground_element and intent locators send to the grounder, and in the hosted mode on to a model provider. Only the field TYPE was filtered there: a password or hidden input 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: and javascript: keep their scheme alone: there the document, or the code of a call with its arguments, sits where the path would be. mailto: and tel: 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, ref and testId are structure, not content, and a secret whose value happens to be button no 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 admin as 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.

  • 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_ROOT now 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.

  • init no longer rewrites a config the project had already edited, and --help no longer runs the command it is asking about.

    Two defects, one outcome. init wrote the channel into unotest/unotest.config.mjs unconditionally 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 run init before, that file is the project’s own: it was replaced by the template with no --force, no warning, and an already present line 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 present and changes nothing.
    • --force still 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 init keeps 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>.bak too. “Nobody edits these” is an assumption about a file in your repository, and it costs nothing to be wrong about it safely.
    • --help / -h works 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. init reads its options against the list of what it knows, so the next flag added cannot fall through the same hole.
  • init writes files that pass the project’s own prettier --check.

    The block init maintains in CLAUDE.md now 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 runs format:check no longer goes red on files it did not write itself. A file init merges into is re-emitted by the same rules, including the entries the user had there.

    AGENTS.md too: 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 447213 masks while 4472130 and 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/sources and cannot write next to its own files — a prepare script that seeded a database beside the fixtures, or a helper that kept a scratch file under unotest/.tmp, now fails with EROFS / ENOENT before 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 under UNOTEST_ARTIFACTS_ROOT; os.tmpdir() in Node and $TMPDIR in a shell both land in the former. Migration for a suite that wrote beside its sources: pick the scratch directory by whether UNOTEST_ARTIFACTS_ROOT is set — unotest/.tmp locally, /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 from UNOTEST_RUN_TRIGGER / UNOTEST_RUN_ACTOR (a box sets them for its scheduled and CI runs, the viewer for a run ordered from its UI; a terminal sets nothing and reads as cli).

    A scheduled series is announced on a change of state: failed after afterFailures red ticks in a row, recovered, still-failing at a rule’s repeatEvery, not-run when the suite could not run at all (no bundle, a bundle that does not install) — daily by default. A run somebody ordered is announced on its own, failed or passed, every time. A withdrawn or aborted run says nothing; a run whose journal stops and whose heartbeat goes cold is interrupted.

    Channels, rules, mutes and quiet hours live on the box and are managed from the guard’s /_guard/notifications page (or boxd notify …): a channel’s secret is set once and never shown again, a rule picks environments, triggers, events and channels, a series or the whole project can be muted until a time, and quiet hours hold reminders and passed back until the morning. A generic webhook receives the event as JSON (version: 1, the box named, the trigger) with X-Unotest-Event and, when the channel has a secret, X-Unotest-Signature: sha256=<hex> — HMAC-SHA256 over the raw body, the scheme GitHub uses — plus any headers the channel declares.

    @unotest/protocol: RunManifest.trigger and the trigger helpers (runTriggerFromEnv, runTriggerEnv, manifestTrigger); the session-door paths, views and parsers of the notifications API (boxNotifyProjectPath, boxNotifyChannelPath, boxNotifyRulePath, boxNotifySeriesPath, boxNotifyMutePath, boxNotifyQuietHoursPath, boxNotifyPreviewPath, BoxNotifyProjectView, …). The first draft of this feature (channels declared in the box config, secrets by field) never shipped; this replaces it whole.

  • lint and e2e no longer fail on a project tree they cannot write to.

    Editor typings (unotest/jsconfig.json, .unotest/types/env.d.ts) are a convenience for a human with an IDE open, and they were being written on every implicit lint / e2e. On a box that runs a suite in an isolated container the bundle is mounted read-only, so that write raised EROFS / ENOENT and every scenario ended as interrupted — a suite failing because a cache could not be refreshed.

    The write is now skipped when the tree refuses it (matched on the errno code, never on a message), both artifacts report skipped, the result carries the reason, and the run carries on; any other failure still throws. UNOTEST_EDITOR_TYPES=0 turns the whole thing off up front — a box sets it on every run it starts, because there is no editor in a container and the attempt is only noise in the log.

  • Judge: the bearer token on /judge is compared in constant time, and the docs say what a verdict sends and what it is not.

    UNOTEST_JUDGE_TOKEN was checked with a plain string comparison, which returns as soon as one character differs; both sides are now hashed and compared with timingSafeEqual. The judge guide and the package README gain a section on what leaves the machine — the one element’s rendered text and the rubric, nothing else; the secret redactor does not act on that body; CI defaults to fake — and on why a verdict, being a model’s reading of the page’s own text, is a check on wording and never a security gate.

  • Box notifications: a webhook channel’s header values are credentials.

    The notifications API and the guard’s page now see a webhook’s headers as names with { set: true } — the values (the receiver’s Authorization, most of the time) are never read back, like the signing secret. The edit form lists the names; typing a value after one replaces the set, names alone keep it. /api/box/notify is the guard’s own route: it is no longer reachable through the session proxy for any role — a readonly session could previously list every project’s channels with their headers.

  • Box notifications: the webhook signature now covers a timestamp.

    Every webhook delivery carries X-Unotest-Timestamp (Unix milliseconds, when it was sent), and X-Unotest-Signature is HMAC-SHA256 over <timestamp>.<raw body> rather than the body alone — GitHub’s scheme with a timestamp in front, so a delivery captured on the wire cannot be replayed to the receiver once its window (five minutes, documented) has passed. The GitHub webhook the box receives is unchanged: that is GitHub’s contract.

    The by of a CI-triggered run (push a1b2c3d (branch)) is cut at 200 characters — a branch name is the pusher’s input, and it must not balloon the run’s manifest and every notification the run produces.

  • Pin playwright to the exact release the box image ships.

    @unotest/web depended on playwright ^1.62.0, so a fresh install of a suite could pick a newer playwright than the browsers inside the box’s mcr.microsoft.com/playwright image, and every run on the box failed with “Browser ‘chromium’ is not installed” the day 1.63.0 shipped. The dependency is now the exact version of the image (1.62.1); the two are bumped together, and a guard in the monorepo’s verify keeps them equal.

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

    ExplorationService created .unotest/explorations/ in its constructor, and the composition root builds it for every entry point — including an e2e run. Where the project tree is read-only (a box mounts a pushed bundle that way) the run ended before a browser was asked for. Recording is an MCP activity, so the folder is now created with the first record written instead.

    With the editor-typings fix in the same release, an ordinary lint / e2e writes nothing under the sources: run artifacts go to UNOTEST_ARTIFACTS_ROOT (which the box makes writable), and everything else that writes into the project — recording, authoring, init, env set, bundle push — happens on a developer’s machine.

  • Run isolation, stage 1: a box no longer executes a test bundle inside its own daemon.

    box-runner (new, private): the run sidecar — the only service on a box holding the docker socket, and the only one that starts a container. Three authenticated routes on the box network (POST /runs, GET /runs/:id/stream NDJSON, DELETE /runs/:id), a shared secret compared in constant time, and an API that cannot be told anything about how a container is built: no path, image, network, user, mount or flag. A request names a project, an environment, a bundle, a scenario and the environment’s values; the bind sources are derived from the first three, resolved with realpath and re-checked against the projects root, and must already exist. The container is created over the Docker Engine API (pinned v1.43) straight over the socket — no docker binary in the image and no client dependency, and a container that is a JSON document has no place for a value to become a flag. A daemon that refuses or is not there comes back as 502 {reason} before the run is accepted, or as an error event on the stream after — never as a run that “exited with code null”. An environment variable the box does not forward is named in the sidecar’s log rather than dropped silently. A run gets the bundle tree read-only as its working directory, the environment’s unotest/.runs.<env> read-write, a noexec tmpfs /tmp, a read-only rootfs, all capabilities dropped, no-new-privileges, its own uid in boxd’s group, memory/pids/cpu limits clamped to both the operator’s ceilings and the host’s own size (docker refuses a container bigger than the machine instead of clamping it), a 512 MB /dev/shm (docker’s default 64 MB kills Chromium mid-page; the host’s IPC namespace is deliberately not borrowed), and the runs network only. The container engine is an interface, so every rule is asserted on the container that would have reached docker — including the cases where the answer is no container at all.

    boxd: running the SUITE is its own contract (IScenarioRunner), separate from running a command (ICommandRunner, still the daemon’s own npm ci and viewers). ContainerScenarioRunner talks to the sidecar, ProcessScenarioRunner keeps the old child-process shape for a box without docker; BOXD_RUNNER_KIND=container|process chooses, and half a container configuration refuses to start. Reading a bundle’s schedules (unotest-web schedules --json) executes the project’s config module, so it goes the same way. A run’s environment is built from named parts (run-environment.ts) instead of inheriting process.env, and its debug tree is pointed at the run’s own directory rather than the read-only sources. New metrics boxd_run_container_total{outcome} and boxd_run_container_start_seconds. An environment’s runs directory is created by the daemon with 2775, so the run’s uid may write into it and the daemon’s group may read it back.

    box-kit: secretsMatch (constant-time secret comparison, moved out of dist-service), splitLines, and envNumber / envPositiveNumber — the env-reading rule the box-side services share.

    npm ci of a bundle is a container of its own (kind: "install"): the bundle tree is its only mount and it is writable, there is no environment and no artifacts directory in reach, and a dependency’s install scripts RUN — a native module builds or fetches its binary as usual. That is what running them inside the daemon could never allow. The container’s last steps, only on success, check the tree against the box’s size cap, set the final modes on what the install created and write the install marker — each with an exit code of its own, so the daemon’s log names the step that failed — so a killed or oversized install leaves a tree the box will not mount; a failed install takes the tree with it. The daemon no longer passes --ignore-scripts and no longer walks the tree afterwards.

    The daemon runs with umask 002 and clears node_modules before each install, so a tree it created is one the install container (another uid in its group) can actually write.

    An install that fails KEEPS the tree, unmarked: nothing mounts it and the next attempt reinstalls in place (npm ci wipes node_modules itself). And a bundle directory holding a manifest with neither an archive nor a tree behind it no longer counts as “this box has it” — a push of the same content brings it back instead of being answered already had this exact bundle.

    web: bundle push and the docs say that a dependency’s install scripts run on a box, in isolation — the earlier advice to vendor them is gone.

    A run ordered in the viewer now goes through the daemon’s queue instead of being spawned by the viewer.

    POST/GET/DELETE /api/box/envs/<project>/<env>/runs[/<runId>] (paths and wire types in @unotest/protocol) takes a manual ticket like every other producer. Two callers may use it, and neither is taken on its word: the guard, proving it is the guard with a shared secret from box-init (the actor header is read only next to it — a viewer container could otherwise claim to be any admin), and a viewer, with a per-environment token the daemon minted when it started that viewer plus a short-lived single-use ticket the guard signed over WHO clicked. The guard gates the route under admin and requires an Origin on a mutation; the daemon checks the token’s environment, the ticket’s environment and its jti against the path.

    @unotest/viewer gets BoxdRunner: on a box the Run button orders and the Stop button asks the daemon, and the progress still comes from the run’s journal on disk. A local viewer spawns as it always did — the composition root picks by whether a box handed it credentials.

  • Run isolation, stage 2b and 3: an environment’s viewer runs in a container of its own, and a box ships the sidecar in its compose stack.

    The viewer was the last thing on a box that executed a bundle’s code next to the daemon’s state: it runs the UI, the language service and the linter out of the bundle’s own node_modules. It is now a container the run sidecar starts and the daemon asks for — POST /viewers, one per environment, addressed by a name both services derive (unotest-viewer-<project>-<env>-<8 hex>, port 7788).

    Each viewer gets a NETWORK of its own with exactly two other containers attached: the guard, which proxies people at it, and the daemon, which it orders runs from. Not the sidecar’s network, not the runs network, and above all not another viewer’s — a viewer has no authentication of its own, and two on one network would be two containers of untrusted code with a route to each other. That network is INTERNAL: a viewer’s server makes no outbound call, so it gets none, while the network a run joins stays open because a test drives an application. The name of a viewer and its network is a function of the project/environment PAIR rather than of the string they join to, so two environments whose names concatenate the same way cannot end up sharing one. Its mounts are the bundle tree read-only and the environment’s unotest/ read-write (the run history it renders and the queue it withdraws tickets from); the environment’s secrets and its current link sit in the directory above and are mounted by nobody. Its environment is assembled by the daemon — the target, the environment’s variables and secrets, its own credential — and the daemon’s own process.env no longer travels.

    Editing a suite’s files from the viewer on a box no longer works: the sources are read-only there. Such edits never survived the next bundle push anyway, which replaced the tree. Everything else in the UI is unchanged.

    Readiness is the docker daemon’s own healthcheck, probed inside the container and read through the sidecar — the daemon no longer waits on a lock file, and nothing dials a viewer from the process holding the docker socket. A viewer’s output is streamed into the daemon’s log and reattached when the stream drops. A container whose bundle and values still match is ADOPTED across a restart of either service, keeping the credential it was born with; one whose environment is gone is stopped, with its network and its token. The sidecar recognises its own containers by label, never by name alone, and enforces its own ceiling on how many viewers may exist (BOX_RUNNER_MAX_VIEWERS) — boxd knows how many environments there are right up until boxd is the thing that was compromised.

    A run somebody stopped now ENDS in the viewer instead of hanging. Stopping a run stops its container gracefully (SIGTERM, then SIGKILL after BOX_RUNNER_STOP_GRACE_SECONDS, default 10) so the suite writes the terminal event of its own journal; if it could not — the grace ran out, the machine died — the daemon appends it. The same grace applies to a run killed on its timeout, so a run’s budget is the timeout plus the grace.

    A viewer that dies is noticed in about a second and replaced: the log stream the daemon holds open ends with the container, and the 404 that answers its immediate reattach is the signal. The route comes out of the table at once — the guard then says the environment has no viewer instead of proxying at a dead address — and a fresh container is started, on the same backoff the daemon uses for everything else it cannot reach.

    A restart of the sidecar is no longer an outage. While it cannot be reached the daemon keeps the routes it published (the viewer containers are up, and the guard reaches them directly) and tries again with a short backoff instead of waiting for its next five-minute sweep. Editing a file in the viewer on a box now answers 403 with the reason — the sources are read-only there — rather than a blank 500.

    The viewer no longer collapses the daemon’s refusals into a blank 500: the box’s own status and reason reach the browser, so “that click has already been used” (409), “the box cannot verify the caller” (503) and “the box daemon is not answering” (503) are told apart by the person who clicked.

    Deployment: compose.yaml gains the runner service (the only holder of the docker socket, the release ships its bundled box-runner.mjs beside boxd.mjs), fixed container names for the guard and the daemon, a box_secrets volume whose secrets box-init generates, and a one-off migration of the projects tree to the group layout an install container needs. Two values an operator fills in: BOX_PROJECTS_HOST_DIR (a bind source is resolved by the docker daemon on the host) and BOX_DOCKER_GID. Step by step in the box release’s cutover manual.

  • Viewer: a run that started before the page was reloaded is shown again.

    The live sidebar was rebuilt from WebSocket events only, and those speak from the moment the page connects — a collection already in progress never re-sent its started event, so after a reload it was missing from ACTIVE, then reappeared nameless with no scenario list once its next scenario began, while its children showed up one by one. On load, and on an environment or target switch, the viewer now asks the server which runs are live and replays each one’s journal into the same state the live stream feeds, so a reload mid-run shows the collection with its name, scenarios and progress.

  • Viewer: a session that may not edit variables does not see credential-shaped values, nor the project’s absolute path.

    Behind the guard, a readonly session could read GET /api/variables and get every box variable’s value — PASSWORD=… included when the operator had pushed it as a variable rather than a secret. The viewer now reads the capabilities the guard stamps on each request: when the caller cannot edit variables, a value whose name matches PASSWORD | PASSWD | SECRET | TOKEN | KEY | CREDENTIAL is sent empty and flagged secret, and GET /api/targets leaves out projectRoot. Display-masking by capability, not authorization — a viewer without a proxy in front is unchanged, and the guard still refuses every write.

  • Viewer: a collection run no longer shows up nameless and without scenarios.

    The journal tailer moved its cursor to the end of the file even when the last line was still being written, so the half already on disk was lost and the other half was later read as garbage. The longest line in a journal is collection-run:started with the scenario list, so that was the one it usually caught: the run appeared under its id instead of its collection name, with “collection has no scenarios” and a 2/0 done counter. The cursor now stops at the last complete line and the rest is read on the next change.

  • Viewer: a run started the moment the viewer came up is shown, not lost until the next change.

    Watching a directory becomes live a moment after the watcher reports it is ready, and a run directory created inside that gap was not merely late — it was invisible until something else changed in the same day, which in a quiet environment is the next run. Measured at 1-3% on macOS with a busy machine, and the window is exactly “open the viewer, start a run”. The viewer now proves the watch delivers before it declares itself up: it creates a directory of its own and waits to hear about it, so boot completes on evidence rather than on a promise. A tree it cannot write to, or a probe that never comes back, costs a warning in the log and boots as before.

    A directory whose name starts with a dot is never reported as a run — an editor’s leftovers, or the watcher’s own probe, can no longer appear in the run list as a phantom.

    Run queue: a waiter whose process paused is no longer mistaken for dead — the queue stamps its own files with its own clock.

    A run waiting for a slot proves it is alive by touching its ticket, and what it is judged against was the modification time the filesystem wrote: on a network share that is the server’s clock, and against a machine busy enough to keep a process off the CPU, a ticket written a moment ago could look like it belonged to a process that died. The queue now stamps every file it creates with the same clock it judges them by.

  • Updated dependencies [2abfd4d]

  • Updated dependencies [b3e1220]

  • Updated dependencies [b3e1220]

  • Updated dependencies [5d1bde3]

  • Updated dependencies [348aba7]

  • Updated dependencies [9158968]

  • Updated dependencies [b3e1220]

  • Updated dependencies [bd6b7ba]

  • Updated dependencies [8846a06]

    • @unotest/protocol@0.33.0
    • @unotest/viewer@0.33.0
    • @unotest/core@0.33.0
    • @unotest/dsl@0.33.0
    • @unotest/grounder-client@0.33.0

0.32.0 — 2026-09-05

  • npx @unotest/web box … reads a box’s results from a terminal: box envs lists the environments a read token may look at, box runs their history (--latest collapses it to one line per scenario with its failing streak), box run <id> explains one run — the failure, the soft steps, the judge’s verdicts — box queue shows who is waiting, and box screenshot saves a frame the run captured.

    box run --download fetches the run’s *.unotest.zip into .unotest/box/ and unpacks its failure bundle into .unotest/failures/, where list_failures, get_failure_* and agent_fix already look — so a run that failed on a box is debugged with the commands a local failure is. --no-screenshots asks the box itself to leave the step frames out (GET /api/runs/:id/export?screenshots=0), which is what makes the download smaller rather than only the disk.

    Reading needs a personal read token in UNOTEST_BOX_READ_TOKEN; the project’s UNOTEST_BOX_TOKEN still pushes bundles and values and cannot read runs.

    The viewer publishes its archive reader as @unotest/viewer/snapshot, so the three places that open a *.unotest.zip — its server, its browser bundle and now the CLI — share one implementation and one message for a file that is not an archive.

  • Six MCP tools close the loop that bundle push --run opens: box_run says what became of a run the agent ordered on a box, box_run_download unpacks its failure bundle into .unotest/failures/ so get_failure_trace, get_failure_console, get_failure_a11y, get_failure_screenshot, get_failure_network and agent_fix work on it unchanged, and box_envs, box_runs, box_queue and box_screenshot cover the cases where the agent has no run id, no environment name, a run that never started, or a page it would rather see than read about. Their descriptions carry the route, not just the arguments.

    Without a read token the tools refuse with a message saying where to mint one, so an agent meets an instruction rather than an unexplained failure.

  • Box: personal read tokens, so an agent can read a box’s runs without a browser.

    A user mints a token for themselves on the guard’s new Read tokens page (/_guard/tokens), sees the value once, and points a client at the box with UNOTEST_BOX_READ_TOKEN. The token is always readonly whatever its owner’s role, may only GET, and names the environment it means in X-Unotest-Environment: <project>/<environment> (GET /_guard/api/envs lists them). It is not a machine identity: it follows its owner — revoked, disabled or a lapsed seat all stop it, and the refusal says which. An administrator sees every token on the box and can revoke one that is not theirs; issuing, first use and revocation all land in the audit trail.

    UNOTEST_BOX_TOKEN is unchanged: the project token still pushes bundles and environment values, and read tokens cannot — asking with the wrong one now says which token the route wants instead of a bare “unauthenticated”.

    A read token is never passed on to the viewer behind the guard, so it cannot end up in the logs of a service that has no use for it. A box whose licence has lapsed, and an environment whose viewer is not up yet, answer a token in the read contract’s shape rather than with a page or a bare 503 — “wait” and “renew the licence” are not the same instruction as “your token is wrong”.

  • The agent integration guide’s tool catalog matches the server again. It listed fifteen per-action tools (goto, click, fill, press, …) that were removed when recording moved into explore_step, had no section at all for the ten exploration tools that replaced them, counted the debugger’s eleven tools as six, and put the total at 39 when it is 50. An agent reading it as a map — which is exactly what this file is for — would call tools that do not exist and conclude the server is broken. Every name now comes from the list the test suite verifies against the real registry, and the section counts add up to the total.

  • BoxReadClient reads a box’s runs — environments, run history, one run’s whole journal, a collection’s children, the queue, a run’s export zip and its individual artifacts — over the same HTTP routes a browser uses, authenticated with the personal read token in UNOTEST_BOX_READ_TOKEN. Every refusal arrives as a typed BoxReadError whose kind says what to do next, so throttling is never mistaken for a rejected token and a live run’s export says “wait” rather than looking like a broken box. Configuration mistakes surface when a read is attempted rather than at startup, so a stale token in a project’s .env cannot stop a local run that never touches a box.

    The viewer gains a @unotest/viewer/wire entry point exporting its HTTP contract types (the runs page, a run’s full snapshot, the queue payload and the snapshot manifest), so a client can name the shapes it parses instead of keeping a second copy of them.

  • The box address and the read token are resolved from unotest/.env and unotest/.secrets on every request, not once at startup, and the box CLI reads those files directly. unotest-web box … runs without loading the project config, so nothing had flattened unotest/.env into its environment — the command told people to put the address there and then refused with “no box address”. And an MCP server that resolved once meant a token written while it ran was ignored until the editor reconnected it, which is exactly the restart this path exists to avoid. One rule for both settings, from both entry points: a flag, then the environment, then the project’s files.

  • Protocol contract for reading a box’s run results with a personal read token: UNOTEST_BOX_READ_TOKEN (distinct from the project’s UNOTEST_BOX_TOKEN, which stays a write credential for bundles and environment values), the X-Unotest-Environment: <project>/<environment> header every bearer request names its own environment with, the /_guard/api/envs listing and its BoxReadEnvironment entry, and typed refusals (unauthorized, forbidden, unknown-environment, not-found, rate-limited, run-in-progress, unavailable, malformed) with parsers that reject anything that is not a box answering. Throttling gets a code of its own rather than an unauthorized carrying Retry-After: a rejected token means mint a new one, a throttled one means wait and resend the same one. The viewer’s ViewerEnvOption is now that same protocol type rather than a second copy of it. An environment whose viewer is still starting — the common state right after a bundle push — answers unavailable rather than looking like an unreachable box, so the advice is to wait rather than to check the address.

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

  • UNOTEST_BOX_READ_TOKEN is now read from unotest/.secrets as well as from the environment, with an exported value winning. This is what makes the box_* tools usable from an agent at all: an MCP server is started by the editor, so a token that exists only in a shell is a token the server never receives — the alternative was editing the editor’s own JSON config and reconnecting the server. unotest/.env is deliberately not consulted: it travels inside a pushed bundle, while .secrets does not. The CLI resolves the token the same way, so one token serves both.

  • box run and box_run now carry the {tag} of a failed soft step, in the JSON and in the printed line (Rubric [judge-red], the shape a local run already prints). A data-driven test runs one step.soft over many cases, so its failures all share a label and the tag is the only thing that says which case failed — without it a remote reader saw “Rubric failed” three times and had to tell them apart by line number. The tag was in the run’s journal all along; the summary dropped it.

  • Secrets injected by a box are masked. Masking works by value, and the registry of values was built from unotest/.secrets alone — a file that does not exist on a box, where the daemon passes the values as environment variables and names them in UNOTEST_BOX_SECRET_NAMES. The registry was therefore empty on every box run, and a password a scenario typed reached the run journal, the run’s stdout.log, the viewer’s System pane and anything an agent downloaded, in the clear. The runner now registers those values alongside the ones it reads from files, so masking no longer depends on where the run happens to be. Nothing changes on a developer’s machine, where the variable is not set.

  • Failure text no longer carries terminal colouring into files and replies.

    Playwright paints its call log whenever the environment claims a terminal is watching — FORCE_COLOR, which an MCP server inherits from whatever launched it — and that message was copied verbatim into steps.jsonl, runtime.json and the failure bundle. An agent reading the JSON got an escape sequence in the middle of the sentence it was trying to parse, and the viewer rendered it as a chewed-up word.

    The colouring is dropped where a thrown error becomes our data, so every reader of a failure gets the same clean text. Our own output is unaffected: it paints at print time, which is where colour belongs.

    The same on mobile: a failure’s text in the run journal and in the report no longer carries terminal escapes.

  • A failure somebody paused on stays a failure, with the evidence to show for it.

    A run driven through the debugger — run_test then resume, or the viewer’s Continue — reported completed after a failure it had paused on, wrote no failure bundle and no failure/ artifacts, and left list_failures with nothing to show. Continuing past a failure is how it gets inspected; it was never meant to retract it. The verdict is now decided where the run’s own events are seen, so the journal, runtime.json and the reply agree, and a debug run leaves the same evidence a plain unotest-web e2e run does.

    abort_runtime (and Stop, and SIGTERM) now also ends the run in runtime.json, not only in steps.jsonl: the control file used to keep saying paused-step about a run that was over, so anything reading it rather than the journal saw a pause that never ended.

  • README links the documentation site instead of manuals that are not shipped in the package (guides/manuals/*), and says what a run on a box cannot reach: localhost, port-forwards, host tools, a judge service on your machine — with the recipe for the judge on a box.

  • Secret values registered from unotest/.secrets are now masked in what the runner prints, not only in what it writes. The run journal and every failure artifact were redacted; the terminal line was not, and that line is also copied into the run’s stdout.log / stderr.log and streamed to the viewer’s System pane. Masking is applied once, where the logger is built, so child loggers and message arguments are covered too. The collection runner is covered by the same rule: its messages go through its logger rather than straight to the stream, and the logger it builds when a caller passes none reads the project’s secrets the way the composition root’s does.

  • A step.soft(...) failure no longer stops a debug run — or goes missing from it.

    Under a debugger (run_test, or e2e --debug), every soft failure raised the debug wheel: the run stopped on each one and an agent had to resume its way through them. Worse, it was then lost — a paused failure is consumed where it paused, so it never reached the step.soft envelope that records it, the envelope closed as if the block had passed, and a run with three soft failures could finish green with none of them listed.

    Pausing is now for a failure that ENDS the run. A failure under any enclosing soft step is recorded and stepped over exactly as it is on the command line, and runtime.json — which the run_test reply is built from — carries every soft failure of the run, not just the last stop.

    A hard failure still pauses: that is what the debugger is for.

    The same holds on mobile: a step.soft(...) failure no longer stops a run under the debugger, and every soft failure of a run is now part of its runtime state instead of being lost at the pause.

  • Updated dependencies [442fadf]

  • Updated dependencies [442fadf]

  • Updated dependencies [8aa0f30]

  • Updated dependencies [8aa0f30]

  • Updated dependencies [047ca68]

  • Updated dependencies [047ca68]

  • Updated dependencies [047ca68]

    • @unotest/viewer@0.32.0
    • @unotest/protocol@0.32.0
    • @unotest/dsl@0.32.0
    • @unotest/core@0.32.0
    • @unotest/grounder-client@0.32.0

0.31.0 — 2026-09-04

  • New DSL function note(label, value): attach a labelled value to the current step — the question a data-driven case asked, the answer it got — kept in the run journal and shown in the viewer under the step, during and after the run. Any value: a string as it is, anything else as JSON; cut at 4 KB (marked truncated), secrets masked. log(...) now also lands in the journal under the step that wrote it (the stdout line stays), and assertJudge verdicts carry the position of their statement, so the viewer can show a verdict under its step and the whole verdict on a failed step’s error card. Each of these events records the DSL call’s file / line / col and the nearest entry-file statement (entryLine / entryCol) — a note from inside a helper is attributed to the entry step that called the helper. Without a run journal (exploration, an ad-hoc runtime) note goes to the logger; nothing fails.

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

  • explore_run_flow replays parameterised flows: pass args (positional JSON values, "{{NAME}}" for a variable) and the helper’s parameters are bound for the live replay — flow_login(username, password) no longer fails with Variable "username" is not defined. explore_start lists each flow’s params; a wrong argument count is refused with the signature. The recording keeps variable NAMES, so the saved test reads flow_login(LOGIN, PASSWORD); — flow_call is now a regular action plugin rendering the call with its arguments.

  • New explore_step action screenshot — the evidence step of an exploration. It files a PNG under the session (.unotest/explorations/<explorationId>/screenshots/<NNN>-<name>.png, adhoc/ without a session), replies with the absolute path, width and height, and returns the image itself as an MCP image block (a JPEG copy when the PNG is over 1 MiB; path only when both are). name defaults to the section slug, fullPage captures the whole page, locator captures one element, outline: true adds the page outline to the reply, evidenceOnly: true keeps the file but records no step. A recorded screenshot becomes screenshot(name) in the saved test; an element capture degrades to a page capture with a comment and a non-blocking DEGRADED_STEP warning. explore_stop now lists the session’s files in artifacts. Driver: DriverPage.screenshotElement.

  • New linter rule lint:external-variable-undeclared (default error): a bare UPPER_SNAKE identifier that no layer file declares — unotest/.env, unotest/.secrets, or the .env.<name> / .secrets.<name> overlay of the environment lint --env <name> / UNOTEST_ENV selects — is reported with the files searched, instead of failing at that statement at run time after the browser is up. A name bound in the file (assigned, a parameter, a loop counter) is never flagged. The shell is not a declaration: a value CI exports still needs its name in unotest/.env (NAME= with no value declares it; the shell’s value wins at run time, as before). As an error it is not silenced by // lint-ok:; downgrade it in linter.rules if a project needs that.

    run_test (MCP) and e2e run the same check before the browser starts, against what the run will actually resolve — files and the ambient shell — so a suite that lives on a shell variable keeps running; a name that would have failed in the scenario anyway now fails up front (run_test answers lint_failed; e2e prints it and proceeds, like every pre-run diagnostic).

    The linter also descends into operator, array and property-access operands now, so a regex or {{mustache}} literal inside a + b is diagnosed the same as one on its own.

  • New linter rule lint:one-test-per-file: a second (third, …) top-level function test_* in one scenario file is reported at its declaration. A file is the unit a collection runs and the viewer shows — the Steps tree projects a run onto the first test_*, so the others executed with nowhere to be seen. Fold the cases into one test with tagged steps (step("…", {tag: id}, () => { … }), step.soft when a case must not stop the run) or move shared journeys into flow_* helpers under unotest/e2e/_helpers/; flow_* functions in a scenario file are not counted.

    Migration window: the rule is a warning in 0.31 — visible in lint, the editor and run_test’s warnings, silenced per line with // lint-ok: <reason>, never blocking. A later minor release flips the default to error (which the pre-run gate of run_test refuses); split files that hold several test_* before then.

  • New DSL function readJsonLine(path, filter) → object: read a JSONL file that already exists and return the first line matching the key filter — no polling. waitForJsonLine was the only structured input into a scenario, and for a fixture or a finished export its 20s timeout only masked a wrong path. readJsonLine takes the same filter (strict equality per key, dot paths for nesting, unparsable lines skipped) and fails at once: file not found: <path> when the file is missing, no line matches {…} in <path> with parse stats and the closest line when nothing matches. Data-driven tests read their cases with it and name evidence after them (screenshot(q.id)).

  • Data-driven steps: step("label", {tag: q.id}, () => { … }) names the case an iteration is on, and step.soft("label", [{tag}], () => { … }) records a failure inside instead of stopping the run — the rest of that body is skipped, the next case runs, and the test still ends failed with every soft failure listed (3 soft step(s) failed: Question [q17]: …, one line per case in the CLI output; run_test answers next.softFailures; inspect_runtime’s lastFailure carries stepTag and soft). The failure bundle shows the page at the moment of the first soft failure, with soft: true and stepTag in failure.json; later soft failures live in the journal. step.soft is allowed inside test_* only; nesting is free, and the outer step is a group, not an assertion — a soft failure inside does not change its outcome. The run journal gains step-block:started / step-block:finished envelopes per block (label, tag, soft, outcome, whole-block duration). Editor typings declare the new forms. 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’s in step "…" [tag] line) now names the innermost statement that raised it — the assert inside the tagged step — instead of the outermost loop that re-threw it.

  • A bundle no longer declares the runner’s own UNOTEST_* settings (UNOTEST_HEADED, UNOTEST_LOG_LEVEL, …) from the suite’s .env, so a box stops warning on every push that an environment “neither sets nor holds” names env push never sends. Both sides now share one rule (isRunnerSetting in @unotest/protocol): those variables configure the machine a run happens on, not the suite. A per-suite value such as the navigation timeout travels with the bundle through unotest.config.mjs (defaultNavigationTimeoutMs), not through .env.

  • unotest.config.* is merged with the defaults recursively. A partially written section — linter: { rules: { 'lint:deep-css': 'off' } }, failureBundle: { tier3: { video: true } }, viewport: { width: 1920 } — now keeps the rest of that section’s defaults instead of failing with “config validation failed” (nested sections require all of their fields at validation time, so a top-level replace made every partial section invalid). Arrays and scalars still replace as a whole: browsers: ['firefox'] means firefox only. The setup manual’s example config, which was exactly such a partial linter, loads as written; its timeouts block — never a config field — is corrected to defaultTimeoutMs / defaultNavigationTimeoutMs.

  • The viewer’s editor flags an undeclared external variable (lint:external-variable-undeclared), the way unotest-web lint and the pre-run lint already do: a bare UPPER_SNAKE identifier that no unotest/.env / .secrets file of the active environment declares is an error in the editor, naming the files searched. Protocol: DslValidateContext gains externalNames / externalSources; the viewer supplies them from the active target + environment’s layer files (the same files the variables panel shows) and @unotest/web’s language service turns them into the linter’s lookup.

  • Viewer editor: lint markers appear as soon as a file opens (or the page reloads), not only after the first keystroke — the editor validates the source it mounted with instead of waiting for a change event that a reopened tab never fires.

  • Docs: hover now lists its options (force, position, timeout) in the DSL reference, the editor signature and the generated typings — it used to read as if it took none, while position is exactly what a hover-revealed menu at one edge of a tall element needs. The agent integration guide gains “Watching a collection run”: the collection’s steps.jsonl journal (path, the four collection-run:* events, how to tail it) as the progress source for a run started in the background, where the CLI’s per-scenario lines only arrive when the process ends.

  • unotest-web lint now receives its arguments: the bin dispatcher forwarded only the command name, so lint --env <name> linted the base environment and an explicit file argument was ignored (every file was linted instead). UNOTEST_ENV=<name> worked all along, which is why the gap went unnoticed.

  • lint works on a project that has not migrated to the 0.28 layout yet. A unotest.config.* still at the project root used to stop lint with the same refusal e2e gives — right for a command that would RUN on defaults, wrong for one that only grades: nobody lints a suite during the very migration that touches every file. lint now builds its context on the default settings (rule severities, helpers dir), reads unotest/.env, .secrets and _helpers/ as usual, and reports the layout once as the new lint:legacy-layout warning (default warn, configurable like any rule) with the migration on one line. The exit code is unaffected by it. e2e, collection and bundle push refuse exactly as before.

  • The MCP server says when it is running a stale build. run_test’s pre-spawn lint, flow discovery (explore_start), explore_run_flow, generate_dsl_from_exploration and save_exploration_as_test parse and render DSL in the server’s own process, on the modules it loaded at start — after a rebuild a new DSL feature came back as a false parse error until the server was restarted, while the runner child was already on the new build. Those replies now carry serverStale: true and a serverStaleWarning (“restart the MCP server”) once the code on disk is newer than the server’s start; nothing changes while it is fresh.

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

  • goto with a relative path resolves against the runtime’s own base URL instead of the browser context’s. In a CLI run the two coincide; in an exploration recording against an env override, explore_run_flow replayed goto('/_guard/') on the MCP session’s shared context — created with the server’s base — and landed on the wrong host. The replay now follows the session’s baseUrl (and its variables, as before).

  • screenshot(name) accepts any string value, not only a literal. The runtime always slugified whatever it received, but the validator (and therefore lint and run_test) rejected screenshot(q.id) and screenshot(textJoin(['q-', id])) — a data-driven test had to name every capture the same. The name slot is now string-like, as in fill; a non-string argument is still an arg-kind error.

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

  • A scenario that finished in the same instant the viewer looked at it is no longer recorded as interrupted with a finish an hour before its start. The runner ends a run by appending run:finished and then backdating the heartbeat; the viewer read those two files in the opposite order, so a steps snapshot taken a few milliseconds early could meet the backdated heartbeat, and the provisional verdict stuck in the run index while the collection reported the scenario passed. The heartbeat is read first now. A viewer restart (which rebuilds the index from disk) already corrected such records; runs indexed live are right the first time.

  • Viewer, Home tab: the tile of a scenario that is running now breathes through colour and shadow only — the scale pulse is gone. It moved the tile’s box every frame, so an automation driver never saw the tile as stable and a click on it waited out its timeout. A tile that never ran no longer carries data-age="fresh": it has no age, so the attribute is absent.

  • Viewer, Home tab: clicking a scenario tile opens the scenario’s LAST RUN — the run whose verdict the tile’s colour is showing — instead of the test file. A tile that has never run still opens the test (there is no run to open), and so does every tile in schedule mode, where the schedule is edited from the test. The legend now reads “hover for history · click to open the last run”.

  • Viewer, Home tab: the tile field no longer refits itself in a loop. The header and legend take the grid’s width; when the legend wrapped at one width and not at the next, the field’s height changed, the fit followed, the width changed back — at 60 fps every tile’s box moved every frame, and an automation click on ANY tile (“element is not stable”) waited out its timeout unless forced. Header and legend now never wrap (they grow past the grid instead), so the fit converges and tiles stay put. A run opened from a tile is titled by its scenario, like one opened from the Runs tree, instead of by its run id.

  • Recording no longer bakes relative time or counters into a locator. An accessible name such as Run smoke — passed, 7m ago, just now, yesterday, 5 passed, 7 runs or a trailing badge (Inbox · 3, Errors: 12) now counts as volatile: the recorder emits the stable prefix ({name: "Run smoke — passed", exact: true}, then /^Run smoke — passed\b/) instead of the literal, and the draft carries the DYNAMIC_TEXT warning with that prefix. Bare numbers elsewhere stay stable (Page 2, Q4 2026, v2, 2FA).

  • Updated dependencies [b8e105b]

  • Updated dependencies [b8e105b]

  • Updated dependencies [b8e105b]

  • Updated dependencies [9ae6d59]

  • Updated dependencies [56784b3]

  • Updated dependencies [e048334]

  • Updated dependencies [dcf4c9e]

  • Updated dependencies [100ab9d]

  • Updated dependencies [100ab9d]

  • Updated dependencies [100ab9d]

  • Updated dependencies [100ab9d]

  • Updated dependencies [ad7c918]

  • Updated dependencies [ad7c918]

  • Updated dependencies [ad7c918]

  • Updated dependencies [9ae6d59]

  • Updated dependencies [13a2d51]

  • Updated dependencies [91fb562]

  • Updated dependencies [dcf4c9e]

    • @unotest/core@0.31.0
    • @unotest/protocol@0.31.0
    • @unotest/viewer@0.31.0
    • @unotest/dsl@0.31.0
    • @unotest/grounder-client@0.31.0

0.30.0 — 2026-09-03

  • On a hosted viewer (a box), the project and environment you are looking at are now named and switched in the viewer’s own tree header, in place of the checkout directory it used to show there (current, which told nobody anything). Previously the only way to another environment was logging out and using the picker page. The chevrons appear on hover, like the refresh button beside them, and only when something fronts the viewer and offers it a list; a plain local viewer keeps the folder name it always showed. Because each environment on a box is its own viewer process, picking one is a full page load into that viewer, and the menu shows which test bundle each environment is running. The status bar’s .env.<name> overlay switcher is now labelled “env overlay”, so the two senses of “environment” no longer share a name. @unotest/viewer/session carries the two URLs this needs (environmentsUrl, switchEnvUrl) and the ViewerEnvOption type, both optional — a proxy that does not offer them gets the old behaviour.

  • A run started in the first moments after the viewer boots is now discovered: the run watcher finishes its initial scan of the day directory before reporting itself started, instead of filing such a run as history.

  • bundle push and env push no longer send you to “the box’s admin page” for a project token — there is no such page; tokens are issued per project by the box’s operator, and the refusal hints and the missing-token message now say so.

  • The vocabulary’s description of evaluate now says how extra arguments reach the body — none → nothing, exactly one → the value itself, two or more → one array — matching the runtime, the linter’s hint and the authoring skill; it used to read as if they were positional (evaluate(js, ...args)), and so did the generated DSL typings.

  • A scenario that answers a native confirm() — a “Remove” button that asks first, say — no longer fails when run_test auto-attaches the MCP session to the run’s browser, or after attach_debug_session. The attaching client now applies the same dialog policy the run does (dialogPolicy, accept by default) to the page it watches; before, it listened for no dialogs at all, and Playwright dismisses every dialog on behalf of a client that has no listener — so the second pair of eyes was answering “no” before the run’s own accept could land. The same scenario passed with an exploration session open only because that session happened to keep the run’s browser to itself. Under manual the attached client leaves the dialog to whoever handles it instead of dismissing it.

  • On a box, an administrator can now see a secret’s value from the guard’s values page — the eye reveals it in the row, the copy button puts it on the clipboard without showing it — and every look is a secret.revealed entry in the box’s audit trail, naming who, which secret and whether it was shown or copied. The page is one table now (target, variables, secrets), edited in place, with a JSON view that changes the whole environment at once; a secret left as "••••" there is kept as it is. The token door env push / env set talk to is unchanged: secrets stay write-only on the wire.

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

  • evaluate(js, …args) has always handed the body its extra arguments in one of three shapes — none, the single value, or ONE array for two or more — while the authoring skill described them as positional. The skill now states the rule with both spellings (evaluate('function(n){…}', 21) / evaluate('([a, b]) => …', a, b)), and the lint:evaluate-discouraged warning adds the same hint when it sees two or more extra arguments; the runtime is unchanged. The skill also gains the getters it left out (count, getInputValue, textContains), the note that waitForText never sees a textarea’s value, the parser’s refusal of ! / indexOf / string methods, the shared-stand rule against “the first row” (mark your own data, filter by it, clean up), that native dialogs are accepted by the runner, and what to do when the grounder is unavailable.

  • While recording with explore_step / explore_steps, the bare NAME of a scenario variable is substituted only where the step types a value into the page — fill’s value, press’s key, select_option’s value, goto’s url. In a locator’s text or an assertion’s expected text a bare name is now the literal it looks like: assertText(el, "BOX_LAB_PASSWORD") expects those letters, where before the live step silently waited for the secret’s value and the saved test said something else. To reference a variable in a matcher, write {{NAME}}; the reply warns when a literal happens to equal a variable’s name. And a secret’s value typed into a value field is recorded as the secret’s name (the reply says which), so the value never lands in the test file.

  • run_test no longer reports spawn_failed about a runner that is still starting. Two runs launched together on a busy box took longer than the fixed 10-second wait to write their first runtime.json, and the second was declared dead while it was booting a browser. The wait now lasts as long as the runner child is alive (up to 60 seconds), ends early when the child joins the run queue, and — when the child really dies — says so with its exit code and the last lines of its stderr, which the runner now writes to spawn.stderr.log in its run directory.

    explore_start now reports whether the grounder is usable for the session: grounder: {available: true}, or {available: false, reason, hint} when grounding is off, the backend is unreachable, or a required model is not pulled ("embeddinggemma" is not available there — pull it: \ollama pull embeddinggemma“). An intent step in such a session fails with the same words, not with the backend’s raw error; the probe is one short request per session.

    Secret masking no longer rewrites the runner’s own identifiers in runtime.json: with LOGIN=admin, the scenario path guard-admin.js, the test function test_guard_admin and the run id kept their names in inspect_runtime replies instead of turning into guard-‹secret:LOGIN›.js. Masking by value is unchanged everywhere else; the run id, scenario path, test function and call-stack names are an explicit exception.

  • save_exploration_as_test (and generate_dsl_from_exploration) now write scoped locators — a getByRole / getByText / locator(css) step under a parent, such as the “Reveal” button inside one table row — as the chain the DSL already runs: locator('tr[data-row="A"]').getByRole('button', {name: 'Reveal', exact: true}). Before, any recorded step whose locator went below its root was skipped with NO_DSL_PRIMITIVE, and the save was refused, although the step had executed and the hand-written form worked. Every locator is now rendered in the same method-chain spelling — narrowing steps too (getByRole('row').filter({hasText: 'Alice'}).first() instead of first(filter(getByRole('row'), {hasText: 'Alice'}))); both spellings remain valid input, the runtime is unchanged.

  • Updated dependencies [23de03c]

  • Updated dependencies [9f2e244]

    • @unotest/viewer@0.30.0
    • @unotest/protocol@0.30.0
    • @unotest/core@0.30.0
    • @unotest/dsl@0.30.0
    • @unotest/grounder-client@0.30.0

0.29.0 — 2026-09-02

  • New env push, env set and env rm commands send an environment’s values to a box — the target, the variables and the secrets a suite runs with there — from the same files a local run reads (unotest/.env, .env.<env>, .secrets, .secrets.<env>), or from files named with --env-file / --secrets-file. .env* become the box environment’s variables, .secrets* its secrets and APP_BASE_URL its target; UNOTEST_* and empty values stay home and are listed as skipped. A push replaces the box’s layers and reports what it removed; env set reads one value from stdin, never from the command line. Values never print. The commands use the project token of bundle push, minted on the box with the new --values scope — a token without it is refused, so a CI token that pushes suites cannot rewrite an environment’s credentials. @unotest/protocol carries the wire (box-values.ts) and TARGET_ENV_VAR. The viewer’s messages about box-held variables now point at these commands instead of the box’s shell.

  • The Variables panel of a viewer on a box now lists what the box injects into every run of the environment — the target URL, the operator’s variables and the names of the environment’s secrets — as read-only rows with a box badge. It used to read unotest/.env*, which never travel in a bundle, and show an empty panel next to a full run history; a secret’s value is still never sent, only its name. The box passes the names through UNOTEST_BOX_VARIABLE_NAMES / UNOTEST_BOX_SECRET_NAMES (constants in @unotest/protocol); writes to such a key are refused with 409 and point at boxd.config.mjs / boxd.mjs secret set.

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

  • init without --force still leaves an existing unotest/.env.example alone, but the skip now names the keys the current template declares that the file does not — a project seeded on an older version never learnt about UNOTEST_STEP_SCREENSHOTS, because nothing compared its file with the template. The file is not touched. declaredEnvKeys() in @unotest/protocol reads active and commented-out keys alike. The DSL reference now documents UNOTEST_STEP_SCREENSHOTS next to screenshot(): readers of that page concluded the every-step mode did not exist.

  • shell() tells a missing working directory apart from a missing binary. Node reports both as ENOENT, and the old message sent people looking for a tool that was there all along while the real cause was a sandbox.shellCwd that exists on the laptop and not in CI or on a box. The directory is checked before the spawn and the new ShellCwdMissingError names it and where it came from.

  • Updated dependencies [ab296ff]

  • Updated dependencies [64108b8]

  • Updated dependencies [64108b8]

  • Updated dependencies [64108b8]

    • @unotest/protocol@0.29.0
    • @unotest/viewer@0.29.0
    • @unotest/core@0.29.0
    • @unotest/dsl@0.29.0
    • @unotest/grounder-client@0.29.0

0.28.0 — 2026-09-01

  • BREAKING. The suite is its own npm package now: unotest/package.json pins @unotest/web, unotest.config.* moved into unotest/, and a bundle carries that directory and nothing above it.

    A bundle used to carry the project’s root package.json and lockfile, and a box ran npm ci over the whole product tree to obtain one dependency. On a real customer that failed outright: their own peer conflict (@nestjs/core@10 against @nestjs/websockets@11) was hidden locally behind legacy-peer-deps=true in ~/.npmrc, which does not travel in a bundle and must not — registry tokens live there. The bundle arrived, the install died, the environment never got it. The same class of failure was waiting for anyone with a private registry, a native build or a prepare hook. Fixing it by editing the customer’s dependency stack is not an option: a test runner has no business in the dependency graph of the application it tests.

    What follows from it: we no longer dictate your package manager (the “your project locks with pnpm, and a box installs with npm ci” refusal is gone — npm is now only about unotest/), a box install shrinks from the product tree to the suite’s own dependencies, and upgrading @unotest/web can no longer collide with your application.

    The CLI finds the project root by walking UP for unotest/e2e, the way git finds a repository, so cd unotest && npx @unotest/web e2e <name> works as well as running from the root. Relative path arguments resolve against the directory you typed them in.

    Migrating from 0.27:

    npx @unotest/web init
    git mv unotest.config.mjs unotest/unotest.config.mjs
    npm install --prefix unotest

    and drop @unotest/web from your application’s package.json. The old layout is diagnosed rather than ignored: a config left at the project root fails the load with these instructions, because the alternative is a suite running silently on defaults — no baseUrl, no sandbox, no schedules.

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

    • unotest/jsconfig.json — an include written for the old layout points at ../node_modules, where the package no longer is, and editor autocomplete for the DSL dies silently. It is your file, so nothing rewrites it: ../node_modules/ becomes ./node_modules/.
    • a config that derives paths from its own location (dirname(fileURLToPath(import.meta.url))) now sits one level deeper, so those paths need a ...

    On the box side: bundles pushed before 0.28.0 are refused with an explicit message instead of being installed — push them again from 0.28.0 or newer. Without that, npm ci in a unotest/ that has no manifest would walk up the tree (that is how npm behaves), install the old root manifest, and leave the box unhealthy for twenty minutes without saying why.

  • @unotest/viewer@0.28.0

  • @unotest/core@0.28.0

  • @unotest/dsl@0.28.0

  • @unotest/grounder-client@0.28.0

  • @unotest/protocol@0.28.0

0.27.0 — 2026-08-31

  • viewer starts quietly: the Nest boot log (a line per module and route) no longer floods the terminal — you get the viewer URL and nothing else. Errors and warnings still print; UNOTEST_DEBUG=1 brings the full boot map back.

  • The LOCAL viewer now shows the same effective schedule set a box would run — what you see is what a push ships, 1:1.

    • Locally the schedule registry reads the merged set through schedules --json (the same path a box uses): config-declared entries are finally visible, marked read-only (“declared in unotest.config — edit it there”); yaml entries stay editable as before. A runner without a schedules command falls back to the yaml half.
    • bundle push records a fingerprint of the effective set in the bundle manifest. The box re-computes the set in its own environment and — when a config that reads process.env computes something different there — warns in its log, and the deployed viewer shows a drift line instead of letting “what I see is what I pushed” stand.
    • bundle push now refuses to pack a project whose schedules registry cannot be read — the same broken yaml used to upload fine and un-schedule the suite on the box.
    • npx @unotest/web schedules marks entries outside the previewable cron subset (not previewable — an executor may drop it), and the new --check flag turns that mark into a non-zero exit for a pre-push hook or a CI gate.
  • Schedules stop lying on a box, and a wrong cron is caught before the push.

    • npx @unotest/web schedules now previews every entry’s next run — next tomorrow 03:00 — on this machine’s clock (an executor ticks in its own timezone, and the output says so). Expressions outside the schedule-builder subset are shown verbatim, never guessed at.
    • On a box the viewer shows the EFFECTIVE schedule set — the config’s schedules merged with unotest/schedules.yaml, read through the project’s own CLI — as read-only, with a badge: schedules are declared in the repo, and the box runs whatever the deployed bundle declares. Editing there used to write into a disposable bundle copy nothing ever re-read; now the popup points at the repo and the API refuses the write (403).
  • Schedules from the viewer — for collections and single tests.

    Right-click a test or a collection in the viewer → Schedule…: a human schedule builder (frequency select — every 5/15 min, hourly, every 2/4/6/12 h, daily, weekly — plus toggle badges for minutes, hours and weekdays; multi-select, so 04 + 16 × 00 + 30 runs four times a day). Saved to unotest/schedules.yaml, versioned next to the tests; a box applies it after commit & sync. The Home screen gains a last run | schedule toggle: in schedule mode tiles are tinted by how often a test runs (grey = not scheduled), with an upcoming list and per-tile next-run times in your browser’s clock.

    Under the hood:

    • unotest/schedules.yaml is a new schedule registry; unotest-web schedules [--json] prints it merged with the config’s schedules (the file wins on a duplicate target), so boxes pick both up unchanged.
    • A schedule entry may now target a single test: scenario: <ref> instead of collection: <name>.
    • unotest-web e2e <name> --scheduled[=index] runs a test the way its entry says (env + prepare, exit 94 when prepare fails) — the per-test mirror of collection --scheduled.
    • Viewer server: GET/PUT /api/schedules (validated by the same parser the CLI reads the file with).
  • Updated dependencies [c843d21]

    • @unotest/protocol@0.27.0
    • @unotest/viewer@0.27.0
    • @unotest/core@0.27.0
    • @unotest/dsl@0.27.0
    • @unotest/grounder-client@0.27.0

0.26.1 — 2026-08-30

  • A push now says which environments could not run the suite

    The values a suite’s externals need live with the environment on a box — no bundle carries them, deliberately — so a suite pushed to a box that has none of them was accepted happily and went red an hour later, complaining about a missing login. The bundle now carries the NAMES its .env / .secrets (or their .example twins) declare — names only, never values — and the box answers the push with what it cannot supply, per environment, plus the command that sets each one.

  • One command cuts the whole release

    publish:ecosystem now also commits the docs pin and cuts the box release (the guard-v<version> tag) when something a box runs changed since the last one — --skip-box refuses, --box forces. Two artifacts from one commit used to be two commands, and the second one is the one people forget: a fleet on yesterday’s daemon while npm has today’s. What is left for a human is one git push --follow-tags.

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

    prepare used to live only in a schedules entry, so it ran on a scheduled tick and nowhere else. A suite that needs a seeded database was therefore red for every other way of starting it — a CI check of a deployment, the Run button in a box’s viewer — with a message about a missing table rather than about an unprepared environment. It belongs to the suite, so it now lives with the suite: the collection’s own prepare: runs however the collection was started, inside the same queue slot, and a failure still exits 94 rather than pretending the tests ran. A schedules entry may still name its own (it wins for that tick), and --no-prepare skips it for a run against an environment somebody else prepared.

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

    publish-examples copied unotest/.secrets into the public examples repository as .secrets.example. That was harmless while the file held only the playground’s public demo login, and stopped being harmless the moment a real credential was added to it. Secret KEYS are now spelled out in the generator (values only where they are genuinely public), and a guard refuses to publish when the two lists disagree — the same discipline .env has had.

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

    --env was consumed globally, where it selects a local .env.<name> overlay, and never reached bundle push — whose --env names an environment ON A BOX. The command every CI job and the box manual use (“—run needs —env”) could not work as documented. bundle now keeps its own flag; every other command is unchanged.

  • Fix: a viewer started for an environment listed no runs at all

    A box starts one viewer per environment and names it through UNOTEST_ENV; the viewer’s launcher ignored that variable and came up on the base runs directory, while runs were filed under .runs.<environment>. Effect on a box: an empty run history right after a suite finished.

  • Fix: screenshots stayed blank in the viewer when runs write to a separate artifact root

    A run whose artifacts do not live under the working directory — every run on a box, where the sources are a disposable copy of a test bundle — emitted screenshot events whose path was relativised against the working directory. That came out as ../../../…, which the viewer’s asset route refuses to serve, so every step preview rendered as a broken image. Artifact paths are now relative to the artifact root, which is still the project root on every local run.

    The failure bundle had the mirror-image problem: it was written to .unotest/failures/ under the working directory, so on a box it landed inside the bundle copy — outside the tree the viewer serves, and discarded on the next bundle push. It now hangs off the artifact root too, which leaves local runs byte-for-byte unchanged.

    The inspector’s Semantic DOM, Console, Network and Snapshot panes were dark for a third reason, and this one bit local runs as well: they composed the run’s directory in the browser and left out the date shard runs have been filed under since M-10, plus the environment suffix on a box. The viewer’s asset route now takes ?file=<name-relative-to-the-run-dir> and resolves the directory itself — runs root, target suffix, environment suffix and shard are all things only the server knows. ?path= stays for the whole paths the server ships on run events.

  • bundle push strips the project’s own install hooks (postinstall, prepare, …) from the packed package.json. A box installs the suite’s dependencies; the project’s hooks are not its business, and they point at files a bundle deliberately does not carry (build scripts, Makefiles), so npm ci on the box died with MODULE_NOT_FOUND. Dependency scripts — native module builds — are untouched, and the push reports what it removed on a note: line.

  • Updated dependencies [9424f6b]

    • @unotest/protocol@0.26.1
    • @unotest/viewer@0.26.1
    • @unotest/core@0.26.1
    • @unotest/dsl@0.26.1
    • @unotest/grounder-client@0.26.1

0.26.0 — 2026-08-30

  • The viewer can now be hosted: it shows which test bundle an environment is running, and lets you switch it.

    This is the other half of bundle push. Nothing changes for a local viewer — everything here appears only when something is hosting it.

    • The artifact root and the project root are now genuinely separate. The viewer already took UNOTEST_ARTIFACTS_ROOT for the run queue; run discovery, the run index, snapshots, log capture and the viewer’s own lock file now honour it too. Locally the two are the same directory and nothing moves. Where they differ — sources in a disposable copy of a bundle, history in the environment beside it — the history is no longer read out of (or written into) the sources.
    • Bundle badge in the status bar. main@1a2b3c4, or wip for a bundle packed from an uncommitted tree, with the author, the pinned @unotest/web and who switched the environment last. Clicking it lists the bundles pushed to this environment and switches to one — that runs as a maintenance ticket on the host, so it waits for the environment to be free instead of pulling the code out from under a running suite.
    • Runs remember their bundle. manifest.json and GET /api/runs carry bundleId (from UNOTEST_BUNDLE_ID, set by the host); the runs list marks the ones that ran on another bundle, so “was this red run even the code I am looking at?” has an answer.
    • The contract is a file, not a mode. @unotest/protocol gains WorkspaceFile — the host writes unotest/.workspace.json beside the artifacts and the viewer reads it, the same way the guard’s session header narrows the UI. No file, no bundle UI; the viewer never learns what a box is. Values of environment variables and secrets never appear in it — only their names.
    • Queue tickets can name a bundle. QueueTicket.source.bundleId is recorded when a run is queued, so a run executes the code it was ordered with even if the environment moved on while it waited.
  • npx @unotest/web bundle push — send your suite to a box, instead of giving a box access to your repository.

    Terminal window
    npx @unotest/web bundle push --box https://tests.example.com

    It packs unotest/, unotest.config, package.json and its lockfile into one archive and uploads it. That is the whole channel: a box holds no deploy key and no token for anybody’s code and never pulls anything, so nothing is on it that was not pushed to it. (There is no path-scoped git credential anywhere — a key that can fetch unotest/ can fetch the product source next to it, which is why this direction is the only one we offer.)

    • Uncommitted work travels. The manifest records dirty: true and the bundle shows up as wip, so pushing a fix you have not committed yet is a normal loop, not a release step.
    • What would fail on the box is refused here. A scenario that reads a file outside unotest/ (MODULE_NOT_FOUND on the box, hours later, on a run nobody is watching), a missing lockfile (npm ci will not run without one), no @unotest/web in the dependencies, a symlink. Each problem names the file, the line and the fix.
    • Secrets do not travel. unotest/.env* and .secrets* are excluded even if committed: environment values belong to the environment and are injected over the bundle when it runs.
    • In a git project, git decides what belongs. The file list comes from git ls-files --cached --others --exclude-standard, so whatever your .gitignore keeps out — generated databases, recordings, .runs — stays out. Content is read from the working tree, not the index.
    • The id is the content. bundleId is a hash of the packed files, not a commit sha (a dirty tree’s sha is not unique), so re-pushing an unchanged suite is an instant no-op and two people packing the same tree get the same bundle.
    • --dry-run packs and checks without uploading, --out <file> keeps the archive (plain tar.gz — tar -tzf shows what is inside), --json prints { bundleId, status } for a CI job that runs it afterwards.
    • UNOTEST_BOX_URL / UNOTEST_BOX_TOKEN supply the address and the token, which a CI job usually takes from its secret store. Tokens are issued per project on the box.
  • Your CI can now ask a box to run the suite it just pushed, and a box can report the result back to GitHub.

    Terminal window
    npx @unotest/web bundle push --run --env test --collection smoke

    --run sends the second half of the CI recipe: upload the bundle, then order a run of it. Two requests rather than one flag on the upload, because a bundle is content — pushing an identical tree twice is normal and answers exists — while “run it” is an event. The same split is what lets a deploy script run a bundle somebody pushed hours ago.

    • The box answers as soon as the runs are queued (202) with their run ids. A suite takes minutes to hours; an HTTP request held open for that long is a timeout in somebody’s proxy, not a result. The ids are real from the moment you get them, so a CI log can print a link into the viewer straight away.
    • --env <name> is required with --run. Which environments a box has is the operator’s decision, and guessing one would be guessing which system gets tested.
    • Without --collection, the box runs what that environment normally runs — the collections its schedules name. Naming them explicitly is the deploy-check case; leaving it out is the “same as every night” case.
    • --pr <number> coalesces. A newer push of the same pull request withdraws its older runs that are still waiting; a run that already started is never killed. Three pushes in five minutes cost one suite. --json reports how many were withdrawn, so “my run vanished” has an answer in the log of the run that replaced it.
    • Refusals are typed and say what to do: no such environment, an environment belonging to another project, a bundle the box no longer holds, nothing to run.

    On a hosted viewer, the queue panel now names the change each machine ticket belongs to (PR #412@1a2b3c4) instead of a row of identical “ci” entries, and the history gained a this bundle only filter for the moment you are judging the bundle in front of you rather than the last month of runs.

    Nothing changes for a local npx @unotest/web viewer: there are no bundles there, so neither the badge nor the filter appears.

  • Runs of one project now take turns instead of colliding.

    Until now nothing coordinated them: the viewer refused a second run with HTTP 409, and that was the whole defence — a npx @unotest/web e2e in a terminal, an agent’s run_test and a scheduled suite would all start on top of each other, driving the same browser and the same seeded database at the same time. The 409 protected the UI, not the machine.

    • A filesystem queue, in unotest/.queue<target>[.<env>]/ beside the runs root it belongs to. A run writes a ticket, waits until it is at the head, takes a slot, runs, and gives the slot back. There is no daemon: whoever holds the slot executes the run itself, so nothing new has to be installed, started or kept alive. Only open("wx"), rename, unlink and mtime — no flock, which lies on network and container filesystems. A crashed producer is reaped by whoever comes next.
    • Every producer is in it: the CLI (e2e, collection, author), the viewer’s Run button, and the MCP run_test (which spawns the CLI). A collection is ONE run — its workers still run in parallel inside it, because a child process carries its parent’s lease and skips the queue.
    • The viewer shows the queue: the Active panel lists what is waiting, including tickets a terminal or an agent wrote, with a button to take any of them back out. GET /api/queue, DELETE /api/queue/:ticket, and a queue:changed websocket message.
    • Config: queue.concurrency (default 1) and queue.enabled (default true) in unotest.config. Both the CLI and the viewer read the same file, so they cannot disagree about how many slots exist.
    • Escape hatches: UNOTEST_NO_QUEUE=1 runs without queueing; Ctrl-C while waiting gives up your place in line and runs nothing.
    • For boxes: UNOTEST_QUEUE_GLOBAL_DIR + UNOTEST_QUEUE_GLOBAL_SLOTS add a host-wide budget several environments share (a collection weighs its worker count), and UNOTEST_ARTIFACTS_ROOT puts .runs / .queue somewhere other than the directory the tests live in — so the history outlives a throwaway copy of the tests.

    Migration notes

    POST /api/run no longer answers 409. Ordering a run while another one is active used to fail with ActiveRunConflictError (HTTP 409); it now succeeds and the run waits. The response changed shape with it:

    { runId, kind, ref, pid, startedAt }
    { runId, kind, ref, status: "queued" | "running", ticket, acceptedAt }

    runId is still final and immediately usable — open the tab on it as before — but the run may not have started yet, and pid is not known at that point. POST /api/runs/:runId/abort covers both states: it withdraws a waiting ticket or SIGTERMs a running child.

    Anything that treated 409 as “busy, try later” should now simply order the run. Anything that relied on “only one run can exist” should read GET /api/queue. To keep the old immediate-start behaviour (without the protection), set queue.enabled: false.

  • Scheduled runs: declare what should run on its own, next to the tests it runs.

    unotest.config.mjs
    export default {
    schedules: [
    { collection: "smoke", cron: "0 * * * *" },
    { collection: "nightly", cron: "0 3 * * *", prepare: "node seed.mjs" },
    ],
    };
    • schedules — a new config leaf: collection + cron, plus an optional env (which environment the run happens in) and prepare (a command run before the suite). Versioned with the tests, so “what is supposed to run nightly” is in the repo instead of on a machine nobody can see.
    • npx @unotest/web schedules prints what is declared; --json is the machine shape a scheduler reads back. Each entry also carries the collection’s workers resolved from its yaml manifest, so an executor can weigh the run without parsing yaml itself.
    • npx @unotest/web collection <name> --scheduled runs a collection the way its schedule does: in the entry’s env (unless you set one) and with its prepare command first, inside the same run slot (preparing touches the target, which is exactly what the run queue serialises). A failed prepare exits 94, not 1 — “the environment was not prepared, nothing was tested” is a different event from “the suite is red”, and alerting has to tell them apart. A collection listed in schedules more than once needs --scheduled=<index> (zero-based) — guessing which entry’s prepare to run is refused.

    Nothing in this package ticks. schedules is data: no cron parser, no timers, no background runs. Cron is executed by whatever hosts your runs — your CI (on: schedule + unotest-web collection), or a box. A clone of a repo that declares schedules therefore never starts running suites by itself; unotest-web schedules says so out loud instead of implying a timer that does not exist.

  • Updated dependencies [3eede7c]

  • Updated dependencies [6d1c612]

  • Updated dependencies [dbfa36f]

  • Updated dependencies [c43aa00]

  • Updated dependencies [8ec4177]

    • @unotest/viewer@0.26.0
    • @unotest/protocol@0.26.0
    • @unotest/core@0.26.0
    • @unotest/dsl@0.26.0
    • @unotest/grounder-client@0.26.0

0.25.0 — 2026-08-29

  • Viewer: a pinned Home tab, a multi-row tab strip, and live tiles.

    The viewer ships with @unotest/web and opens via npx @unotest/web viewer, so the change lands here too. The project-health overview used to render only when no tab was open — it disappeared exactly while you were working. It is now a pinned first tab: always present, never closable, and where “Close All” leaves you. The tab strip wraps into up to three rows instead of scrolling sideways, tabs are pills (middle-click closes, ⋮ opens the right-click actions), and the strip is a proper tablist for screen readers. A scenario running right now breathes on the overview field: the tile fades under a warm ring, still when paused. Full notes in @unotest/viewer’s changelog.

  • Judge: provider edge cases, a startup preflight, and logs.

    Vertex GOOGLE_CLOUD_LOCATION=global could not work. The endpoint was built as https://<location>-aiplatform.googleapis.com, so global asked global-aiplatform.googleapis.com — not a Vertex host. Google’s edge answered 404 with an HTML page, which arrived as “model not found”. The global endpoint has no region prefix; it is now special-cased. global is the default in Google’s own docs, so this was the first thing a Vertex user hit. A model id containing a slash (publishers/anthropic/models/claude-haiku-4-5) is now passed through instead of being forced under publishers/google, and a pasted models/gemini-2.5-flash no longer doubles the prefix on the Gemini API.

    Backend replies are classified instead of lumped together.

    • 401/403 → config with the variable to fix (OPENAI_API_KEY, ADC, an expired UNOTEST_JUDGE_ACCESS_TOKEN), no longer indistinguishable from a model fault.
    • 429 and 5xx → retried transparently with a short backoff that honours Retry-After. A single rate limit used to kill a whole scenario. The retry policy is a decorator over JudgeProvider, not a loop inside the HTTP helper, so it covers every provider — claude spawns a process and therefore had no re-send at all. A claude usage limit is deliberately still reported rather than retried: the window is seconds, the limit is not.
    • A non-JSON error body says the endpoint is wrong rather than quoting an HTML page back as the backend’s opinion of the model. The body is kept for debug logging.
    • OpenAI finish_reason: length and Anthropic stop_reason: max_tokens are named as token-limit truncation instead of surfacing as “did not reply with the requested JSON verdict”.
    • The verdict parser accepts "PASS" and a JSON object embedded in prose — providers without a JSON mode do both.

    UNOTEST_JUDGE_VOTE=N no longer fails on one bad ballot. The N calls ran under Promise.all, so voting — the reliability feature — tripled the chance that one transient error killed the request. Errored ballots are now dropped as long as a majority of the N calls still voted; a tie among the survivors re-raises the failure rather than picking a side.

    The service checks its credentials before it says “listening”. Each provider exposes a free probe (an ADC token exchange, GET /models, claude --version) — no model call, no cost. A failing probe prints the reason and exits non-zero (UNOTEST_JUDGE_SKIP_PREFLIGHT=1 waives it), and npx @unotest/judge --check runs just the probe for start scripts. GET /health reports the same thing instead of an unconditional {"ok":true}: 503 + {"ok":false,"code":"config","error":"..."}, cached for a few seconds. The waiver covers the startup gate only — a service started under it still answers /health from a real probe, and --check still answers honestly, because the operator who reaches for the flag is precisely the one whose credentials are dead. A static UNOTEST_JUDGE_ACCESS_TOKEN is validated too, against Google’s free tokeninfo: those tokens live about an hour and refresh themselves never, so “valid at startup” is a different question from “valid now”.

    ADC that expires on a daily reauth policy is a normal morning state, not a one-off misconfiguration — it should not cost a five-minute run to discover.

    New: UNOTEST_JUDGE_LOG_LEVEL (falls back to the UNOTEST_LOG_LEVEL @unotest/web already reads). At the default info, one line per judged request — verdict, attempts, model, latency — with errors on stderr; a run of 21 assertions with VOTE=3 is 63 billed model calls that previously left no trace. At debug, per call: the effective prompt (rubric including any UNOTEST_JUDGE_PREAMBLE), each ballot with its reasoning, and the raw provider reply. The judged text is application content — debug writes it to stdout verbatim, which is why it is opt-in and never the default.

    An oversized request body now returns 413 with a reason; the socket used to be destroyed while the client was still writing, so the client saw socket hang up.

    @unotest/web: the judge:verdict run artifact records preamble beside rubric when one is set. The model saw the concatenation, but only the split says which half came from the scenario and which from the environment — without it, “a red step is explainable without a re-run” held only for whoever also knew the env file. A judge request that outlives UNOTEST_JUDGE_TIMEOUT_MS now says so, and names the service-side UNOTEST_JUDGE_CALL_TIMEOUT_MS it may be racing.

    The Claude CLI provider also stopped leaking a timer: the per-call budget is now our own timer, cleared on every exit path. child_process.spawn’s timeout option does not clear itself when the spawn fails, so a missing binary held the event loop for the whole budget — 15s for a --version probe, 120s for a verdict.

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

  • Updated dependencies [70f2e11]

  • Updated dependencies [adb9d72]

    • @unotest/protocol@0.25.0
    • @unotest/viewer@0.25.0
    • @unotest/core@0.25.0
    • @unotest/dsl@0.25.0
    • @unotest/grounder-client@0.25.0

0.24.0 — 2026-08-26

  • Judge: rendered text, one env namespace, preamble and majority vote.

    Breaking (judge env, no aliases). Every JUDGE_* variable of the @unotest/judge service is now UNOTEST_JUDGE_*, matching the client-side names @unotest/web already used. @unotest/judge shipped a day before this change with no known installs outside the monorepo, so the rename lands without a deprecation window — the window would never be cheaper. Migration:

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

    Two names were deliberately not a straight prefixing. The service’s per-call budget became UNOTEST_JUDGE_CALL_TIMEOUT_MS because UNOTEST_JUDGE_TIMEOUT_MS was already taken by the client’s whole-request budget — same family, different question. And the service’s bearer token merged into the client’s UNOTEST_JUDGE_TOKEN: it is one secret, and in a shared overlay the two ends have to carry the same value anyway.

    Behavioral fix: rendered text. assertJudge and the DSL query getInnerText(loc) now read innerText, not textContent. Both promised rendered text and delivered the raw source-order concatenation: line breaks gone, display:none subtrees and <script> contents folded in. A rubric about structure (“the answer is three bullets”) was unjudgeable, and getInnerText returned text no user can see. Scenarios that relied on hidden text reaching these two functions will now see it excluded.

    Vertex fixes. google-auth-library moved from optional peer to a regular dependency: as a peer it did not resolve under npx @unotest/judge at all, which broke the advertised keyless path for anyone who never installs the package locally. It is still loaded lazily, so non-vertex runs pay nothing. Expired Application Default Credentials now produce “run gcloud auth application-default login” instead of a raw Google OAuth blob, read off the structured response body rather than message text.

    New: UNOTEST_JUDGE_PREAMBLE (web side) — text prepended to every rubric in the project, for standing context (“the app under test is a support bot, Russian UI”) that would otherwise be copy-pasted into each rubric. It applies in both local and remote mode; a remote judge stays project-agnostic.

    New: UNOTEST_JUDGE_VOTE=N (service side, odd N, default 1 = off) — N independent calls decided by majority. It replaces UNOTEST_JUDGE_RETRIES rather than stacking with it: re-asking is a deliberate bias toward pass, a vote is deliberately symmetric, and running both would quietly restore the bias the vote was chosen to remove. Ballots run concurrently, so a vote costs N provider calls but roughly one call of wall-clock.

    Wire protocol. New error code config on the judge’s error responses: the service’s own setup is broken (bad env, expired credentials) and the message carries the fix. @unotest/web surfaces it as a ConfigError instead of burying it as a generic “judge unavailable” fault.

  • assertJudge local mode: the “needs @unotest/judge installed” ConfigError now appends the real import failure cause — a half-built dist failed the same way and read as a phantom install problem. Also: the viewer run-from-ui dogfood scenario accepts history’s run-length collapse row.

  • Updated dependencies

    • @unotest/protocol@0.24.0
    • @unotest/viewer@0.24.0
    • @unotest/core@0.24.0
    • @unotest/dsl@0.24.0
    • @unotest/grounder-client@0.24.0

0.23.0 — 2026-08-26

  • LLM-judge assertion for free-form text: assertJudge(locator, rubric).

    • New package @unotest/judge — the judge service (npx @unotest/judge): judges text against a natural-language rubric and returns a structured {verdict, reasoning, model, attempts}. Providers: vertex (Google Vertex AI via ADC, no API keys; temperature pinned to 0) and fake (deterministic, CI-safe). Non-determinism policy lives in the service: JUDGE_RETRIES re-asks on fail, first pass wins.
    • @unotest/web: new DSL assertion assertJudge(locator, rubric) — the element’s rendered text + the rubric go to the judge; a fail verdict fails the step with the judge’s reasoning. Wired via env so the config sits in the --env overlay: UNOTEST_JUDGE_MODE=off|local|remote, UNOTEST_JUDGE_URL, UNOTEST_JUDGE_TOKEN, UNOTEST_JUDGE_TIMEOUT_MS. Every verdict (pass and fail) is recorded into the run’s steps.jsonl. Also documented: shell() runs with cwd = the project root (sandbox.shellCwd to override) and returns {stdout, stderr, code}.
    • @unotest/protocol: judge wire types (JudgeRequest, JudgeVerdict, JUDGE_ROUTES) and the new judge:verdict run-artifact event.
  • Judge provider matrix: four new JUDGE_PROVIDER backends.

    • claude — spawns the local Claude Code CLI (claude -p, no shell interpretation): auth comes from the Claude Code session, so a subscription works with no API key. JUDGE_MODEL is passed as --model (aliases like sonnet work); JUDGE_CLAUDE_BIN overrides the binary; default JUDGE_TIMEOUT_MS is 120000 for this provider.
    • gemini — the Gemini API with GEMINI_API_KEY (same wire format as vertex, temperature pinned to 0; default model gemini-2.5-flash).
    • openai — OpenAI chat completions with OPENAI_API_KEY (response_format: json_object; default model gpt-5-mini).
    • anthropic — the Anthropic API (/v1/messages) with ANTHROPIC_API_KEY (default model claude-haiku-4-5).

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

    @unotest/web: docs only — provider matrix in the assertJudge reference and the .env.example judge block.

  • Updated dependencies

    • @unotest/protocol@0.23.0
    • @unotest/viewer@0.23.0
    • @unotest/core@0.23.0
    • @unotest/dsl@0.23.0
    • @unotest/grounder-client@0.23.0

0.22.0 — 2026-08-26

  • Updated dependencies [80619a9]
    • @unotest/viewer@0.22.0
    • @unotest/core@0.22.0
    • @unotest/dsl@0.22.0
    • @unotest/grounder-client@0.22.0
    • @unotest/protocol@0.22.0

0.21.0 — 2026-08-25

  • feat: environments are first-class in the viewer — a switcher, per-env run history

    Run artifacts are laid out per environment, as folders. Each environment’s history lives in its own root, mirroring the env-file scheme: unotest/.runs (base), unotest/.runs.<env> (e.g. .runs.staging for --env staging / UNOTEST_ENV=staging); the axes compose — .runs-mobile.staging. Indexes (_day.jsonl, _latest.json) are per-env automatically: switching environments is a change of root, not a filter and not a rebuild. Old runs stay in .runs (= base), no migration needed. runsDirFor / projectRunsRoot / projectRunDirFor gained an optional envName parameter; the run manifest carries env for self-description. Make sure your .gitignore uses the wildcard form — unotest/.runs*/ (init already writes it that way for new projects).

    The viewer knows about environments. A new switcher sits at the top of the Variables panel and in the status bar: base plus every environment discovered from unotest/.env.<name> / .secrets.<name> (.env.example and other templates do not count). The active environment is server-side (GET/POST /api/environments), and all tabs converge via the env:changed WS message:

    • the Variables panel shows layers WITH the active environment’s overlay (previously base only); values coming from .env.<name> carry a badge; an edit goes to the file where the key is defined; new variables go to base;
    • Overview and the Runs list show only the active environment — tiles are colored by the latest run in that environment;
    • a run started from the viewer gets the active environment’s UNOTEST_ENV (RunRequest.env is a per-request override);
    • a run opened from another environment still resolves by runId across all .runs* roots.

    MCP: run_test {env} already switched the child process’s environment; its artifacts are now correctly found by the server in .runs.<env> (inspect/step/attach/list_runtimes scan all roots).

    Four fixes uncovered while shaking this down:

    • The --env overlay actually reaches the run. Long-lived hosts (viewer, MCP server) flattened the base .env into their own process.env on loadConfig; children inherited it as ambient (ambient beats files) — the overlay’s APP_BASE_URL was silently clobbered by the base value. The viewer now rolls its env back after loading the config; MCP spawns children from a clean pre-flatten snapshot.
    • Pause/Abort from the viewer work again. Debug commands were written to the flat <root>/<runId>/commands.jsonl, while runs have lived in date shards since 0.19 — the write 404’d and abort was silently ignored. Order fixed too: SIGTERM to the own process first (tests AND collections), then the command file.
    • A collection no longer “dies” in the UI after 30 seconds. The parent run wrote no heartbeat — the monitor declared it interrupted, the row vanished from ACTIVE and the tail detached. The orchestrator now maintains the heartbeat (shared machinery in @unotest/core), and the viewer additionally treats a flowing steps.jsonl as a sign of life (compatibility with older runners).
    • No phantom failed after a clean exit. The “child exited without artifacts” check looked at the flat path and fired bogus run-finished: failed events plus an error toast on every exit.
  • Updated dependencies [174d27e]

    • @unotest/protocol@0.21.0
    • @unotest/core@0.21.0
    • @unotest/viewer@0.21.0
    • @unotest/dsl@0.21.0
    • @unotest/grounder-client@0.21.0

0.20.0 — 2026-08-24

  • Value helpers: return is no longer tied to the flow_ prefix

    A helper that reads state and returns a value had to be named flow_* — the only place the validator accepted return. But flow_* is an executable entry: the runner accepts it as a scenario and flow discovery offers it to the agent to replay while recording, so a width getter showed up in the list of reusable flows.

    The boundary now keys off test_* instead. return is legal in any helper — flow_* composites and plain snake_case value helpers alike — and still rejected in a test_* entry, where it would mean the scenario silently ended halfway.

    unotest/e2e/_helpers/viewer.js
    function overview_header_width() {
    return evaluate(
    '(() => document.querySelector("header").getBoundingClientRect().width)()'
    );
    }

    New linter rule lint:flow-returns-value (warning) flags a flow_* that returns a value: legitimate when the flow acts and yields its result (email = flow_signup()), a rename hint when it only reads. Override it under linter.rules like any other rule.

    No migration needed — existing flow_* helpers that return keep working.

  • A failure in the first test of a file is no longer lost, and the dashboard no longer waits for F5.

    One run, one run:finished. The event was written by the executor’s per-entry tap, so a file with four test_*() functions left four terminal events in steps.jsonl. Every consumer reads the last one — so collection reported a scenario green whenever the final function passed and the first one failed. The runner now writes the event once, folding the outcomes of every entry: failed > interrupted > aborted > completed. A run that never got started (no entry function, browser failed to launch) writes a terminal event too — its artifact used to read as “the process died”.

    The viewer asks for the index again. The first load() could land while the server was still rebuilding the run index: the answer was “no runs”, and nobody asked again — WS only reports a run starting and finishing. On a project with history the whole overview showed “never run” until a page reload. indexReady: false now schedules a retry with a 0.5s → 30s backoff, until the first ready answer.

  • Updated dependencies [7957469]

  • Updated dependencies [aab65a5]

  • Updated dependencies [c6efd92]

    • @unotest/protocol@0.20.0
    • @unotest/viewer@0.20.0
    • @unotest/dsl@0.20.0
    • @unotest/core@0.20.0
    • @unotest/grounder-client@0.20.0

0.19.0 — 2026-08-23

  • The CLI passes termination on to the process it runs. unotest-web is a dispatcher that spawns the real entry (viewer, a scenario run, the MCP server) and waits; killing the dispatcher used to leave that process alive, reparented to init and still holding its port. Ctrl-C hid it — a terminal signals the whole foreground group — so it only bit tooling that kills the process it started: each such call leaked one server. SIGTERM / SIGINT / SIGHUP now reach the child, and the exit code still comes from it.

  • Per-collection parallelism via a workers: field in the collection YAML.

    • workers: N (integer ≥ 1) in unotest/e2e/_collections/*.yaml sets how many scenarios of that collection run concurrently; omitted = serial.
    • Precedence: CLI --workers=N flag > manifest workers: > 1.
    • Viewer: the collection header shows an editable workers value; the setting is stored in the YAML, so CLI and CI runs pick it up too.
    • CollectionMeta (protocol) gains a workers: number | null field.
  • Run history scales: runs are filed under daily shards and the viewer stops polling history it is not showing.

    • New on-disk layout unotest/.runs/<YYYY>/<MM>/<DD>/<runId>/ (UTC date the run started). The shard is a pure function of the runId, which already carries the timestamp — a direct link to a run keeps working even with the index deleted. Existing flat .runs/<runId>/ history is migrated automatically (see the runs migrate entry).
    • The viewer’s watcher polls only LIVE runs; finished history never touches it. Discovery watches the current day’s directory with native fs.watch and re-attaches at UTC midnight.
    • A JSONL index per day and per scenario backs the history list. It is fully rebuildable — rebuilt on start, non-blocking — so a corrupted or missing index degrades the listing, never the runs themselves.
    • @unotest/protocol: new run-shard and run-index modules (shard path derivation, index record shapes) exported from the root entry.
  • Step screenshots are stored once and shared, and old runs expire on their own — a long history stops growing without bound.

    • Identical frames are content-addressed in unotest/.runs/_blobs/ and hardlinked into each run. du and ordinary copies see whole files; cp -a / rsync -H preserve the sharing. On one dogfood suite: 45 frames, 2.04 MB by apparent size, 0.96 MB on disk.
    • Reference counting is the filesystem’s (st_nlink), so nothing to keep in sync. Blob generations keep the dedup honest past ext4’s 65 000-link ceiling, and a fresh blob is untouchable for an hour so the window between writing it and linking it is never mistaken for garbage.
    • Retention: UNOTEST_RUNS_RETENTION_DAYS (whole days, default 180) sweeps runs older than the window at the end of a collection run. It never changes the exit code — a failed sweep is reported, not fatal.
    • @unotest/protocol: new run-blobs module exported from the root entry.
  • Upgrading moves existing run history to the new layout by itself, and Playwright moves to 1.62.

    • The flat .runs/<runId>/ history is migrated on the first run of the new version. Default is to remove old runs (nobody asked to keep them); UNOTEST_RUNS_LEGACY=keep re-files them into date shards instead. The completion marker is written last, so an interrupted or failed move is retried on the next start rather than silently marked done.
    • New command unotest-web runs migrate [--dry-run] — the same migration, runnable FIRST, with a report-only mode. A migration nobody can look at before it happens is one that runs unannounced on someone else’s machine.
    • playwright moves to ^1.62.0 in @unotest/web and @unotest/viewer. Run npx playwright install once after upgrading if your browser cache predates it.
    • Frame capture is now format-parameterised internally, still PNG. WebP was measured and rejected: Chromium’s lossless type: "webp" is pixel-exact but 1.5× LARGER than PNG on real frames (3.17 MB vs 2.04 MB on one suite).
    • Fix: the viewer served step screenshots as 403 after the layout change — its asset allow-list still expected the flat layout. The route now has tests.

0.18.0 — 2026-08-23

  • Editor typings for scenarios — .js files stop looking like broken JavaScript in VS Code / WebStorm: autocomplete, hover docs, signature help and locator-chain resolution, without turning on a second type-checker (checkJs stays off; diagnostics remain the DSL validator’s job).

    • New shipped file types/unotest-dsl.d.ts — ambient declarations for every DSL function, generated from the same contracts the validator uses (buildDslVocab()), so it can never drift from the installed version.
    • New CLI command unotest-web types — creates unotest/jsconfig.json if missing and regenerates the .unotest/types/env.d.ts cache (variable NAMES from unotest/.env / .secrets; values never leave those files).
    • init scaffolds the jsconfig + env cache; scaffoldWorkspace() now includes the jsconfig too.
    • Upgrade path is automatic: lint and e2e create a missing unotest/jsconfig.json on the fly (one announcement line) and silently refresh the env cache. Skipped under CI. No action needed after upgrading — run any test or unotest-web lint once and the editor comes alive. If you keep a custom unotest/jsconfig.json, it is never overwritten (re-run init --force to reset it).
    • @unotest/protocol: additive optional fields on the DSL vocab — DslVocabArg.enumValues (literal members behind kind: "enum") and DslVocabEntry.variadic.
  • New package @unotest/eslint-plugin — unotest DSL scenarios as first-class ESLint citizens. One rule (@unotest/dsl) delegates every check to the validator inside the installed @unotest/web (parser, vocabulary, linter rules, linter.rules severities from unotest.config), so npx eslint unotest/e2e and unotest-web lint report identical diagnostics; a new validator rule needs zero plugin changes. configs.recommended() (async factory, flat config) wires unotest/e2e/**/*.js, turns no-undef off (implicit globals are the DSL’s design; unknown names are the engine’s job) and declares globals for the vocabulary, your helpers and your .env/.secrets variable names.

    @unotest/web: the @unotest/web/dsl subpath now exports loadLintProjectContext() (config severities + helper discovery + variable names) and linterRulesToSeverity() — the same loader the CLI lint command now uses, so the two frontends cannot drift.

  • screenshot() appears in the DSL vocab (completion, hover, signature help, generated editor typings). It always had a validator contract, but its diagnostics contract group was missing from the vocab builder, so every vocab consumer silently lacked it. A parity test now pins the vocab to the contract registry, so a future contract group cannot be forgotten again. @unotest/protocol: new DslVocabCategory value "diagnostic".

0.17.0 — 2026-08-20

  • Upload/API-step DX, driven by 0.16.0 consumer feedback (a multipart upload authored as apiCall(m, p, {file: …}) failed only as a remote 400, with nothing local pointing at upload()):

    • New DSL function json(value) — serialize any value to a JSON string for readable assert messages: assertTrue(res.status == 202, json(res.body)). Objects concatenated with + collapse into [object Object]; this is the readable form.
    • New linter rule lint:api-call-file-body (warning) — an apiCall body object with a file key is sent as plain JSON, no file attached; the diagnostic shows the upload(...) shape to use. Deliberate JSON fields named file are kept with // lint-ok: <reason>.
    • The CLI e2e command now runs the same pre-run lint as the MCP run_test tool and prints diagnostics before the run (advisory — the run still proceeds). Previously authoring mistakes the linter knows about surfaced only through unotest-web lint.
    • Reaching for a JS global in a scenario (JSON.stringify, Date) now answers with the DSL alternative (json(value), nowMs()) instead of the misleading add it to unotest/.env error.
    • The generated unotest.config.mjs template documents the sandbox section (apiBaseUrl, uploadDir, database) — uploadDir was referenced by the guides but absent from the template.

0.16.0 — 2026-08-19

  • File oracles as first-class citizens, plus an interaction-state wait (bot-service feedback, round 4).

    A background process that speaks by appending JSONL was only half supported: waitForFile matches a substring against the WHOLE file, so a multi-key event filter was inexpressible — JSON key order is not guaranteed, and "userId":"x","isBot":true breaks the first time the writer reorders its fields. Every positive wait therefore went through a hand-written node script. Four functions close that:

    • waitForJsonLine(path, filter, {timeoutMs}) → the matching line, parsed. Strict equality per key, dot paths ('user.id') for nesting, unparsable lines skipped (a file caught mid-append is normal). The timeout says how many lines were read, how many parsed, and which one matched the most keys.
    • assertNoJsonLine(path, filter, {withinMs}) — the negative twin.
    • waitForFileCount(path, pattern, count, {timeoutMs}) → the count. Waits for at least count matching lines: between two polls a writer can append more than one, and a strict wait would then never see its target. pattern is a substring, a regex, or the same key filter.
    • assertFileCount(path, pattern, count) — snapshot, strict equality, for “exactly N happened” after the window has been awaited.

    Counting is per LINE (grep -c semantics), not per occurrence.

    waitFor(loc, {state}) also accepts 'enabled' / 'disabled' now, alongside the DOM-presence states. It polls the same probe isEnabled / isDisabled use, so aria-disabled widgets and <fieldset disabled> inheritance count — Playwright’s own wait knows neither. This replaces the for loop around pause() that a widget which unlocks on a timeout otherwise forces.

    Two smaller things that came with them:

    • Options objects on the file oracles reject unknown keys. A misspelled {timeout: 5000} used to be ignored and the step silently waited the default instead.
    • UNOTEST_DEBUG=1 gained a step layer: every executed statement is bracketed with enter/exit (file, line, step label, ms, ok) and every nested driver / resolver event carries the step it ran under. A CLI failure can now be diagnosed by reading the log as step → driver call → answer, instead of matching timestamps by eye.
  • unotest-web viewer learns UNOTEST_VIEWER_HOST / UNOTEST_VIEWER_PORT.

    By default the viewer still binds localhost plus a free port the OS picks. Set the variables for headless / containerized setups where a reverse proxy reaches the viewer at a fixed address (e.g. viewer:4000 inside a compose stack). An invalid port fails fast with an actionable message instead of silently falling back. UNOTEST_VIEWER_NO_OPEN=1 still skips opening the browser.

  • UnotestError — the typed-error base class — now lives in @unotest/core and is part of its public API. @unotest/web re-exports it unchanged, so existing imports and instanceof chains keep working. Motivation: other packages need the same base without copying it, and a published package is the only home they may all import.

  • Unknown function: indexOf now says what to write instead.

    The DSL has no string or array methods, so a JS habit (out.stdout.indexOf(x), s.toLowerCase(), JSON.parse(line)) failed with the same bare message as a typo in a helper name — indistinguishable, and the reason bot-service asked for a linter hint back in 0.10.0.

    The engine stays vocab-agnostic: FunctionContractRegistry gained an optional suggestForUnknown(name) hook, and the web vocab supplies the table (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 CollectionReporter seam — the orchestrator still only emits collection-run:* events.

  • The examples mirror now tops up unotest/.env and unotest/.secrets with keys a release added, instead of skipping the files because they already exist. Your values are never touched — only missing keys are appended.

    The old behaviour broke every upgrade that introduced a variable: .env.example grew the key, .env stayed as it was, and the run died with external variable "CATALOG_SEED" not found. The workaround was to delete unotest/.env by hand — on the runner, and for anyone who forked the examples.

0.14.0 — 2026-08-16

  • Negative window assertions + a string probe (bot-service feedback, round 3):

    • assertNeverAppears(loc, {withinMs}) — watch the window and FAIL the moment the locator becomes visible; pass only when the whole window elapsed with zero sightings. The opposite of every waitFor* — for “must NOT happen” requirements (a private message must not leak into a public feed, a disabled bot must not reply). withinMs is mandatory: absence is only provable within an explicit window, and the step always costs that long.
    • assertNoFile(path, pattern?, {withinMs}) — the file-side mirror of waitForFile with inverted polarity: fail as soon as the file exists (and matches pattern), pass when the window elapsed clean. Replaces the fragile “helper worker + assertTrue(res.code != 0)” inversion.
    • textContains(haystack, needle) → boolean — substring probe for strings that came back as data: assertTrue(textContains(out.stdout, 'ready'), out.stderr).
  • evaluate(js, ...args) now actually passes its arguments. The documented multi-arg form —

    result = evaluate(`function([a, b]) { return a + b; }`, 10, 20);

    — reached the page with undefined instead: the driver’s evaluate signature had no parameter for the argument at all, so the registry handed it over and the driver dropped it. Silently wrong answers, not an error. When js evaluates to a function it is now called with the argument.

    Also adds the seven functions that were missing from the editor vocabulary (randomFirstName, randomLastName, randomEmail, randomText, randomNth, waitForCount, screenshot) — they were documented in the DSL reference but invisible to hover and completion.

  • Fix assertValue, isDisabled / isEnabled, setLocalStorage / getLocalStorage in the published build.

    These four went through functions that Playwright serializes into the page. They were written inline in the driver, so the obfuscation step of the release build rewrote their string literals and member access into string-array decoder calls — and that decoder only exists in Node. In the browser they died with ReferenceError: _0x… is not defined on every call. assertValue was affected on ANY element, not just checkboxes: it probes the checked state first.

    Broken since 0.12.0 (assertValue, isDisabled, isEnabled) and since the first published version (setLocalStorage, getLocalStorage). Only the built package was affected — running from source was always fine, which is why the test suite stayed green.

    The functions now live in the un-obfuscated page-inject entry, next to the snapshot algorithms. Two new guards keep them there: a source check (check:no-inline-evaluate, part of check:surface) rejects any browser-bound function outside that entry, and the bundle smoke — the one step that runs AFTER obfuscation — now exercises all four against a real page.

0.13.1 — 2026-08-15

  • Docs: the textJoin section overstated the + rejection. + with a string literal on either side ('prefix-' + name) concatenates and lints clean; the validator rejects only literal-free string addition (a + b between string-typed variables/calls), which reads as arithmetic. Existing literal-based + code needs no rewrite (bot-service feedback on 0.13.0).
    • @unotest/core@0.13.1
    • @unotest/dsl@0.13.1
    • @unotest/grounder-client@0.13.1
    • @unotest/protocol@0.13.1
    • @unotest/viewer@0.13.1

0.13.0 — 2026-08-15

  • Per-call environments for explore sessions + init guidance (bot-service feedback, round 2):

    • explore_start accepts env: that session’s variables, secrets and APP_BASE_URL resolve against the .env.<env> / .secrets.<env> overlays, without touching the server’s own environment or other sessions. Overlay secrets are also masked in all log sinks. The explore_steps autoRun finale runs the saved test with the same env. Symmetric with run_test’s per-call env.
    • init “Next steps” now says to restart an already-open agent session (MCP clients load .mcp.json servers at session start — no hot-connect in the protocol); same recipe added to the troubleshooting manual.
  • New DSL function textJoin(parts) → string — concatenates an array of parts (numbers coerced). The validator has always rejected + on strings with a hint pointing at textJoin([...]), but the function did not exist in the web registry, leaving scenarios with no legal way to build a dynamic string. Found by the dogfood suite on its first lint.

  • waitForText now respects the active enterFrame stack — it used to build its locator from the top document, so inside a frame it waited on text the frame content could never satisfy. Found by the dogfood suite (nested-iframes scenario).

All notable changes to @unotest/web are documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

0.12.0 — 2026-08-14

  • The active variable environment is now agent-visible instead of docs-only. explore_start and run_test replies carry an environment field: the active env name (UNOTEST_ENV, null = base) plus every .env / .secrets layer file with an exists flag. A missing external variable error now also lists the searched layers (Looked in environment "dev" (UNOTEST_ENV): unotest/.env, unotest/.env.dev (no such file)). Tool descriptions for run_test / explore_start explain the env mechanism — MCP sessions inherit UNOTEST_ENV from the server process (set it in the client’s env block); CLI runs take --env <name>. The shipped agent guide gains a “Variable environments” section.

  • Feedback batch from a real-world integration (bot-service): shell() safety, run diagnostics, boolean-attribute probes, linter ergonomics.

    BREAKING — shell() fails the step on a non-zero exit code. Previously a non-zero exit resolved silently and a forgotten res.code check produced false-green tests. Migration: calls where non-zero is expected (probes, grep -q) pass {allowNonZero: true} as the last argument and keep checking res.code; calls that asserted assertTrue(out.code == 0, ...) can drop the assert. More shell() hardening: a missing binary is a distinct “failed to start” error (was a fake code: 1), output over 10 MiB fails loudly (was silent truncation), and every call has a wall-clock budget (sandbox.shellTimeoutMs, default 120 s, or per-call {timeoutMs}) — a detached grandchild holding the stdio pipes no longer hangs the scenario forever; the timeout error explains the detach recipe.

    New capabilities:

    • waitForFile(path, pattern?, {timeoutMs}) — poll a file until it exists / contains a substring or regex; the file-side waitForText for background processes. Returns the content.
    • sandbox.exportSecrets: [names] — explicit allowlist of .secrets* variables exported into shell() subprocess env (still masked in logs). Secrets stay out of process.env otherwise.
    • isDisabled(loc) / isEnabled(loc) — honest disabled probe (native :disabled incl. fieldset inheritance, or aria-disabled); hasAttribute(loc, name) — presence probe for boolean attributes (getAttribute reads "" for present and absent alike).
    • Generators: randomWord(len) (letters only — digit-free markers), nowMs(), today(), daysFromNow(n) (previously documented in the skills but not implemented).
    • Failed runs report the enclosing step: ✗ test: … in step "…" in the console, stepLabel in the failure bundle and inspect_runtime.
    • Every CLI run prints a one-line header: active environment, layer files (missing ones flagged) and effective baseUrl; run_test / explore_start replies echo baseUrl next to environment.
    • run_test accepts per-call env — the spawned runner child gets UNOTEST_ENV=<env> for that run only.
    • Linter: // lint-ok: <reason> silences warning/info diagnostics on its line (reason required; errors never suppressible); new lint:echo-assert warns when waitForText/assertText overlaps a value the scenario itself filled (chat echo trap); lint:goto-concat / lint:regex-invalid are now configurable in linter.rules (they were emitted but missing from the config schema).
    • A _helpers file that fails to parse is reported as the root cause in unotest-web lint (and explore replies list unparsableHelperFiles) instead of silently cascading into “Unknown function” at every call site. The viewer logs the same condition (patch).

0.11.0 — 2026-08-13

  • e2e CLI argument diagnostics: extra positional arguments are rejected instead of silently ignored (with a “did you mean: unotest-web e2e ” hint for a duplicated subcommand); an argument that resolves to a directory gets its own message instead of the misleading “.js extension” error; unknown scenario names now suggest close matches from unotest/e2e/** (new suggestClosest / levenshteinDistance utilities in @unotest/core). The collection command also rejects extra positionals.

  • Failure artifacts are always on, and steps can carry screenshots.

    • On any scenario failure the runner writes screenshot.png + page.html (runtime DOM, open shadow roots included, secrets redacted) to unotest/.runs/<runId>/failure/ — no UNOTEST_DEBUG needed. The .unotest/failures/ bundle gains the same page.html; its screenshot keeps honouring failureBundle.tier2.
    • New env flag UNOTEST_STEP_SCREENSHOTS=1 captures a PNG after every executed step into unotest/.runs/<runId>/screenshot/.
    • Protocol: screenshot events may carry the step’s file/line/col.
    • Viewer: a step row with a captured screenshot shows a camera icon and toggles an inline preview on click (both screenshot() DSL captures and per-step ones).
  • Parse errors now carry an exact position and a caret code frame.

    • Every lexer failure is a ParseError with line/column (previously bare Error with no position).
    • New formatCodeFrame(source, line, column) / formatParseError exports in @unotest/dsl.
    • The web runner’s “parse error in ” message now includes <file>:<line>:<col> plus the code frame — no more hunting for an “unexpected LBRACKET” by eye.
    • Token columns are now uniformly 1-based at the token start. Previously identifier/number/string tokens were 0-based while operators were 1-based, so carets and editor squiggles missed by one. Migration note: line:col breakpoint keys stored in .debugger[-suffix].json against word-initial statements shift by +1 — re-set those breakpoints once.
  • BREAKING: multipart uploads in apiCall are now opted into explicitly with the new upload(path, {field?, fields?}) DSL function — the {file, field?, fields?} body-shape magic is removed.

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

    Migration:

    // before
    apiCall("POST", "/documents", {
    file: "fixtures/doc.pdf",
    fields: { source: "e2e" },
    });
    // after
    apiCall(
    "POST",
    "/documents",
    upload("fixtures/doc.pdf", { fields: { source: "e2e" } })
    );

    upload() must be the body itself (a nested {doc: upload(...)} is rejected with a pointer to the correct usage). Path containment in sandbox.uploadDir and the Content-Type rules are unchanged.

  • Fixes and DX follow-ups from the real-time-polls field check:

    • Fix: unotest/.runs/<runId>/failure/ was never written. The runner derived the run dir from its internal runtime id (r-N…) while the on-disk dir is named by makeRunId(scenario), so the always-on failure artifacts silently skipped. The CLI now passes its run dir into the runner (RunJsScenarioOptions.runDir), and the failure screenshot + page.html land where documented.
    • init re-syncs tool-owned skill copies. .claude/skills/* and .claude/hooks/* shipped by the package are overwritten on a plain init re-run when they diverge from the installed version (status updated) — stale skills after a package upgrade no longer require --force. User-scaffolded files keep the skip-unless---force behaviour.
    • The MCP agent-test-author prompt caught up with the runtime: multipart via upload(...) and the obj.prop[i] workaround are now documented there too, and both facts are enforced by check-skill-prompt-sync.
  • Screenshots are no longer saved as blank white PNGs. goto() resolves on the load event, which on any client-rendered app fires before the first frame carries content — so a per-step capture (UNOTEST_STEP_SCREENSHOTS=1) of a step that ended on goto(), or a screenshot() call placed right after one, froze the pre-render frame. Both paths now settle first: wait for the network to go quiet, then for a painted frame, capped at 2s total. A page that never goes quiet (polling, live feeds) spends the cap once per capture and is then shot as-is; the wait never fails a run. The failure screenshot deliberately skips the settle — it must show the page as it was at the failure.

    Adds DriverPage.waitForLoadState(state, opts?) — diagnostics-only, backing the settle. It is not exposed to the DSL: a test that waits on “the network went quiet” instead of on visible app state is the flaky pattern the linter rejects.

0.10.0 — 2026-08-06

  • API-setup primitives for pure-_helpers/ test setup (multi-host RAG smoke flows and similar):

    • apiCall(method, path, body?, headers?, options?) gains {base: 'API_BASE_X'} — the NAME of a unotest/.env variable holding an http(s) base URL. Default behavior (path against sandbox.apiBaseUrl) is unchanged; URLs still never appear in scenarios.
    • apiCall multipart upload: a body of the exact shape {file, field?, fields?} posts multipart/form-data via fetch+FormData. The path is relative and confined to sandbox.uploadDir (new optional config field; defaults to the project root) — absolute paths and ../ escapes throw, and a manual Content-Type header with a multipart body is rejected.
    • DSL validator now allows break inside loop bodies (break-outside-loop error elsewhere) and index access on data variables (docs[0].objectsCount); indexing a Locator is rejected as locator-index-access, and while / do…while / continue / arr[i] = v stay unsupported. Parser and executor already supported these forms; only validation changed.
  • Assert steps ground as whole units. Intent.prefer gains "unit": the grounder returns the semantic unit itself (heading / record / text) and suppresses the record→control hop — the profile for read-only targets. Web assert actions (assert_text/visible/hidden/value/count) declare intentTarget: "unit" on their plugins, and the intent resolver materializes a DOM ref for unit resolutions via the new materializeRefScript(nodeId) (grounder-client dom-walker): one lazy attribute write on the unit’s own element, reused by later captures. Previously an assert intent like “the page heading” was rejected with a RECORD error and forced a find_element detour. Control-target errors now name the unit kind correctly (heading / record / text block) and point at assert steps for check-not-click intents.

    Materialized record containers now resolve to a stable locator. A record row / card / tree-item (<li>, <tr>, <article>) carries no accessible name of its own — its text lives in children — so RefResolver gains a container strategy: getByRole(role).filter({hasText}), keyed off the container’s short descendant text (capped so a whole table is never captured) and verified for uniqueness + ref identity. Fixes assert_visible on a grounded tree item failing with no stable identifier.

  • assertValue on a checkbox / radio / switch now asserts its checked state instead of the DOM value. Their value is the submitted payload ("on" by default) and is identical whether the box is ticked or not, so the old behaviour made a correct test fail on DOM trivia (live agent run: assertValue(checkbox, "true") → expected "true", got "on").

    Pass 'true' / 'false'; any other expectation on a checkable element is rejected with a usage error naming the rule. Text inputs, textareas and selects are unchanged. Applies to the assert_value MCP action too.

    Migration: a test asserting the literal payload — assertValue(checkbox, 'on') — now fails with that usage error. Assert the state ('true') instead; to read the raw attribute use getAttribute(loc, 'value').

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

    assert_value also grounds intent locators as a control now (intentTarget: "control"). Under the previous "unit" profile an intent like “selection checkbox of the Pending row” resolved to the ROW, materialized a ref on the <tr>, and failed with Node is not an <input>. The read-only asserts (assert_text / assert_visible / assert_hidden / assert_count) keep the unit profile.

  • Chat-feed-friendly wait primitives (ai-support plan, tasks 5–6):

    • waitForText now waits for ≥1 VISIBLE occurrence instead of failing strict mode on duplicated text; {first: true} becomes a no-op. New {exact: true} option and regex matchers (waitForText(/\bhi\b/)) fix substring-inside-a-word false matches.
    • New waitForCount(loc, n, {timeout, exact}) DSL primitive and wait_for_count explore action: polls until the locator matches at least n elements (exactly n with {exact: true}) — the “wait for reply №N” pattern without index locators; counts as a verification step for autoRun / NO_VERIFICATION.
  • Shared injected DOM helpers: one createDomHelpers() factory (visibility, ARIA roles, W3C accname, state flags, ref minting) now backs the grounder-client DOM walker and the web snapshot/find algorithms, composed into the page by composeInjectedScript — replaces four hand-maintained copies of the same in-page logic.

    Breaking (@unotest/grounder-client): captureRawTree takes the helpers object as its first parameter, so passing it straight to page.evaluate(captureRawTree) no longer works (evaluate arguments are JSON-serialized and cannot carry the helpers). Migration: inject the serialized script instead — page.evaluate(domWalkerScript()) — which composes the helpers automatically. New exports: createDomHelpers, composeInjectedScript, InjectedDomHelpers, DomHelperOptions, InjectedScriptOptions.

    @unotest/web behavior deltas from unifying on the walker’s ARIA mapping: get_aria_snapshot now maps dl to list (dt/dd stay name-only) and tbody/thead/tfoot to rowgroup, and never names structural containers from their text content; find_element matches against the full W3C accname (label-for lookup, composed multi-child names) instead of a reduced approximation.

  • Recorded assertions and one-call test authoring in exploration mode. Six new recordable actions — assert_text, assert_visible, assert_hidden, assert_value, assert_count, assert_url — available in explore_step, explore_steps and explore_record; they execute live through the same poll-until-timeout cores as the runtime assertText(...) family (extracted to dsl/assert-core.ts), so a recorded assert has already passed against the real page and the generated DSL carries the matching assert lines. Assert steps count as verification for the NO_VERIFICATION save gate. New autoRun: true flag on explore_steps (requires explorationId): when every step succeeds, a verification step is recorded, the draft has no blocking warnings (FRAGILE_LOCATOR is informational — it passes and rides along in the reply; DYNAMIC_TEXT / NO_DSL_PRIMITIVE block, mirroring save’s policy) and the target file is new, the same call stops the session, saves unotest/e2e/<scenarioName>.js, resets the browser context and runs the test with run_test semantics (autoRun.run.next.outcome); any gate failure degrades to autoRun.status: "skipped" + reason with the session left active. The green happy-path shrinks to new_context → explore_start → explore_steps {…, autoRun: true}. UNOTEST_TOOLSET=core unchanged — asserts are actions, not tools.

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

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

    explore_start opens the browser context itself when none is open, so the new_context prologue is gone from the happy path. new_context keeps its role as the explicit RESET (between the recording and the first run, and inside autoRun); an existing or attached collaborative context is never re-created.

  • Steps whose locator was an intent now report groundedTo — the element line the grounder resolved it to (checkbox "Select row R-00147" (in row "Row R-00147: … Pending …")), per locator argument. Present on explore_step, every step of an explore_steps batch, and explore_record.

    Until now a step’s reply said only executed / recorded / success, so an agent could not tell WHICH element a positional intent hit without a separate ground_element probe — grounding the same intent twice. A live run spent 4 extra calls and doubled grounder time doing exactly that. The skill and the tool descriptions now point at groundedTo instead.

  • New screenshot(name?, {fullPage?}) DSL command — on-demand PNG capture of the active page into unotest/.runs/<runId>/screenshot/<NNN>-<name>.png (returns the project-relative path). Evidence tool for green runs: fixes what the page actually looked like at a chosen point. Each capture is also mirrored as a screenshot RunArtifact event, so the viewer’s event stream links to it. In runtimes with no artifact dir (exploration) the call logs a warning and returns null instead of failing the run.

  • The write-e2e-test-ground skill now records batch-first: when the task brief already spells out the step sequence, Phase 2 sends it as one explore_steps call (goto included, intent locators ground at execution time) instead of one explore_step per action, with a repair recipe for mid-batch failures. A/B across four eval tasks showed equal-or-better scores with fewer calls, lower cost, and 9–38% less agent time. The snapshot-mode skill is unchanged — its ref discipline requires a snapshot before locator steps, so pre-discovery batching does not apply.

  • Dedup pass across the tree (jscpd 0 clones, ratchet threshold 0). New public export in @unotest/grounder-client: intEnvInRange(env, name, def, min, max) — the UNOTEST_GROUNDER_* integer-env parser shared by the consumer side and the grounder server. Everything else is internal refactoring with no behavior change: protocol gains a shared fs-safe-name validator behind validateCollectionName / validateSegmentName and a shared env-file assignment iterator; core’s RuntimeInspection becomes a Pick of RuntimeStateFile; dsl’s ExecutionWalker threads one WalkCtx object instead of an eight-argument clump and AST nodes use constructor parameter properties; web deduplicates the MCP tool scaffolds, action plugins, DSL contracts, perception LRU caches, and semantic-dom grouping passes.

  • Grounder infra resilience. The MCP server now runs a cheap grounder reachability ping at startup when UNOTEST_GROUNDER_MODE is local or remote (/api/tags for local ollama, /health for a remote grounding server) — non-blocking, warning-only. Network-level failures on the intent / ground_element path are wrapped into a typed GrounderUnreachableError citing the backend URL, a mode-specific fix hint and the boot-time health verdict, instead of surfacing undici’s raw fetch failed. The ground-mode skill now spells out the exact fallback recipe: a single {kind:"intent"} step, the ref from find_element, or the ready-made locator object from ground_element — hand-assembled getByRole/css locators are rejected in recording mode.

0.9.2 — 2026-06-16

Added

  • Run snapshot export. A finished run can be exported as a portable *.unotest.zip bundle (events + captured artifacts) for a teammate to open in their own viewer. The runner now records console / network capture flags in the run manifest so a re-rendered snapshot shows “capture was off” rather than an empty console, and unotest/.snapshots/ is added to the generated .gitignore.

Fixed

  • Locator picker survives navigation and reaches into shadow DOM. A navigation that tore down the JS context mid-drain now surfaces as a typed ExecutionContextDestroyedError, so the picker overlay reports not-alive and self-heals (re-stamp refs + re-inject) instead of logging a dead end. Hit-testing descends through open shadow roots and ref-lookup climbs back out across shadow boundaries, keeping the picked element and its stamped ref symmetric with the snapshot capture.

0.9.1 — 2026-06-14

Added

  • check_locator MCP tool + runtime locator probe. Evaluate a DSL locator string against the live page and see what it actually matches — count, the N a trailing .first()/.last()/.nth() silently collapsed, and where each match points — instead of guessing or re-running the whole test. Same parser/core (probe-locator) the runner uses, so a result here equals the runtime locator.
  • Interactive debug + auto-attach. run_test --debug auto-attaches the MCP session to the paused run’s shared browser, so get_page_snapshot / find_element / check_locator hit the page being debugged. Collaborative probe command (the human’s check_locator twin) is handled by the runner.
  • Agent tool calls in the viewer’s System console. A new StepsToolEventSink publishes each MCP tool call + result (secret-redacted, capped) into the attached run’s steps.jsonl, so the human sees what the agent is doing.
  • Run breakpoints published to runtime.json (.debugger.json seed + --breakpoints override), so the viewer paints gutter dots for non-persisted breakpoints.

Changed

  • Shared runner machinery extracted to @unotest/core (run-artifact / run-manifest / runtime-state writers, debug-commands watcher, env reader) and consumed via thin web bindings.

Fixed

  • abort now actually terminates a paused run. A {cmd:abort} on a run paused mid-statement could not unwind the suspended executor, leaving the process + headed browser alive with a fresh heartbeat (the viewer showed a ghost “active” session). Abort now force-terminates the runner the same clean way Ctrl-C / SIGTERM does, emitting a terminal run:finished and closing the browser.

0.9.0 — 2026-06-11

Added

  • Opt-in capture artifacts for the viewer inspector. UNOTEST_NETWORK=1 records each request/response to the run’s network.json; UNOTEST_CONSOLE=1 records the browser console to console.json. Written per run (any outcome), redacted of secrets. Off by default — zero overhead.
  • runtime.json for every run. The runtime-state writer (vars / scope / call stack) now runs for plain headless runs too, not just --debug, so the viewer’s Vars inspector is populated on any run.
  • Friendly relative-goto error. goto('/path') with no baseUrl configured now throws a typed NavigationError that names the URL and the fix, instead of Playwright’s “Cannot navigate to invalid URL”. When baseUrl is set, Playwright resolves the relative path as before.

Changed

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

0.6.2 — 2026-06-05

Added

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

Changed

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

Added

  • External variables and secrets. Scenarios reference external values by bare UPPER_SNAKE identifiers (APP_BASE_URL, TEST_USER_EMAIL) instead of hard-coding them. Non-secret values live in unotest/.env, secrets in unotest/.secrets (gitignored); both are created by init and loaded fresh on every run. Secret values are masked as ‹secret:NAME› in logs, debug output, and recorded artifacts, so they never reach a saved file in plaintext.
  • Reusable flows. Recorded exploration steps can be tagged with a flow group and extracted on save into a flow_<name>() helper under unotest/e2e/_helpers/; the generated test calls the helper instead of inlining the steps. The new explore_run_flow MCP tool replays an existing flow_* helper without re-recording it, and explore_start now surfaces the available flows and the declared variable names.
  • Run source snapshot. Each run writes a sources.json (the entry scenario plus every loaded helper as they were when the run began), so a run always renders against the code that actually executed even if the file is edited afterward. (Consumes @unotest/protocol’s RunSources.)
  • New scenario linter rules: scenario-in-root (scenarios must live in a feature subfolder) and mustache-in-dsl (flags {{NAME}} template syntax inside DSL string literals).

Changed (BREAKING)

  • Scenarios must live in a feature subfolder. A scenario placed directly in unotest/e2e/ is now rejected by the linter — use unotest/e2e/<feature>/<name>.js (the e2e/ root is reserved for _helpers/). The MCP scenarioName argument (explore_start / save_exploration_as_test) likewise requires <feature>/<name> form, and init scaffolds the welcome smoke test at unotest/e2e/welcome/smoke.js.
  • {{NAME}} inside a DSL string literal is now a lint error. That form is only for MCP recorded values; in scenario code, reference a variable as a bare identifier (fill(loc, PASSWORD)).

Fixed

  • Windows: path handling. Run-artifact paths (entry, sources.json keys, step file events) are normalized to project-relative posix, so the viewer’s asset allowlist and source mapping match on Windows — a native \ previously broke them.
  • Actionable error when no browser is installed. Playwright launch now preflights the browser binary and fails with a clear message hinting npx @unotest/web install-chromium, instead of a raw stack trace; launch failures are printed to the console instead of exiting silently.

0.5.0 — 2026-05-29

Added

  • Recording mode (exploration sessions). Eight new MCP tools — explore_start, explore_stop, explore_state, explore_step, explore_record, explore_remove_step, generate_dsl_from_exploration, save_exploration_as_test. The agent drives the browser through explore_step, which both executes the action and records it into a session log. When done, save_exploration_as_test generates a DSL scenario and runs it via run_test. Session data persists as JSONL under unotest/.explorations/ (override via EXPLORATIONS_DIR).
  • Ref resolve-on-record. Wire-only {kind:"ref",ref:"eN"} locators emitted by get_page_snapshot are resolved to stable forms (getByTestId / getByRole / getByLabel / getByText / locator) at record time. The persisted scenario contains no stale refs; execution still uses the original ref-locator for precise targeting.

Changed (BREAKING)

  • 14 per-action MCP tools removed. click, double_click, fill, hover, press, check, uncheck, select_option, scroll_into_view, goto, reload, wait_for, wait_for_text, wait_for_url are replaced by explore_step { action, locator, … }. Without an explorationId, explore_step runs the action ad-hoc with the same semantics as the removed tools.

0.4.0 — 2026-04-25

Added

  • Debug logging v2 — folder per call (UNOTEST_DEBUG=1). Each MCP tool call gets its own directory unotest/.debug/<session>/<NNN>-<tool>/ containing: call.json (args, events, result), before.html / after.html (full shadow-pierced DOM), before.txt / after.txt (outline), diff.txt (line-level LCS diff of outline), aria.yaml (ARIA tree), resolved/<ref>.html (outerHTML of the resolved element). On failure: page.html + screenshot.png. Levels: =1 / =full all artifacts; =summary tool + snapshot only, no HTML; unset — no I/O.
  • audit_last_run MCP tool. Deterministic rule-based audit of the last debug session: catches ref-before-snapshot, resolve-loop, missing section descriptions, and selector downgrades.
  • get_last_mcp_log filters. errors, layer:N, tool:N, exploration:<id> — narrow the JSONL log to relevant events.
  • Resolver: data-* stable-id hints. data-id, data-guid, data-key and similar attributes are detected and used as primary CSS selectors ([data-id="…"]), making table-row locators stable across re-renders.
  • Resolver: tooltip / label data-* hints. data-tooltip, data-shadow-title, data-original-title and similar attributes provide display names for icon-only controls.
  • Resolver: clickable-row detection. Non-control elements with cursor:pointer or onclick and a stable data-* id appear in the outline as [clickable] entries, collapsing large grids to the meaningful rows.
  • Ancestor-scope disambiguation. When a best locator candidate is ambiguous (multiple matches), the resolver climbs to the nearest unique ancestor and emits a single combined CSS selector (<ancestorSel> <childCss>) rather than failing.

0.3.0 — 2026-03-18

Added

  • UNOTEST_DEBUG logging. Set UNOTEST_DEBUG=1 to write a structured JSONL log to unotest/.debug/mcp-latest.jsonl. Captures tool calls, resolver decisions, and driver events. Readable via the get_last_mcp_log MCP tool.
  • get_last_mcp_log MCP tool. Returns the last N lines of the debug log. Agents use it to self-diagnose stale refs and resolve failures without human intervention.
  • Shadow DOM full-capture. before.html / after.html artifacts pierce shadow roots via getComputedStyle — plain outerHTML misses shadow-hosted content.

Fixed

  • Outline renderer truncated role names longer than 32 chars; now preserves full name.
  • RefResolver emitted duplicate getByText selectors when two adjacent elements had identical visible text; now falls back to positional nth-selector.

0.2.0 — 2026-01-08

Added

  • Local results viewer (unotest-web viewer). Opens a browser UI showing live test results, step logs, and screenshots. Backed by @unotest/viewer — installed automatically as a dependency.
  • open_viewer MCP tool. Starts the viewer server in the background and returns its URL. Subsequent calls reuse the running instance.
  • Collection runner (unotest-web collection <name>). Runs every scenario listed in unotest/e2e/_collections/<name>.yaml under a shared parent run-id. Supports --workers=N for parallel execution and --bail to stop on first failure.
  • install-chromium command. Downloads Playwright’s bundled Chromium (~150 MB) for environments without a system browser.
  • list_runtimes MCP tool. Returns all active scenario runtimes in the current session (run-id, scenario name, status, step count).

Fixed

  • unotest-web init failed silently when the target directory existed but was missing unotest/; now creates missing subdirectories.
  • run_test returned a stale run-id when called concurrently with an in-progress run; each call now allocates a fresh id atomically.

0.1.0 — 2025-10-01

Initial release.

Added

  • DSL — JavaScript scenarios on a sandboxed AST engine. Scenario files are .js in unotest/e2e/ using Playwright-vocabulary primitives (goto, click, fill, getByRole, getByTestId, assertText, and ~54 others). The AST parser (now published as @unotest/dsl) supports method chains, options-object literals, and raw backtick strings.
  • MCP server with 37 tools. Bundled server (unotest-web mcp, default subcommand): 6 debugger tools (run_test, step, resume, inspect_runtime, abort_runtime, list_runtimes), 21 browser interaction tools, 4 multi-context tools (list_pages, switch_page, new_context, close_context), 6 failure-bundle tools, and agent_fix.
  • Semantic DOM canvas. Inspection layer producing a compact, agent-readable view of the page (role / name / relevant attributes). Used for both live snapshots and failure bundles.
  • Failure bundle — tier 1 + 2 capture. On test failure the runner writes failure.json, console.json, snapshot.json, and screenshot.png to unotest/.runs/<runId>/. Accessible via list_failures, get_failure_trace, and related MCP tools.
  • agent_fix workflow. mcp__unotest-web__agent_fix builds a structured fix-context bundle (last failure, relevant DOM, scenario excerpt). The package does not call an LLM or auto-apply patches — human review is required.
  • AST scenario linter (unotest-web lint). Six rules: deep CSS selectors, XPath, obfuscated class names, explicit pause() calls, disambiguate-by-index, and discouraged evaluate() patterns. Severity is config-driven via unotest.config.*.
  • Project-agnostic sandbox primitives. shell(cmd, …args) (execFile, no shell interpolation), dbQuery / dbExec (lazy peer dependency — pg / mysql2 / better-sqlite3 resolved from sandbox.database URL), apiCall(method, path, body?, headers?) (against sandbox.apiBaseUrl). Consumer-specific seeders live in unotest/e2e/_helpers/.
  • Cross-browser support. Chromium, Firefox, WebKit via Playwright.
  • unotest-web init. Bootstraps unotest.config.mjs, unotest/e2e/smoke-welcome.js, unotest/e2e/_helpers/, unotest/.env, MCP config, and Claude Code skill into any project.