@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 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.195.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.195.0",
45
- "@fusion.dev/helix-cli-darwin-x64": "0.195.0",
46
- "@fusion.dev/helix-cli-linux-arm64": "0.195.0",
47
- "@fusion.dev/helix-cli-linux-x64": "0.195.0",
48
- "@fusion.dev/helix-cli-win32-x64": "0.195.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. */