@fusion.dev/helix-cli 0.195.0 → 0.197.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 +86 -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.197.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.197.0",
|
|
45
|
+
"@fusion.dev/helix-cli-darwin-x64": "0.197.0",
|
|
46
|
+
"@fusion.dev/helix-cli-linux-arm64": "0.197.0",
|
|
47
|
+
"@fusion.dev/helix-cli-linux-x64": "0.197.0",
|
|
48
|
+
"@fusion.dev/helix-cli-win32-x64": "0.197.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,11 @@ 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
|
|
31
|
+
helix visual report --history runs what earlier runs' records say
|
|
29
32
|
helix visual compare a.png b.png compare two PNG files, with no browser
|
|
30
33
|
```
|
|
31
34
|
|
|
@@ -35,8 +38,20 @@ helix visual compare a.png b.png compare two PNG files, with no browser
|
|
|
35
38
|
named `<stem><suffix>.diff.png`. Add `*.diff.png` to your `.gitignore`.
|
|
36
39
|
- **`helix visual update`** pictures every entry and writes a baseline only where the
|
|
37
40
|
comparison would fail. Review the files it writes before you commit them.
|
|
41
|
+
`--boxes` also writes the element boxes into each matching baseline that has
|
|
42
|
+
none, inside its own PNG, leaving its pixels as they are, so the run can say
|
|
43
|
+
how far a changed element moved.
|
|
38
44
|
- **`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.
|
|
45
|
+
its summary as Markdown instead, for a CI job's summary page. `--merge`
|
|
46
|
+
takes the reports of a run's shards, each a report directory or its
|
|
47
|
+
`run.json`, and prints their changes as one set of groups, so a change
|
|
48
|
+
spread across shards is one group. With a `triage` in the config, a merge
|
|
49
|
+
asks about each group once, reading the diff since `--changed`.
|
|
50
|
+
`--history` takes the records of earlier runs, each a `run.json` or a
|
|
51
|
+
directory holding any number, and names the shots that needed a second
|
|
52
|
+
take in more than one run or that differed and matched on one commit, and
|
|
53
|
+
the changes that failed on more than one branch. With `--merge`, each group
|
|
54
|
+
also names the other branches it failed on lately.
|
|
40
55
|
- **`helix visual compare`** compares two PNG files. `--diff` writes the image of the
|
|
41
56
|
difference, and `--threshold` sets how far apart two colors may be and still
|
|
42
57
|
count as the same pixel, from 0 to 1.
|
|
@@ -137,6 +152,27 @@ export default defineConfig({
|
|
|
137
152
|
covers the region, and the file a manifest says defines it. A baseline
|
|
138
153
|
carries its elements' boxes inside its PNG once a run writes it, and from
|
|
139
154
|
then on the run also says how far each element moved.
|
|
155
|
+
- **`triage`**: a model that explains each group of changes a failed run
|
|
156
|
+
found, as intended, unintended, or unclear, with a sentence on why. It
|
|
157
|
+
never approves a baseline: the answers are the order to review in. Off when
|
|
158
|
+
not given, and then no run makes a network call.
|
|
159
|
+
- `provider: 'anthropic'`, the default, asks Claude through
|
|
160
|
+
`@fusion.dev/helix-cli`'s optional peer `@anthropic-ai/sdk`, with the
|
|
161
|
+
key the SDK finds, `ANTHROPIC_API_KEY` among them. `model` is
|
|
162
|
+
`claude-opus-5-5` unless you name another.
|
|
163
|
+
- `provider: 'ollama'` asks a model on an Ollama endpoint, `url`,
|
|
164
|
+
`http://127.0.0.1:11434` unless you name another, so nothing leaves your
|
|
165
|
+
machine. `model` names what you pulled.
|
|
166
|
+
- Only words are sent: the attribution, the accessibility difference, the
|
|
167
|
+
diff of the attributed files since the run's `--changed` ref, and the
|
|
168
|
+
names of every file changed since it.
|
|
169
|
+
`images: true` also sends the baseline and the capture of each group's
|
|
170
|
+
largest region, cropped and no wider than 512 pixels.
|
|
171
|
+
- A run split into shards leaves the triage to `helix visual report
|
|
172
|
+
--merge`, which asks once about a group the shards share.
|
|
173
|
+
- `groups`, 10 unless you set it, caps the requests a run makes, the
|
|
174
|
+
largest group first. A run with no differences makes none, and the last
|
|
175
|
+
line says how many tokens each run read and wrote.
|
|
140
176
|
- **`diff`**: how a shot is compared, for the whole config or for one source.
|
|
141
177
|
`mode: 'pixel'` counts the pixels past `threshold`, 0.1 unless you set it,
|
|
142
178
|
and fails above `pixels`, 0 unless you set it. `mode: 'perceptual'` scores
|
|
@@ -210,6 +246,11 @@ picture is committed.
|
|
|
210
246
|
scheme, and the harness style change between shots on the same page.
|
|
211
247
|
- **A difference is taken again from a fresh page load.** A shot that matches
|
|
212
248
|
on the second take passes, and the run names it.
|
|
249
|
+
- **Changes are grouped by what they happened to.** Each region of a shot
|
|
250
|
+
that differs is traced to the element under it, and shots whose changes
|
|
251
|
+
read the same, apart from where they are and each element's `id`, are one
|
|
252
|
+
group: a token change that moved one kind of element across 300 pictures is
|
|
253
|
+
one group of 300. No model is involved.
|
|
213
254
|
- **Requests stay on the harness origin.** Every request to another host is
|
|
214
255
|
refused, so a baseline never records what another server returned.
|
|
215
256
|
`network: 'open'` turns that off.
|
|
@@ -254,6 +295,50 @@ The Action lives in the Fusion repository, in `helix/visual/action`.
|
|
|
254
295
|
`helix` and `playwright` name the commands that run each,
|
|
255
296
|
`npx --no-install helix` and `npx --no-install playwright` unless you set them.
|
|
256
297
|
|
|
298
|
+
- **Every check keeps its run record**, the report's `run.json`, as an
|
|
299
|
+
artifact named `visual-run-<job>-<index>` for `keep` days, 30 unless you set
|
|
300
|
+
it. Each record names the commit and the branch it ran on.
|
|
301
|
+
- **`history: 30`** reads the records of the last 30 runs of the workflow and
|
|
302
|
+
adds what they say to the summary. It reads them with the workflow's token,
|
|
303
|
+
so the job needs `permissions: actions: read`. Nothing is stored outside
|
|
304
|
+
your Actions artifacts.
|
|
305
|
+
|
|
306
|
+
## Review on the pull request
|
|
307
|
+
|
|
308
|
+
A workflow can let a reviewer retake the entries they have looked at with a
|
|
309
|
+
comment, using only the retake workflow you already run. This one takes
|
|
310
|
+
`/retake styled/badge site/home` from anyone who can push, on a branch of the
|
|
311
|
+
repository, and dispatches the retake there:
|
|
312
|
+
|
|
313
|
+
```yaml
|
|
314
|
+
name: Retake on request
|
|
315
|
+
on:
|
|
316
|
+
issue_comment:
|
|
317
|
+
types: [created]
|
|
318
|
+
permissions:
|
|
319
|
+
actions: write
|
|
320
|
+
contents: read
|
|
321
|
+
pull-requests: write
|
|
322
|
+
jobs:
|
|
323
|
+
retake:
|
|
324
|
+
if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/retake') && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) }}
|
|
325
|
+
runs-on: ubuntu-latest
|
|
326
|
+
steps:
|
|
327
|
+
- env:
|
|
328
|
+
BODY: ${{ github.event.comment.body }}
|
|
329
|
+
GH_TOKEN: ${{ github.token }}
|
|
330
|
+
NUMBER: ${{ github.event.issue.number }}
|
|
331
|
+
run: |
|
|
332
|
+
head=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$NUMBER" --jq .head.ref)
|
|
333
|
+
entries=$(head -n 1 <<<"$BODY" | sed 's#^/retake##' | tr -s ' ' '\n' | grep -E '^[a-z0-9-]+(/[a-z0-9-]+)?$' | tr '\n' ' ')
|
|
334
|
+
gh workflow run visual-baselines.yml --ref "$head" -f entries="$entries"
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
It dispatches a `visual-baselines.yml` that runs the Action with
|
|
338
|
+
`mode: retake` and passes an `entries` input in `args`. Read the comment from
|
|
339
|
+
the environment, as this does, and never paste it into a command. The retake opens its own pull request into the branch, which is
|
|
340
|
+
where the pictures are looked at before they land.
|
|
341
|
+
|
|
257
342
|
## How it works
|
|
258
343
|
|
|
259
344
|
- **Local-first.** `helix visual` runs on your machine and in your CI. It needs no
|
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. */
|