@fusion.dev/helix-cli 0.195.0 → 0.196.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/package.json +10 -6
- package/visual/README.md +36 -1
- package/visual/driver.mjs +162 -0
- package/visual/index.d.ts +17 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fusion.dev/helix-cli",
|
|
3
3
|
"description": "The Helix command line, for a workspace that reaches it through npm, and the visual tests it runs.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.196.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ross Sheppard <ross.sheppard@fusion.dev>",
|
|
7
7
|
"repository": {
|
|
@@ -41,16 +41,20 @@
|
|
|
41
41
|
"visual-regression"
|
|
42
42
|
],
|
|
43
43
|
"optionalDependencies": {
|
|
44
|
-
"@fusion.dev/helix-cli-darwin-arm64": "0.
|
|
45
|
-
"@fusion.dev/helix-cli-darwin-x64": "0.
|
|
46
|
-
"@fusion.dev/helix-cli-linux-arm64": "0.
|
|
47
|
-
"@fusion.dev/helix-cli-linux-x64": "0.
|
|
48
|
-
"@fusion.dev/helix-cli-win32-x64": "0.
|
|
44
|
+
"@fusion.dev/helix-cli-darwin-arm64": "0.196.0",
|
|
45
|
+
"@fusion.dev/helix-cli-darwin-x64": "0.196.0",
|
|
46
|
+
"@fusion.dev/helix-cli-linux-arm64": "0.196.0",
|
|
47
|
+
"@fusion.dev/helix-cli-linux-x64": "0.196.0",
|
|
48
|
+
"@fusion.dev/helix-cli-win32-x64": "0.196.0"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
+
"@anthropic-ai/sdk": ">=0.132",
|
|
51
52
|
"playwright": ">=1.50"
|
|
52
53
|
},
|
|
53
54
|
"peerDependenciesMeta": {
|
|
55
|
+
"@anthropic-ai/sdk": {
|
|
56
|
+
"optional": true
|
|
57
|
+
},
|
|
54
58
|
"playwright": {
|
|
55
59
|
"optional": true
|
|
56
60
|
}
|
package/visual/README.md
CHANGED
|
@@ -24,8 +24,10 @@ rest of `helix` needs no browser, so Playwright is an optional peer, and
|
|
|
24
24
|
helix visual test compare every shot, and fail on a difference
|
|
25
25
|
helix visual test button only the entries named button
|
|
26
26
|
helix visual update write only the baselines the comparison rejects
|
|
27
|
+
helix visual update --boxes also write element boxes into baselines that have none
|
|
27
28
|
helix visual test --shard 0 --of 4 the first of four shards, for CI
|
|
28
29
|
helix visual report open the report of the last run
|
|
30
|
+
helix visual report --merge a b the groups of several shards' reports as one
|
|
29
31
|
helix visual compare a.png b.png compare two PNG files, with no browser
|
|
30
32
|
```
|
|
31
33
|
|
|
@@ -35,8 +37,15 @@ helix visual compare a.png b.png compare two PNG files, with no browser
|
|
|
35
37
|
named `<stem><suffix>.diff.png`. Add `*.diff.png` to your `.gitignore`.
|
|
36
38
|
- **`helix visual update`** pictures every entry and writes a baseline only where the
|
|
37
39
|
comparison would fail. Review the files it writes before you commit them.
|
|
40
|
+
`--boxes` also writes the element boxes into each matching baseline that has
|
|
41
|
+
none, inside its own PNG, leaving its pixels as they are, so the run can say
|
|
42
|
+
how far a changed element moved.
|
|
38
43
|
- **`helix visual report`** opens the report the last run wrote. `--markdown` prints
|
|
39
|
-
its summary as Markdown instead, for a CI job's summary page.
|
|
44
|
+
its summary as Markdown instead, for a CI job's summary page. `--merge`
|
|
45
|
+
takes the reports of a run's shards, each a report directory or its
|
|
46
|
+
`run.json`, and prints their changes as one set of groups, so a change
|
|
47
|
+
spread across shards is one group. With a `triage` in the config, a merge
|
|
48
|
+
asks about each group once, reading the diff since `--changed`.
|
|
40
49
|
- **`helix visual compare`** compares two PNG files. `--diff` writes the image of the
|
|
41
50
|
difference, and `--threshold` sets how far apart two colors may be and still
|
|
42
51
|
count as the same pixel, from 0 to 1.
|
|
@@ -137,6 +146,27 @@ export default defineConfig({
|
|
|
137
146
|
covers the region, and the file a manifest says defines it. A baseline
|
|
138
147
|
carries its elements' boxes inside its PNG once a run writes it, and from
|
|
139
148
|
then on the run also says how far each element moved.
|
|
149
|
+
- **`triage`**: a model that explains each group of changes a failed run
|
|
150
|
+
found, as intended, unintended, or unclear, with a sentence on why. It
|
|
151
|
+
never approves a baseline: the answers are the order to review in. Off when
|
|
152
|
+
not given, and then no run makes a network call.
|
|
153
|
+
- `provider: 'anthropic'`, the default, asks Claude through
|
|
154
|
+
`@fusion.dev/helix-cli`'s optional peer `@anthropic-ai/sdk`, with the
|
|
155
|
+
key the SDK finds, `ANTHROPIC_API_KEY` among them. `model` is
|
|
156
|
+
`claude-opus-5-5` unless you name another.
|
|
157
|
+
- `provider: 'ollama'` asks a model on an Ollama endpoint, `url`,
|
|
158
|
+
`http://127.0.0.1:11434` unless you name another, so nothing leaves your
|
|
159
|
+
machine. `model` names what you pulled.
|
|
160
|
+
- Only words are sent: the attribution, the accessibility difference, the
|
|
161
|
+
diff of the attributed files since the run's `--changed` ref, and the
|
|
162
|
+
names of every file changed since it.
|
|
163
|
+
`images: true` also sends the baseline and the capture of each group's
|
|
164
|
+
largest region, cropped and no wider than 512 pixels.
|
|
165
|
+
- A run split into shards leaves the triage to `helix visual report
|
|
166
|
+
--merge`, which asks once about a group the shards share.
|
|
167
|
+
- `groups`, 10 unless you set it, caps the requests a run makes, the
|
|
168
|
+
largest group first. A run with no differences makes none, and the last
|
|
169
|
+
line says how many tokens each run read and wrote.
|
|
140
170
|
- **`diff`**: how a shot is compared, for the whole config or for one source.
|
|
141
171
|
`mode: 'pixel'` counts the pixels past `threshold`, 0.1 unless you set it,
|
|
142
172
|
and fails above `pixels`, 0 unless you set it. `mode: 'perceptual'` scores
|
|
@@ -210,6 +240,11 @@ picture is committed.
|
|
|
210
240
|
scheme, and the harness style change between shots on the same page.
|
|
211
241
|
- **A difference is taken again from a fresh page load.** A shot that matches
|
|
212
242
|
on the second take passes, and the run names it.
|
|
243
|
+
- **Changes are grouped by what they happened to.** Each region of a shot
|
|
244
|
+
that differs is traced to the element under it, and shots whose changes
|
|
245
|
+
read the same, apart from where they are and each element's `id`, are one
|
|
246
|
+
group: a token change that moved one kind of element across 300 pictures is
|
|
247
|
+
one group of 300. No model is involved.
|
|
213
248
|
- **Requests stay on the harness origin.** Every request to another host is
|
|
214
249
|
refused, so a baseline never records what another server returned.
|
|
215
250
|
`network: 'open'` turns that off.
|
package/visual/driver.mjs
CHANGED
|
@@ -762,6 +762,65 @@ const handlers = {
|
|
|
762
762
|
say({ browser: `chromium ${state.browser.version()}`, op: 'started' })
|
|
763
763
|
},
|
|
764
764
|
|
|
765
|
+
/* **Each group asked in turn, and an answer that fails is one group's
|
|
766
|
+
failure.** A key that is missing or refused fails every request alike,
|
|
767
|
+
so the first such failure is the answer for the rest. */
|
|
768
|
+
async triage({ groups, model, provider, system, url }) {
|
|
769
|
+
const settings = { model, system, url }
|
|
770
|
+
const answers = []
|
|
771
|
+
let stopped = null
|
|
772
|
+
let client = null
|
|
773
|
+
if (provider === 'anthropic') {
|
|
774
|
+
try {
|
|
775
|
+
client = new (await anthropic())()
|
|
776
|
+
} catch (caught) {
|
|
777
|
+
stopped = caught instanceof Error ? caught.message.split('\n')[0] : String(caught)
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
for (const group of groups) {
|
|
781
|
+
if (stopped !== null) {
|
|
782
|
+
answers.push({ error: stopped })
|
|
783
|
+
continue
|
|
784
|
+
}
|
|
785
|
+
try {
|
|
786
|
+
const answer =
|
|
787
|
+
provider === 'anthropic'
|
|
788
|
+
? await askClaude(client, settings, group)
|
|
789
|
+
: await askOllama(settings, group)
|
|
790
|
+
answers.push(answer)
|
|
791
|
+
} catch (caught) {
|
|
792
|
+
/* The API's own sentence when it gave one, without the request id
|
|
793
|
+
that makes every failure's text unique. */
|
|
794
|
+
const said = caught?.error?.error?.message
|
|
795
|
+
const why =
|
|
796
|
+
typeof said === 'string'
|
|
797
|
+
? `${caught.status}: ${said}`
|
|
798
|
+
: caught instanceof Error
|
|
799
|
+
? caught.message.split('\n')[0]
|
|
800
|
+
: String(caught)
|
|
801
|
+
answers.push({ error: why })
|
|
802
|
+
/* A refused key, an endpoint that is down, or the same failure
|
|
803
|
+
twice running, a balance that has run out among them, is one that
|
|
804
|
+
every request after it would meet. */
|
|
805
|
+
const before = answers.at(-2)
|
|
806
|
+
if (
|
|
807
|
+
caught?.status === 401 ||
|
|
808
|
+
caught?.status === 403 ||
|
|
809
|
+
caught?.code === 'ECONNREFUSED' ||
|
|
810
|
+
before?.error === why
|
|
811
|
+
) {
|
|
812
|
+
stopped = why
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
/* A failure that stopped every request is said once, for the run. */
|
|
817
|
+
say({
|
|
818
|
+
answers,
|
|
819
|
+
op: 'triaged',
|
|
820
|
+
stopped: answers.every((one) => one.error === stopped) ? stopped : null,
|
|
821
|
+
})
|
|
822
|
+
},
|
|
823
|
+
|
|
765
824
|
/* Polls until anything answers, a refusal of the connection being the one
|
|
766
825
|
thing that means nothing is listening yet. */
|
|
767
826
|
async wait({ seconds, url }) {
|
|
@@ -803,6 +862,109 @@ const playwright = async () => {
|
|
|
803
862
|
}
|
|
804
863
|
}
|
|
805
864
|
|
|
865
|
+
/**
|
|
866
|
+
* The Anthropic SDK, or a failure that says how to install it: an optional
|
|
867
|
+
* peer for the same reason Playwright is, needed only by a config that asks
|
|
868
|
+
* for a triage.
|
|
869
|
+
*/
|
|
870
|
+
const anthropic = async () => {
|
|
871
|
+
try {
|
|
872
|
+
return (await import('@anthropic-ai/sdk')).default
|
|
873
|
+
} catch (error) {
|
|
874
|
+
if (
|
|
875
|
+
error?.code !== 'ERR_MODULE_NOT_FOUND' ||
|
|
876
|
+
!String(error.message).includes("'@anthropic-ai/sdk'")
|
|
877
|
+
) {
|
|
878
|
+
throw error
|
|
879
|
+
}
|
|
880
|
+
throw new Error(
|
|
881
|
+
'the config asks for a triage by Claude, through the Anthropic SDK, which is not installed. Install it with `npm install --save-dev @anthropic-ai/sdk`.',
|
|
882
|
+
)
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** The answer every triage request is held to. */
|
|
887
|
+
const VERDICT = {
|
|
888
|
+
additionalProperties: false,
|
|
889
|
+
properties: {
|
|
890
|
+
explanation: { type: 'string' },
|
|
891
|
+
verdict: { enum: ['intended', 'unintended', 'unclear'], type: 'string' },
|
|
892
|
+
},
|
|
893
|
+
required: ['verdict', 'explanation'],
|
|
894
|
+
type: 'object',
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* One group asked of Claude: the words and any pictures, answered as JSON
|
|
899
|
+
* held to `VERDICT`. A request a safety classifier declines goes to the
|
|
900
|
+
* fallback model Anthropic routes it to, and one declined there too is an
|
|
901
|
+
* answer of its own, not a failure of the run.
|
|
902
|
+
*/
|
|
903
|
+
const askClaude = async (client, { model, system }, { images, text }) => {
|
|
904
|
+
const response = await client.beta.messages.create({
|
|
905
|
+
betas: ['server-side-fallback-2026-07-01'],
|
|
906
|
+
fallbacks: 'default',
|
|
907
|
+
max_tokens: 16000,
|
|
908
|
+
messages: [
|
|
909
|
+
{
|
|
910
|
+
content: [
|
|
911
|
+
...images.map((data) => ({
|
|
912
|
+
source: { data, media_type: 'image/png', type: 'base64' },
|
|
913
|
+
type: 'image',
|
|
914
|
+
})),
|
|
915
|
+
{ text, type: 'text' },
|
|
916
|
+
],
|
|
917
|
+
role: 'user',
|
|
918
|
+
},
|
|
919
|
+
],
|
|
920
|
+
model,
|
|
921
|
+
output_config: { effort: 'medium', format: { schema: VERDICT, type: 'json_schema' } },
|
|
922
|
+
system,
|
|
923
|
+
})
|
|
924
|
+
const usage = response.usage ?? {}
|
|
925
|
+
const input =
|
|
926
|
+
(usage.input_tokens ?? 0) +
|
|
927
|
+
(usage.cache_creation_input_tokens ?? 0) +
|
|
928
|
+
(usage.cache_read_input_tokens ?? 0)
|
|
929
|
+
const output = usage.output_tokens ?? 0
|
|
930
|
+
if (response.stop_reason === 'refusal') {
|
|
931
|
+
return { error: 'the model declined to answer', input, output }
|
|
932
|
+
}
|
|
933
|
+
const words = response.content.find((block) => block.type === 'text')?.text ?? ''
|
|
934
|
+
return { ...JSON.parse(words), input, output }
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* One group asked of a model on an Ollama endpoint, with the same answer
|
|
939
|
+
* asked for through its `format`.
|
|
940
|
+
*/
|
|
941
|
+
const askOllama = async ({ model, system, url }, { images, text }) => {
|
|
942
|
+
const answer = await fetch(`${(url ?? 'http://127.0.0.1:11434').replace(/\/+$/, '')}/api/chat`, {
|
|
943
|
+
body: JSON.stringify({
|
|
944
|
+
format: VERDICT,
|
|
945
|
+
messages: [
|
|
946
|
+
{ content: system, role: 'system' },
|
|
947
|
+
{ content: text, images, role: 'user' },
|
|
948
|
+
],
|
|
949
|
+
model,
|
|
950
|
+
stream: false,
|
|
951
|
+
}),
|
|
952
|
+
headers: { 'content-type': 'application/json' },
|
|
953
|
+
method: 'POST',
|
|
954
|
+
})
|
|
955
|
+
if (!answer.ok) {
|
|
956
|
+
throw new Error(
|
|
957
|
+
`the endpoint answered ${answer.status}: ${(await answer.text()).slice(0, 200)}`,
|
|
958
|
+
)
|
|
959
|
+
}
|
|
960
|
+
const body = await answer.json()
|
|
961
|
+
return {
|
|
962
|
+
...JSON.parse(body.message?.content ?? '{}'),
|
|
963
|
+
input: body.prompt_eval_count ?? 0,
|
|
964
|
+
output: body.eval_count ?? 0,
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
806
968
|
const input = Buffer.alloc(1 << 16)
|
|
807
969
|
const decoder = new StringDecoder('utf8')
|
|
808
970
|
let pending = ''
|
package/visual/index.d.ts
CHANGED
|
@@ -392,6 +392,23 @@ export interface Config {
|
|
|
392
392
|
style?: string
|
|
393
393
|
/** A file to write the seconds each entry name took, relative to the config. */
|
|
394
394
|
timings?: string
|
|
395
|
+
/**
|
|
396
|
+
* A model that explains each group of changes a failed run found, as intended, unintended or unclear. It
|
|
397
|
+
* never approves a baseline. Off when not given, and then no run makes a network call. Only words are sent
|
|
398
|
+
* unless `images` is `true`.
|
|
399
|
+
*/
|
|
400
|
+
triage?: {
|
|
401
|
+
/** How many groups are asked about, the largest first. 10 when not given. */
|
|
402
|
+
groups?: number
|
|
403
|
+
/** Whether the baseline and capture of each group's largest region are sent, cropped. */
|
|
404
|
+
images?: boolean
|
|
405
|
+
/** The model. `claude-opus-5-5` for `anthropic` when not given; required for `ollama`. */
|
|
406
|
+
model?: string
|
|
407
|
+
/** `anthropic`, through the optional peer `@anthropic-ai/sdk`, or `ollama`, on an endpoint of your own. */
|
|
408
|
+
provider?: 'anthropic' | 'ollama'
|
|
409
|
+
/** The Ollama endpoint. `http://127.0.0.1:11434` when not given. */
|
|
410
|
+
url?: string
|
|
411
|
+
}
|
|
395
412
|
}
|
|
396
413
|
|
|
397
414
|
/** Returns the config unchanged, typed. */
|