@unotest/web 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +496 -0
- package/README.md +57 -5
- package/dist/config/schema.d.ts +18 -18
- package/dist/config/schema.js +1 -1
- package/dist/driver/index.js +1 -1
- package/dist/dsl/index.d.ts +9 -9
- package/dist/dsl/index.js +1 -1
- package/dist/dsl/web-dsl-language-service.js +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/runner/cli.js +1 -1
- package/dist/runner/init/chrome-probe.js +1 -1
- package/dist/runner/init.d.ts +42 -2
- package/dist/runner/init.js +1 -1
- package/dist/runner/install-chromium.js +1 -1
- package/dist/runner/prepare-fix.js +1 -1
- package/dist/runner/scaffold-workspace.js +1 -1
- package/dist/runner/serve-fixture.js +1 -1
- package/dist/runner/web-runner-adapter.js +1 -1
- package/package.json +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,501 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.35.0] - 2026-09-15
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 86da16e: A remote grounder can now say "this account has spent its month", and a client
|
|
8
|
+
can tell that apart from a bad token.
|
|
9
|
+
|
|
10
|
+
A grounder running on an external model provider meters what each account
|
|
11
|
+
costs and stops at a ceiling. At the ceiling the two routes that call a model —
|
|
12
|
+
indexing a page and resolving an intent — answer HTTP **402** with
|
|
13
|
+
`grounder-budget-exhausted`. Money, not permission: the token is perfectly
|
|
14
|
+
good, nothing is re-minted over this, and a client that treats it as an auth
|
|
15
|
+
failure would retry forever. The body carries no amount and nothing about any
|
|
16
|
+
other account; the figure is an estimate of a bill we do not issue, and a
|
|
17
|
+
customer would rightly check it against the provider's own.
|
|
18
|
+
|
|
19
|
+
What a ceiling stops is **writing** tests, not running them, and every text
|
|
20
|
+
about it says so: only the MCP server asks the grounder — `ground_element`,
|
|
21
|
+
and an intent locator inside `explore_step` / `find_element`. A scenario
|
|
22
|
+
carries the locator it was written with, so suites, their runs on a box and
|
|
23
|
+
the viewer are untouched by a cap.
|
|
24
|
+
|
|
25
|
+
`ErrorResponse.code` names it, and gains `revocation-list-unavailable` at the
|
|
26
|
+
same time: a grounder has been answering with that code since signed tokens
|
|
27
|
+
arrived, and the union never learned about it — a client written from the type
|
|
28
|
+
could not discriminate a refusal it was already receiving. `retryAfterSeconds`
|
|
29
|
+
joins the shape for it, matching the `Retry-After` header that was already
|
|
30
|
+
being sent.
|
|
31
|
+
|
|
32
|
+
Nothing changes for a grounder running on our own GPU: nothing is metered
|
|
33
|
+
there, so nothing can be refused for money.
|
|
34
|
+
|
|
35
|
+
- 7ffa4ae: `box picker on|off`: the Picker, switched on a box you already have
|
|
36
|
+
|
|
37
|
+
The Picker is an hourly surcharge on top of the box, and until now it could
|
|
38
|
+
only be chosen when the box was rented — with `box create --picker`, a flag
|
|
39
|
+
most people met for the first time in `--help`.
|
|
40
|
+
|
|
41
|
+
- **`unotest-web box picker on|off [name]`** switches it on a live box, with
|
|
42
|
+
no re-creating and no re-pushing. `on` mints the box's tokens the way
|
|
43
|
+
`box access` does and writes the Picker's three keys —
|
|
44
|
+
`UNOTEST_GROUNDER_MODE=remote` and `UNOTEST_GROUNDER_REMOTE_URL` into
|
|
45
|
+
`unotest/.env`, `UNOTEST_GROUNDER_REMOTE_TOKEN` into `unotest/.secrets`.
|
|
46
|
+
`off` clears the three again, but only when they name that box's grounder:
|
|
47
|
+
a project pointing at a grounder of its own, or at another box's, keeps it
|
|
48
|
+
and is told so. Either way the charge follows at the next hourly tick,
|
|
49
|
+
because a started hour is charged at the rate it started with.
|
|
50
|
+
- **`box create` asks** about the Picker on a terminal when `--picker` was not
|
|
51
|
+
given, quoting the hourly surcharge from the cloud's own price list rather
|
|
52
|
+
than a number kept in the CLI. `--yes`, `--json` and a run with no terminal
|
|
53
|
+
answer no without asking.
|
|
54
|
+
- **`box status`** says whether the Picker is on.
|
|
55
|
+
- **`run_test` offers it once.** A project that has a box but grounds intents
|
|
56
|
+
nowhere remote gets `pickerSuggestion` (`{state: "no-picker", next, why}`)
|
|
57
|
+
after the first run that passes — exactly once per MCP server session, on
|
|
58
|
+
the same terms as `boxSuggestion`, and never both: with no box there is
|
|
59
|
+
nothing to put a Picker on.
|
|
60
|
+
- **A refusal for money reads as one.** A remote grounder answering
|
|
61
|
+
`grounder-budget-exhausted` (HTTP 402) means the account's estimated spend
|
|
62
|
+
for the month has reached its ceiling, not that the token went bad: nothing
|
|
63
|
+
is re-minted over it, and the CLI and MCP replies name both ways out — top
|
|
64
|
+
up the balance, or `box picker off` to stop the Picker and ground locally
|
|
65
|
+
again. No amount is quoted: the figure estimates a bill we do not issue.
|
|
66
|
+
|
|
67
|
+
### Patch Changes
|
|
68
|
+
|
|
69
|
+
- 79c99ab: A box can now say "I cannot judge this token yet" instead of "your token is
|
|
70
|
+
wrong".
|
|
71
|
+
|
|
72
|
+
A box rented from the fleet accepts tokens the cloud signed for it, and it
|
|
73
|
+
checks every one against the operator's revocation list. Until that list has
|
|
74
|
+
reached the box — the minutes between it coming up and its first update tick
|
|
75
|
+
— there is nothing to check against, so such a token is refused. That is not
|
|
76
|
+
the same refusal as a bad credential: the answer is to send the SAME token
|
|
77
|
+
again shortly, never to mint another.
|
|
78
|
+
|
|
79
|
+
`BoxReadErrorCode` gains `revocation-list-unavailable` for it, and a refusal
|
|
80
|
+
body may carry `retryAfterSeconds` (the box sends `Retry-After` with the same
|
|
81
|
+
number). The push and run answers gain the same code, plus `ambiguous-project`
|
|
82
|
+
for a cloud-minted push token that names no project on a box that serves
|
|
83
|
+
several. Nothing changes for the tokens a person or a CI job mints on the box
|
|
84
|
+
itself.
|
|
85
|
+
|
|
86
|
+
- 1584ce2: `box status` says where the Picker grounds
|
|
87
|
+
|
|
88
|
+
A box whose Picker is on now prints one more line: `Grounding: Our GPU` or
|
|
89
|
+
`Grounding: External provider (Gemini)` — which models the cloud resolves
|
|
90
|
+
intent locators with. It is the answer to a question a customer could not
|
|
91
|
+
check before, only read on a web page.
|
|
92
|
+
|
|
93
|
+
The line appears only while the Picker is on. With it off, intents are
|
|
94
|
+
resolved on the machine the tests run from, and naming our models under that
|
|
95
|
+
box would describe somebody else's. A cloud that has not heard from its own
|
|
96
|
+
grounder sends nothing and the CLI prints nothing: a mode we have not been
|
|
97
|
+
told is not a mode to invent.
|
|
98
|
+
|
|
99
|
+
On the wire the field is `grounding`, not `grounderMode`. `UNOTEST_GROUNDER_MODE`
|
|
100
|
+
already means something else and belongs to the reader — whether THEIR machine
|
|
101
|
+
grounds locally, remotely or not at all — and in `box status` the two would
|
|
102
|
+
have met a line apart. It is optional, so an older cloud simply says nothing.
|
|
103
|
+
|
|
104
|
+
- dee96a1: A box can now say "this token has expired" instead of "your token is wrong".
|
|
105
|
+
|
|
106
|
+
Tokens the cloud signs for a rented box live thirty days. Until now a box
|
|
107
|
+
refused an expired one with the same blanket `unauthorized` it gives a
|
|
108
|
+
forgery, so the CLI could not tell "ask the cloud for another pair and
|
|
109
|
+
retry" from "this credential will never work", and an expired pair looked
|
|
110
|
+
revoked.
|
|
111
|
+
|
|
112
|
+
The read, push, run and values answers gain the code `expired` for it. A box
|
|
113
|
+
sends it only for a token its operator key really signed, of the right kind,
|
|
114
|
+
for that box — the signature, the kind and the box are all checked before
|
|
115
|
+
the expiry is — so nothing a guesser sends can produce it. It is also
|
|
116
|
+
decided before the revocation list is consulted: a box that has not received
|
|
117
|
+
a list yet still answers `expired` rather than asking for the same dead
|
|
118
|
+
token again.
|
|
119
|
+
|
|
120
|
+
Two codes that a box could already send are now accepted when parsing a run
|
|
121
|
+
or values answer, which had been left out of those two readers:
|
|
122
|
+
`revocation-list-unavailable` and `ambiguous-project`.
|
|
123
|
+
|
|
124
|
+
- 220bf37: Fake cloud: two access pairs that arrive without a `client_name` no longer
|
|
125
|
+
replace each other. A machine whose hostname the contract's charset refuses
|
|
126
|
+
sends no name at all, so two such machines were evicting one another from a
|
|
127
|
+
box — the fake now replaces a pair only when a name actually matches. Its
|
|
128
|
+
refusal for a box that is not ready is worded like the rest of the service
|
|
129
|
+
("That box is deleted."), so a client sees the same sentence from the fake
|
|
130
|
+
and from the real cloud.
|
|
131
|
+
- 067cb3d: The Picker comes with the box, and the CLI says so
|
|
132
|
+
|
|
133
|
+
The Picker used to be priced as a factor on the box's own rate, which made
|
|
134
|
+
its surcharge a different number for every size and made "what does it cost"
|
|
135
|
+
a sum rather than a price. It is now a fixed hourly price per size — and, for
|
|
136
|
+
now, one nobody pays: it is included in the price of the box.
|
|
137
|
+
|
|
138
|
+
`box create`'s question, `box picker on|off` and `box status` all say it the
|
|
139
|
+
same way, with the number the cloud sends and never one of this CLI's own:
|
|
140
|
+
"included in the price of the box (normally €0.05/h)". The list price stays
|
|
141
|
+
visible on purpose. "Included", alone, reads as "cheap now, billed later" —
|
|
142
|
+
and what is actually charged is the number a client should quote, so
|
|
143
|
+
`GET /rates` sends both: `picker` is what will be added to the bill (zero
|
|
144
|
+
while it is included) and the new `pickerList` is what it lists at, beside
|
|
145
|
+
`pickerIncluded`.
|
|
146
|
+
|
|
147
|
+
A cloud that does not send the two new fields is quoted the old way, from
|
|
148
|
+
`picker` alone — nothing to strike through, and no invented number.
|
|
149
|
+
|
|
150
|
+
- 417369a: `box picker off` now takes the token away, not just the surcharge
|
|
151
|
+
|
|
152
|
+
Until now `off` stopped the hourly charge and cleared the Picker's keys from
|
|
153
|
+
this project's files. A copy of the token already on another machine — a
|
|
154
|
+
teammate's laptop, a CI secret — went on working until the box's licence ran
|
|
155
|
+
out, grounding intents on our grounder that nobody was paying for.
|
|
156
|
+
|
|
157
|
+
Switching the Picker off now revokes that box's Picker tokens, all of them:
|
|
158
|
+
the cloud writes their ids into the operator's signed revocation list, and
|
|
159
|
+
the grounder stops accepting them once it has re-read
|
|
160
|
+
that list. The command says so, and says the minute rather than promising an
|
|
161
|
+
instant. Clearing the files is now the convenience it always was; the
|
|
162
|
+
revocation is what ends the access.
|
|
163
|
+
|
|
164
|
+
Two smaller things follow from it. `off` asks the cloud to revoke even when
|
|
165
|
+
the Picker was already off, so repeating the command is how you finish the
|
|
166
|
+
job if it failed once. And a revocation the cloud cannot write is a refusal
|
|
167
|
+
you see — the surcharge stops either way, but a token still in the wild is
|
|
168
|
+
not something to leave in a log.
|
|
169
|
+
|
|
170
|
+
- Updated dependencies [79c99ab]
|
|
171
|
+
- Updated dependencies [dee96a1]
|
|
172
|
+
- Updated dependencies [86da16e]
|
|
173
|
+
- Updated dependencies [7ffa4ae]
|
|
174
|
+
- @unotest/protocol@0.35.0
|
|
175
|
+
- @unotest/grounder-client@0.35.0
|
|
176
|
+
- @unotest/viewer@0.35.0
|
|
177
|
+
- @unotest/core@0.35.0
|
|
178
|
+
- @unotest/dsl@0.35.0
|
|
179
|
+
|
|
180
|
+
## [0.34.0] - 2026-09-07
|
|
181
|
+
|
|
182
|
+
### Minor Changes
|
|
183
|
+
|
|
184
|
+
- c6b91bb: From `init` to a paid box without copying a token: `login`, `box create`,
|
|
185
|
+
and the tokens that keep themselves fresh.
|
|
186
|
+
|
|
187
|
+
Three new commands sign this machine in to unotest cloud — the account
|
|
188
|
+
that rents boxes — and rent one:
|
|
189
|
+
|
|
190
|
+
- `login` runs the device flow (RFC 8628): it prints a link and a code,
|
|
191
|
+
opens the browser when there is one, and waits for the approval. The
|
|
192
|
+
key is kept in `~/.config/unotest/credentials.json` (mode 0600,
|
|
193
|
+
`XDG_CONFIG_HOME` honoured) and is never printed, not even with
|
|
194
|
+
`--json`. `UNOTEST_CLOUD_TOKEN` in the environment wins over the file,
|
|
195
|
+
which is how a CI job signs in. `logout` revokes the key on the server
|
|
196
|
+
when the cloud can be reached and removes it locally always; `whoami`
|
|
197
|
+
says who, where the key came from, and the balance (exit 77 when not
|
|
198
|
+
signed in).
|
|
199
|
+
- `box create [name] [--size s|m|l] [--topup <eur>]` creates a box named
|
|
200
|
+
after the project (its package name, as a slug), with the environments
|
|
201
|
+
the project runs locally (`unotest/.env` and every `unotest/.env.<name>`,
|
|
202
|
+
their `APP_BASE_URL` or the config's `baseUrl` as the target). When the
|
|
203
|
+
balance will not cover the first hour it prints a Stripe Checkout link,
|
|
204
|
+
opens it when it can, and waits for the payment. Then it waits for the
|
|
205
|
+
box, mints its cloud-signed push and read tokens, writes
|
|
206
|
+
`UNOTEST_BOX_URL` to `unotest/.env` and `UNOTEST_BOX_TOKEN` +
|
|
207
|
+
`UNOTEST_BOX_READ_TOKEN` to `unotest/.secrets` (through the same
|
|
208
|
+
structure-preserving writer the viewer's Variables panel uses, with
|
|
209
|
+
`.gitignore` guarded the way `init` guards it), pushes the suite —
|
|
210
|
+
retrying with backoff while a fresh box has no revocation list yet —
|
|
211
|
+
and prints the viewer's address. Exit codes for an agent driving it
|
|
212
|
+
without a terminal: 75 the payment was not confirmed in time (nothing
|
|
213
|
+
created, nothing charged), 76 the terms of service are not accepted
|
|
214
|
+
(a person must, in a browser), 77 not signed in. No prompt is ever
|
|
215
|
+
shown without a TTY.
|
|
216
|
+
`--picker` rents the box with the Picker; `UNOTEST_GROUNDER_MODE=remote`
|
|
217
|
+
and `UNOTEST_GROUNDER_REMOTE_URL` then go to `unotest/.env` and
|
|
218
|
+
`UNOTEST_GROUNDER_REMOTE_TOKEN` to `unotest/.secrets` — the names the MCP
|
|
219
|
+
server reads — so intents ground on the box with no further setup. A
|
|
220
|
+
box without the Picker leaves those keys alone. The last line of
|
|
221
|
+
`create` is the next step: the complete `bundle push --run --env …
|
|
222
|
+
--collection …` when the project has exactly one environment and one
|
|
223
|
+
collection, the viewer's address plus the command with placeholders
|
|
224
|
+
otherwise.
|
|
225
|
+
- `box access [name]` re-mints the pair and rewrites the two files;
|
|
226
|
+
`box status` and `box destroy` (asks on a terminal, `--yes` elsewhere)
|
|
227
|
+
are thin wrappers. The name defaults to the box `UNOTEST_BOX_URL`
|
|
228
|
+
points at (`acme.box.unotest.com` → `acme`).
|
|
229
|
+
|
|
230
|
+
Cloud-signed box tokens live thirty days. When a box answers that one
|
|
231
|
+
has expired, `bundle push` and the `box …` read commands re-mint the
|
|
232
|
+
pair through the cloud and retry once, provided a cloud login is at
|
|
233
|
+
hand; without one they say to run `login && box access`. The box a
|
|
234
|
+
re-mint is for is the one the project points at, named from its address
|
|
235
|
+
or, when the address spells no name (a stand behind an IP), by the same
|
|
236
|
+
default `create` used — so a lab box re-mints like any other. A `--box`
|
|
237
|
+
naming somewhere else is said to be somewhere else rather than blamed on
|
|
238
|
+
a missing login. A box that has
|
|
239
|
+
not yet received its revocation list is told apart from a real refusal
|
|
240
|
+
and retried; a token the box calls revoked is never re-minted quietly.
|
|
241
|
+
`bundle push` now also reads the push token from `unotest/.secrets`,
|
|
242
|
+
where `box create` puts it (then `unotest/.env`, for suites set up by
|
|
243
|
+
hand), so nothing has to be exported after the setup.
|
|
244
|
+
|
|
245
|
+
The MCP server knows the state too. While the project points at no box,
|
|
246
|
+
`box_envs`, `box_runs`, `box_run` and the other box tools answer
|
|
247
|
+
`{state: "no-box", next, why}` instead of an error, and `run_test`
|
|
248
|
+
carries the same `boxSuggestion` exactly once per server — after the
|
|
249
|
+
first local run that passes — so an agent offers the box at the moment
|
|
250
|
+
it is worth something and never nags. The box tools re-mint an expired
|
|
251
|
+
cloud-signed read token the way the CLI does, through the composition
|
|
252
|
+
root, and `unotest/.secrets` is read on every call: a pair written by
|
|
253
|
+
`box create` while the server runs is used by the next call. On a
|
|
254
|
+
Picker box, `ground_element` (and intent locators) whose picker token
|
|
255
|
+
the grounder refuses re-mint it once through the cloud and hand the new
|
|
256
|
+
token to the running client; a refusal that cannot be mended answers
|
|
257
|
+
`{state: "picker-token-refused", next: "npx @unotest/web box access",
|
|
258
|
+
why}`, and a grounder that has not read its revocation list yet answers
|
|
259
|
+
`{state: "retry-shortly", retryAfterSeconds}`. Box tokens are checked
|
|
260
|
+
before the network: cloud-signed ones by their whole shape (`unos_…`), a
|
|
261
|
+
push token in the read variable (and the reverse) is named as the wrong
|
|
262
|
+
kind, anything else as not a token.
|
|
263
|
+
|
|
264
|
+
A renewal the cloud itself refuses is its own answer, not the cloud's.
|
|
265
|
+
When a box or the grounder says a cloud-signed token is stale and the
|
|
266
|
+
`/access` call that would replace it is refused in turn — no such box,
|
|
267
|
+
the box not ready, too many live pairs, the cloud unreachable — every
|
|
268
|
+
surface says that the credentials could not be renewed and keeps the
|
|
269
|
+
cloud's refusal as the cause. The CLI prints one line and exits with the
|
|
270
|
+
code that refusal earns; the grounding tools answer
|
|
271
|
+
`{state: "picker-token-refused", next, why}` as before, so an intent is
|
|
272
|
+
never answered with a sentence about boxes.
|
|
273
|
+
|
|
274
|
+
`init` ends with one line saying how to run the suite on a box; the
|
|
275
|
+
"mint a read token in the guard" advice in the box commands' refusals
|
|
276
|
+
now points at `login` and `box access` instead. `UNOTEST_CLOUD_URL`
|
|
277
|
+
points the CLI at a stand or a fake cloud; nobody sets it otherwise.
|
|
278
|
+
|
|
279
|
+
`@unotest/protocol`: `replaceEnvVar` (an in-place value replacement the
|
|
280
|
+
new upsert builds on), `BOX_PUSH_TOKEN_PREFIX` / `BOX_SIGNED_TOKEN_PREFIX`
|
|
281
|
+
/ `BOX_SIGNED_TOKEN`, and `UNOTEST_BOX_URL`, `UNOTEST_GROUNDER_MODE`,
|
|
282
|
+
`UNOTEST_GROUNDER_REMOTE_URL` listed among the runner-config keys so they
|
|
283
|
+
file under the Runner section of `unotest/.env`.
|
|
284
|
+
|
|
285
|
+
`@unotest/grounder-client`: `GrounderHttpClientOptions.token` may be a
|
|
286
|
+
function yielding the current bearer; `GrounderRemoteError` carries the
|
|
287
|
+
grounder's `retryAfterSeconds`, and the error codes gain
|
|
288
|
+
`revocation-list-unavailable`.
|
|
289
|
+
|
|
290
|
+
### Patch Changes
|
|
291
|
+
|
|
292
|
+
- d68538f: A box refusing a token now answers with the words the box actually
|
|
293
|
+
sends, and with the step that fits the token you presented.
|
|
294
|
+
|
|
295
|
+
The CLI knew five refusal codes; a box sends three. `expired` still
|
|
296
|
+
re-mints through the cloud and retries once, `revocation-list-unavailable`
|
|
297
|
+
still means the same token, later. Everything a retry cannot fix —
|
|
298
|
+
revoked, bad signature, wrong door — arrives as one `unauthorized`, and
|
|
299
|
+
that is deliberate on the box side: a refusal that names WHICH check
|
|
300
|
+
failed is an oracle. The three codes written ahead of the box and never
|
|
301
|
+
sent by anything are gone.
|
|
302
|
+
|
|
303
|
+
What you see changes in two ways:
|
|
304
|
+
|
|
305
|
+
- the sentence is the box's own reason, never a stock phrase of ours.
|
|
306
|
+
A code this CLI does not know (a box newer than the CLI) reaches you
|
|
307
|
+
with the box's message too, instead of a generic line about a token.
|
|
308
|
+
- the suggested fix follows the KIND of token: a cloud-signed pair is
|
|
309
|
+
re-minted with `npx @unotest/web box access`; a token you minted
|
|
310
|
+
yourself on a box you run is re-minted in that box's guard, because
|
|
311
|
+
there is no cloud behind it to point at.
|
|
312
|
+
|
|
313
|
+
The MCP `box_*` tools return this as a state rather than an error, the
|
|
314
|
+
way they already do for a project with no box: `{state:
|
|
315
|
+
"box-token-refused", next, why}` — `why` is what the box said, `next`
|
|
316
|
+
is the command that fixes it.
|
|
317
|
+
|
|
318
|
+
`pnpm fake:cloud --box-refusal <code>` makes the fake box refuse with
|
|
319
|
+
any code, including one the CLI does not know, so both paths can be
|
|
320
|
+
exercised without a real box.
|
|
321
|
+
|
|
322
|
+
- 565d1b3: Secrets no longer travel with the page capture that grounding reads.
|
|
323
|
+
|
|
324
|
+
`UNOTEST_BOX_SECRET_NAMES` and `unotest/.secrets` masked what the runner
|
|
325
|
+
said out loud — logs, the debug log, the recorded session — but not the
|
|
326
|
+
capture `ground_element` and intent locators send to the grounder, and in
|
|
327
|
+
the hosted mode on to a model provider. Only the field TYPE was filtered
|
|
328
|
+
there: a `password` or `hidden` input was dropped by the page walker,
|
|
329
|
+
while an API token in a plain text input, a one-time code, a card number,
|
|
330
|
+
a whole textarea, a login, or a key the application printed on the page
|
|
331
|
+
went out verbatim.
|
|
332
|
+
|
|
333
|
+
Both modes come through one place now, and it cleans the capture twice
|
|
334
|
+
over before the content hash is taken:
|
|
335
|
+
|
|
336
|
+
- every registered secret value is replaced by `‹secret:NAME›` wherever it
|
|
337
|
+
occurs — a control's value, a text node, an accessible name, the page
|
|
338
|
+
title. Masking is by EXACT value: the same secret written in another
|
|
339
|
+
case is not recognised, deliberately, because matching loosely would
|
|
340
|
+
mask ordinary words and the noise would land in what grounding reads.
|
|
341
|
+
- the page URL and every link keep scheme, host and path; query, fragment
|
|
342
|
+
and `user:pass@` are dropped. That is where a password-reset token, a
|
|
343
|
+
signed link or a session id lives, and no list of known secrets can
|
|
344
|
+
cover those. A link made of nothing BUT a fragment (`#main`) keeps it —
|
|
345
|
+
that fragment is the whole label, and there is no path to name instead;
|
|
346
|
+
a fragment hanging off a real address (`/invite#SECRET`) is dropped like
|
|
347
|
+
any other. `data:`, `blob:` and `javascript:` keep their scheme alone:
|
|
348
|
+
there the document, or the code of a call with its arguments, sits
|
|
349
|
+
where the path would be. `mailto:` and `tel:` stay whole — there the
|
|
350
|
+
path is the addressee, which is a label like any other.
|
|
351
|
+
|
|
352
|
+
The masking is what the grounder is given, so it is also what the content
|
|
353
|
+
hash is taken of — two visits that differ only in a link's one-time token
|
|
354
|
+
are correctly one page to the index cache, not two.
|
|
355
|
+
|
|
356
|
+
The graph's own vocabulary is untouched: `kind`, `tag`, `role`, `ref` and
|
|
357
|
+
`testId` are structure, not content, and a secret whose value happens to
|
|
358
|
+
be `button` no longer has anywhere to do damage. Everything else in a
|
|
359
|
+
capture is masked, including a field added to it later.
|
|
360
|
+
|
|
361
|
+
Loading a secret shorter than six characters now warns, once, naming the
|
|
362
|
+
variable and its length and never its value. Nothing changes about how it
|
|
363
|
+
is masked — the warning exists because masking by value has no notion of
|
|
364
|
+
length, so `admin` as a password also masks the word wherever a page
|
|
365
|
+
writes it, and grounding can get worse on a page that has nothing to do
|
|
366
|
+
with the secret.
|
|
367
|
+
|
|
368
|
+
Still not covered, and worth saying plainly: a value the run was never
|
|
369
|
+
told is a secret. A key the application itself renders on the page — the
|
|
370
|
+
"copy it now" screen — is in no registry, and goes out with the capture.
|
|
371
|
+
|
|
372
|
+
- 32a495e: Failure artifacts survive a run that is isolated from its own artifact root.
|
|
373
|
+
|
|
374
|
+
A run executed inside a container sees only what is mounted into it. The
|
|
375
|
+
failure bundle's directory is derived from the artifact root, which on such
|
|
376
|
+
a run is not one of those mounts — so it could not be created, and a failed
|
|
377
|
+
test produced no screenshot, no trace and no page HTML at all. Worse, that
|
|
378
|
+
one error took the always-on copies in the run directory down with it: both
|
|
379
|
+
sinks shared a single try, so the artifact that does not depend on the
|
|
380
|
+
bundle was lost too.
|
|
381
|
+
|
|
382
|
+
Two changes. `UNOTEST_FAILURES_ROOT` now says where bundles live, for
|
|
383
|
+
whoever spawns the run to point at a directory it can actually write
|
|
384
|
+
(unset, everything behaves exactly as before). And the two sinks are
|
|
385
|
+
independent: an unusable bundle root no longer costs you the run
|
|
386
|
+
directory's screenshot and page HTML.
|
|
387
|
+
|
|
388
|
+
The viewer serves failure assets from either layout, so a bundle written
|
|
389
|
+
inside the environment's runs tree renders like a local one.
|
|
390
|
+
|
|
391
|
+
- 2416abf: `init` no longer rewrites a config the project had already edited, and
|
|
392
|
+
`--help` no longer runs the command it is asking about.
|
|
393
|
+
|
|
394
|
+
Two defects, one outcome. `init` wrote the channel into
|
|
395
|
+
`unotest/unotest.config.mjs` unconditionally at the end of every run, on
|
|
396
|
+
the assumption that the file was the placeholder it had written seconds
|
|
397
|
+
earlier. In a project that had run `init` before, that file is the
|
|
398
|
+
project's own: it was replaced by the template with no `--force`, no
|
|
399
|
+
warning, and an `already present` line printed about it. And because the
|
|
400
|
+
option loop ignored anything it did not recognise, `init --help` — the
|
|
401
|
+
first thing a person types when a command is unfamiliar — fell through
|
|
402
|
+
into a real run in the current directory, scaffolding the layout and
|
|
403
|
+
taking the config with it.
|
|
404
|
+
|
|
405
|
+
What changes:
|
|
406
|
+
|
|
407
|
+
- an existing file stays exactly as it is; the channel is patched only
|
|
408
|
+
into a config this run created itself. A re-run reports `already
|
|
409
|
+
present` and changes nothing.
|
|
410
|
+
- `--force` still takes the templates, and now keeps what it replaced
|
|
411
|
+
next to it as `<file>.bak`. Being overruled is not the same as losing
|
|
412
|
+
the work.
|
|
413
|
+
- the package-owned copies `init` keeps in sync — the test-authoring
|
|
414
|
+
skill, the agent-trace hook — are still synced without `--force`, but a
|
|
415
|
+
copy that diverged is now kept as `<file>.bak` too. "Nobody edits
|
|
416
|
+
these" is an assumption about a file in your repository, and it costs
|
|
417
|
+
nothing to be wrong about it safely.
|
|
418
|
+
- `--help` / `-h` works on every command, prints the usage, and does
|
|
419
|
+
nothing else — answered before the workspace is even resolved, so a
|
|
420
|
+
question touches no files anywhere.
|
|
421
|
+
- an option a command does not recognise is refused with its usage and
|
|
422
|
+
exit **2**, instead of being dropped silently. `init` reads its options
|
|
423
|
+
against the list of what it knows, so the next flag added cannot fall
|
|
424
|
+
through the same hole.
|
|
425
|
+
|
|
426
|
+
- 9daaaf7: `init` writes files that pass the project's own `prettier --check`.
|
|
427
|
+
|
|
428
|
+
The block `init` maintains in `CLAUDE.md` now has a blank line between
|
|
429
|
+
each marker and the text inside, and the JSON it writes — `.mcp.json`,
|
|
430
|
+
`.claude/settings.json` — keeps a short array on one line (`"allow":
|
|
431
|
+
["mcp__unotest-web"]`) instead of one element per line. Both are what
|
|
432
|
+
prettier produces, so a repository whose CI runs `format:check` no longer
|
|
433
|
+
goes red on files it did not write itself. A file `init` merges into is
|
|
434
|
+
re-emitted by the same rules, including the entries the user had there.
|
|
435
|
+
|
|
436
|
+
`AGENTS.md` too: its DSL examples now sit in fences prettier does not
|
|
437
|
+
reformat, and every list has the blank line before it that prettier's
|
|
438
|
+
Markdown rules want.
|
|
439
|
+
|
|
440
|
+
- 50b1d4d: The search text sent for grounding is masked the same way the page
|
|
441
|
+
capture already is.
|
|
442
|
+
|
|
443
|
+
Masking the capture cleaned what the PAGE said. The other half of a
|
|
444
|
+
grounding call — the intent, the thing being searched for — went out
|
|
445
|
+
untouched: to the grounding service, into the picker's prompt and the
|
|
446
|
+
embedder's query, and in the hosted mode on to a model provider. A
|
|
447
|
+
scenario that puts a variable into the search (`intent("the row for " +
|
|
448
|
+
LOGIN)`) sent its value verbatim, while the same value was masked
|
|
449
|
+
everywhere else in the run.
|
|
450
|
+
|
|
451
|
+
Now every registered secret is replaced by `‹secret:NAME›` across the
|
|
452
|
+
whole intent, not only its text: the predicate's field description and
|
|
453
|
+
its value too, which is where a value is most literally the thing being
|
|
454
|
+
searched for. Only the structural keys are kept — the comparison
|
|
455
|
+
operator, the kind of unit wanted back, the kind of ordinal — so a field
|
|
456
|
+
added to the contract later is masked by default rather than forgotten.
|
|
457
|
+
|
|
458
|
+
A numeric secret is masked as well, and only on an exact match: a
|
|
459
|
+
one-time code arrives as a number, and a number has no text for a value
|
|
460
|
+
to hide inside, so `447213` masks while `4472130` and every ordinary
|
|
461
|
+
quantity are left alone.
|
|
462
|
+
|
|
463
|
+
This happens in one place for both grounding modes — local and remote —
|
|
464
|
+
so a value cannot escape through whichever mode a project runs.
|
|
465
|
+
|
|
466
|
+
The honest cost, because it is real: masking is by exact value. Where the
|
|
467
|
+
page rendered the same secret in another form — a different case, a
|
|
468
|
+
percent-encoded path, a value split by an invisible character — the mask
|
|
469
|
+
now lands on the intent alone, and the correspondence between what you
|
|
470
|
+
searched for and what the page shows is lost for that one case.
|
|
471
|
+
Grounding can get worse there. It is the same boundary already stated
|
|
472
|
+
about masking generally: the exact value is what is recognised, not the
|
|
473
|
+
secret "in any form".
|
|
474
|
+
|
|
475
|
+
- Updated dependencies [32a495e]
|
|
476
|
+
- Updated dependencies [c6b91bb]
|
|
477
|
+
|
|
478
|
+
- @unotest/protocol@0.34.0
|
|
479
|
+
- @unotest/viewer@0.34.0
|
|
480
|
+
- @unotest/grounder-client@0.34.0
|
|
481
|
+
- @unotest/core@0.34.0
|
|
482
|
+
- @unotest/dsl@0.34.0
|
|
483
|
+
|
|
484
|
+
- Named late, and it should have been named in 0.33.0: on a box the pushed
|
|
485
|
+
suite is mounted **read-only**. Since run isolation (0.33.0) a run sees
|
|
486
|
+
its bundle at `/box/sources` and cannot write next to its own files —
|
|
487
|
+
a `prepare` script that seeded a database beside the fixtures, or a
|
|
488
|
+
helper that kept a scratch file under `unotest/.tmp`, now fails with
|
|
489
|
+
`EROFS` / `ENOENT` before a single scenario runs. The places a run may
|
|
490
|
+
write are `/tmp` (the container's own, gone with the run) and the run's
|
|
491
|
+
artifact directory under `UNOTEST_ARTIFACTS_ROOT`; `os.tmpdir()` in
|
|
492
|
+
Node and `$TMPDIR` in a shell both land in the former. Migration for a
|
|
493
|
+
suite that wrote beside its sources: pick the scratch directory by
|
|
494
|
+
whether `UNOTEST_ARTIFACTS_ROOT` is set — `unotest/.tmp` locally,
|
|
495
|
+
`/tmp/<suite>` on a box — and point the seed, the fixture and the
|
|
496
|
+
scenario at the same helper. Our own dogfood suite failed on exactly
|
|
497
|
+
this for a day.
|
|
498
|
+
|
|
3
499
|
## [0.33.0] - 2026-09-06
|
|
4
500
|
|
|
5
501
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -134,6 +134,56 @@ bundle, so if your config computes `schedules` from its environment, the
|
|
|
134
134
|
box notices the difference and says so instead of silently arming
|
|
135
135
|
another set.
|
|
136
136
|
|
|
137
|
+
### Run on a box
|
|
138
|
+
|
|
139
|
+
Three commands, no token to copy:
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
npx @unotest/web login # a link and a code; approve in the browser
|
|
143
|
+
npx @unotest/web box create # rents a box named after the project, size S
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`login` signs this machine in to unotest cloud — the account that rents
|
|
147
|
+
boxes — and keeps the key in `~/.config/unotest/credentials.json`
|
|
148
|
+
(mode 0600). `box create` creates the box with the environments the
|
|
149
|
+
suite already runs locally (each `unotest/.env.<name>` and its
|
|
150
|
+
`APP_BASE_URL`), asks for a top-up when the balance is short (it prints
|
|
151
|
+
the Checkout link and waits for the payment), waits for the box, writes
|
|
152
|
+
`UNOTEST_BOX_URL` to `unotest/.env` and the push and read tokens to
|
|
153
|
+
`unotest/.secrets`, pushes the suite and prints the viewer's address.
|
|
154
|
+
The tokens live thirty days; `bundle push` and `box …` re-mint them
|
|
155
|
+
through the cloud when a box says they have expired, and `box access`
|
|
156
|
+
does it on demand. `box status` and `box destroy` do what they say;
|
|
157
|
+
`whoami` and `logout` complete the set. In CI, export
|
|
158
|
+
`UNOTEST_CLOUD_TOKEN` instead of signing in.
|
|
159
|
+
|
|
160
|
+
The **Picker** — grounding intent locators on the box's grounder — comes
|
|
161
|
+
with the box today: it lists at €0.05–€0.13 an hour depending on size,
|
|
162
|
+
and that is struck through, nothing is added to the bill. It is still
|
|
163
|
+
never switched on quietly, because that will not always be true.
|
|
164
|
+
`box create --picker` rents the box with it; without the flag a terminal
|
|
165
|
+
is asked once, with the price read from the cloud's own list — the CLI
|
|
166
|
+
has no price of its own — and the answer defaults to no. On a box you already have, `box picker on` and
|
|
167
|
+
`box picker off` switch it without re-creating anything: `on` writes
|
|
168
|
+
`UNOTEST_GROUNDER_MODE=remote` and `UNOTEST_GROUNDER_REMOTE_URL` to
|
|
169
|
+
`unotest/.env` and `UNOTEST_GROUNDER_REMOTE_TOKEN` to `unotest/.secrets`,
|
|
170
|
+
`off` clears the three again — unless they name a grounder that is not
|
|
171
|
+
this box's, which is left alone and reported. `off` also revokes the
|
|
172
|
+
box's Picker token, so a copy of it on another machine stops working
|
|
173
|
+
too: the grounder refuses it once it has re-read the operator's
|
|
174
|
+
revocation list — how often it does that is the operator's own setting. Either way the charge follows at the
|
|
175
|
+
next hourly tick, because a started hour is charged at the rate it
|
|
176
|
+
started with. `box status` says which state a box is in, and — while the
|
|
177
|
+
Picker is on — which models the cloud grounds with: `Our GPU` or
|
|
178
|
+
`External provider (Gemini)`. That is our grounder, not your
|
|
179
|
+
`UNOTEST_GROUNDER_MODE`; a cloud that has not heard from its grounder
|
|
180
|
+
prints nothing rather than a guess.
|
|
181
|
+
|
|
182
|
+
An agent can drive all of it without a terminal: no prompt is shown
|
|
183
|
+
without a TTY, and the exit codes say what happened — 75 the payment was
|
|
184
|
+
not confirmed in time, 76 the terms of service need accepting (a person
|
|
185
|
+
must, in a browser), 77 not signed in.
|
|
186
|
+
|
|
137
187
|
### Sending the suite to a box
|
|
138
188
|
|
|
139
189
|
If your team runs a box (a machine that keeps the environments, the
|
|
@@ -247,11 +297,13 @@ started by your editor, so a token that only exists in your shell is a
|
|
|
247
297
|
token the agent never sees. A token added while the server is running is
|
|
248
298
|
picked up on the next call — nothing to restart.
|
|
249
299
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
300
|
+
With a box rented through `box create`, the token is minted for you —
|
|
301
|
+
`box access` re-mints it — and lives thirty days. On a box set up by
|
|
302
|
+
hand you mint it for yourself on the box's guard, where it is shown once
|
|
303
|
+
and can be revoked. Either way it is read-only whatever your role on the
|
|
304
|
+
box — it cannot start a run or change a value. `UNOTEST_BOX_TOKEN` is the
|
|
305
|
+
project's push credential and cannot read runs. `box envs` lists the
|
|
306
|
+
environments a token may look at.
|
|
255
307
|
|
|
256
308
|
## 5. Watch it run — the viewer
|
|
257
309
|
|