@fusion.dev/helix-cli 0.215.3 → 0.216.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.215.3",
4
+ "version": "0.216.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "bugs": "https://github.com/project-fusion/fusion/issues",
32
32
  "dependencies": {
33
- "@fusion.dev/helix-templates": "0.215.3"
33
+ "@fusion.dev/helix-templates": "0.216.0"
34
34
  },
35
35
  "homepage": "https://fusion.dev",
36
36
  "keywords": [
@@ -44,11 +44,11 @@
44
44
  "visual-regression"
45
45
  ],
46
46
  "optionalDependencies": {
47
- "@fusion.dev/helix-cli-darwin-arm64": "0.215.3",
48
- "@fusion.dev/helix-cli-darwin-x64": "0.215.3",
49
- "@fusion.dev/helix-cli-linux-arm64": "0.215.3",
50
- "@fusion.dev/helix-cli-linux-x64": "0.215.3",
51
- "@fusion.dev/helix-cli-win32-x64": "0.215.3"
47
+ "@fusion.dev/helix-cli-darwin-arm64": "0.216.0",
48
+ "@fusion.dev/helix-cli-darwin-x64": "0.216.0",
49
+ "@fusion.dev/helix-cli-linux-arm64": "0.216.0",
50
+ "@fusion.dev/helix-cli-linux-x64": "0.216.0",
51
+ "@fusion.dev/helix-cli-win32-x64": "0.216.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@anthropic-ai/sdk": ">=0.132",
package/visual/README.md CHANGED
@@ -286,6 +286,35 @@ picture is committed.
286
286
  that differs stops and names what differs, because every picture would
287
287
  differ for that reason alone.
288
288
 
289
+ ## Picture a screen in a state
290
+
291
+ A page pictured as it loads shows every menu closed and every field empty. To
292
+ picture it with a menu open, a field filled, or a list scrolled, save the screen
293
+ and keep the file beside the entry's baselines as `<stem>.state.json`, where
294
+ `<stem>` is what the entry's baseline files start with. The run puts it back
295
+ once the page has upgraded and before it waits for the page to hold still.
296
+
297
+ Save a screen from the page itself, in the browser's console or from a test.
298
+ Helix puts its capture on the page only when asked, so a page in production
299
+ hands no script the text in its fields. Ask with `exposeState` from
300
+ `@fusion.dev/helix-core`, or, from a tool that drives a page it did not write,
301
+ set the slot to `true` before the page loads, as the run does:
302
+
303
+ ```js
304
+ import { exposeState } from '@fusion.dev/helix-core'
305
+
306
+ exposeState()
307
+ const { captureState } = globalThis[Symbol.for('fusion.dev/helix/state')]
308
+ copy(JSON.stringify(captureState(), null, 2))
309
+ ```
310
+
311
+ The state is put back through each element's own requests, so a page that
312
+ controls a component hears the same events it hears from a reader. An entry
313
+ whose state names an element the page no longer draws, or one the page refuses,
314
+ fails rather than picturing a different screen. A password field, a field whose
315
+ `autocomplete` names a password, a one-time code or a payment card, and anything
316
+ under `snapshot="off"` are never saved.
317
+
289
318
  ## The report
290
319
 
291
320
  Every `helix visual test` and `helix visual update` writes a report into the config's
package/visual/driver.mjs CHANGED
@@ -56,14 +56,15 @@ const STYLE_ID = 'helix-visual-harness'
56
56
  const DIAGNOSTICS = '__helix-diagnostics'
57
57
 
58
58
  /* **Read and written synchronously, on the descriptors themselves.** Bun
59
- 1.1.43, the runtime Fusion's CI pins, hands a script nothing from a piped
60
- standard input until the pipe closes, so a stream reader would wait for a
61
- request the binary is waiting to have answered. `readSync` on descriptor 0
62
- returns each line as it arrives, in Bun and in Node. A request is read only
63
- when the last one is answered, which is the one time this has nothing else
64
- to do. Answers are written with `writeSync` for the same reason: the next
65
- thing after an answer is that blocking read, and an answer still queued
66
- behind it would never reach the binary. */
59
+ 1.1.43, the runtime Fusion's CI pinned when this was written, hands a
60
+ script nothing from a piped standard input until the pipe closes, so a
61
+ stream reader would wait for a request the binary is waiting to have
62
+ answered. `readSync` on descriptor 0 returns each line as it arrives, in
63
+ Bun and in Node. A request is read only when the last one is answered,
64
+ which is the one time this has nothing else to do. Answers are written
65
+ with `writeSync` for the same reason: the next thing after an answer is
66
+ that blocking read, and an answer still queued behind it would never
67
+ reach the binary. */
67
68
  for (const name of ['debug', 'info', 'log']) {
68
69
  console[name] = console.error
69
70
  }
@@ -405,6 +406,43 @@ const codeBlocks = (page, { minimum, selector }) =>
405
406
  { minimum, selector },
406
407
  )
407
408
 
409
+ /**
410
+ * Asks every page in `context` for its state tools before the page loads,
411
+ * since Helix puts them on `globalThis` only where something asked for them.
412
+ */
413
+ const askForState = (context) =>
414
+ context.addInitScript(() => {
415
+ globalThis[Symbol.for('fusion.dev/helix/state')] ??= true
416
+ })
417
+
418
+ /**
419
+ * Puts back the screen an entry's `<stem>.state.json` saved, through the
420
+ * page's own Helix: `restoreState` from `@fusion.dev/helix-core`, which the
421
+ * first copy of Helix to load puts on `globalThis`, since `askForState`
422
+ * asked for it. An entry whose state names
423
+ * an element the page no longer draws, or one the page refused, fails, since
424
+ * its picture would not be of the screen the state describes.
425
+ */
426
+ const restore = async (page, state) => {
427
+ const report = await page.evaluate(async (saved) => {
428
+ const tools = globalThis[Symbol.for('fusion.dev/helix/state')]
429
+ if (!tools) {
430
+ return { error: 'the page has no Helix to restore its state with' }
431
+ }
432
+ return tools.restoreState(saved)
433
+ }, state)
434
+ if (report.error) {
435
+ throw new Error(report.error)
436
+ }
437
+ const wrong = [
438
+ ...report.missed.map((path) => `${path} is not on the page`),
439
+ ...report.refused.map((path) => `${path} refused its state`),
440
+ ]
441
+ if (wrong.length > 0) {
442
+ throw new Error(`the saved state did not take: ${wrong.join('; ')}`)
443
+ }
444
+ }
445
+
408
446
  /** One entry, every shot of it from one navigation. */
409
447
  const take = async (lane, item, { elements, readyProperty }, progress) => {
410
448
  const { page } = lane
@@ -442,6 +480,9 @@ const take = async (lane, item, { elements, readyProperty }, progress) => {
442
480
  `nothing defines ${missing.map((tag) => `<${tag}>`).join(', ')}, after ${UPGRADES} seconds of waiting`,
443
481
  )
444
482
  }
483
+ if (item.state) {
484
+ await restore(page, item.state)
485
+ }
445
486
  } else if (shot.harness !== null) {
446
487
  await page.evaluate(
447
488
  ([id, rules]) => {
@@ -622,6 +663,7 @@ const lanes = async (count) => {
622
663
  reducedMotion: 'reduce',
623
664
  viewport: { height: 800, width: 1280 },
624
665
  })
666
+ await askForState(context)
625
667
  if (state.network === 'origin') {
626
668
  await context.route('**/*', (route) => {
627
669
  let origin