@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 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.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.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.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. */