@poveste/app 0.18.3 → 0.19.1

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.
Files changed (42) hide show
  1. package/dist/THIRD_PARTY_NOTICES.md +159 -0
  2. package/dist/bundled/app.css +4 -4
  3. package/dist/bundled/components/command/PromptSelect.vue_vue_type_script_setup_true_lang.js +1 -1
  4. package/dist/bundled/components/story/StoryNativeScope.js +14 -0
  5. package/dist/bundled/components/story/StoryVariantGridItem.js +1 -1
  6. package/dist/bundled/components/story/StoryVariantGridItem.vue_vue_type_script_setup_true_lang.js +23 -16
  7. package/dist/bundled/components/story/StoryVariantSinglePreviewNative.vue_vue_type_script_setup_true_lang.js +22 -15
  8. package/dist/bundled/components/story/StoryVariantSinglePreviewRemote.vue_vue_type_script_setup_true_lang.js +8 -2
  9. package/dist/bundled/index.js +12 -3
  10. package/dist/bundled/router.js +15 -12
  11. package/dist/bundled/sandbox.js +32 -2
  12. package/dist/bundled/stores/preview-settings.js +29 -4
  13. package/dist/bundled/util/color-scheme.js +41 -1
  14. package/dist/bundled/util/commands.js +16 -4
  15. package/dist/bundled/util/const.js +2 -1
  16. package/package.json +6 -4
  17. package/src/app/components/command/PromptSelect.vue +2 -1
  18. package/src/app/components/story/StoryNativeScope.vue +23 -0
  19. package/src/app/components/story/StoryVariantGridItem.vue +14 -10
  20. package/src/app/components/story/StoryVariantSinglePreviewNative.vue +15 -10
  21. package/src/app/components/story/StoryVariantSinglePreviewRemote.vue +13 -2
  22. package/src/app/index.ts +14 -2
  23. package/src/app/router.ts +26 -15
  24. package/src/app/sandbox.ts +41 -2
  25. package/src/app/stores/preview-settings.ts +48 -10
  26. package/src/app/types.ts +7 -1
  27. package/src/app/util/color-scheme.ts +44 -0
  28. package/src/app/util/commands.ts +19 -7
  29. package/src/app/util/const.ts +1 -0
  30. package/src/__tests__/base-select.spec.ts +0 -25
  31. package/src/__tests__/color-scheme.spec.ts +0 -55
  32. package/src/__tests__/font.spec.ts +0 -76
  33. package/src/__tests__/grid-slots.spec.ts +0 -84
  34. package/src/__tests__/icons.spec.ts +0 -26
  35. package/src/__tests__/postcss-scope-wrapper.spec.ts +0 -65
  36. package/src/__tests__/render-tracker.spec.ts +0 -38
  37. package/src/__tests__/reset.spec.ts +0 -53
  38. package/src/__tests__/source-code.spec.ts +0 -55
  39. package/src/__tests__/state-bridge.spec.ts +0 -155
  40. package/src/__tests__/state-clone.spec.ts +0 -120
  41. package/src/__tests__/state-types.spec.ts +0 -175
  42. package/src/__tests__/uncaught.spec.ts +0 -36
@@ -1,55 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest'
2
-
3
- // `util/config` re-exports a virtual module Vite generates from the book's own
4
- // config, so the tests supply it directly.
5
- const config: { theme: { darkClass?: string }, sandboxDarkClass?: string } = { theme: {} }
6
-
7
- vi.mock('../app/util/config', () => ({
8
- get povesteConfig() {
9
- return config
10
- },
11
- }))
12
-
13
- // Touches browser storage on import; unrelated to the pure function under test.
14
- vi.mock('../app/util/dark', () => ({ isDark: { value: false } }))
15
-
16
- const { previewDarkClasses } = await import('../app/util/color-scheme')
17
-
18
- function darkClassesFor({ darkClass = 'dark', sandboxDarkClass }: { darkClass?: string, sandboxDarkClass?: string }) {
19
- config.theme = { darkClass }
20
- config.sandboxDarkClass = sandboxDarkClass
21
- return previewDarkClasses()
22
- }
23
-
24
- describe('previewDarkClasses', () => {
25
- it('returns the theme class for a book that configures neither', () => {
26
- expect(darkClassesFor({})).toEqual(['dark'])
27
- })
28
-
29
- // #126: the deprecated option used to default to `dark` and be emitted here
30
- // too, which is what made the three render paths disagree.
31
- it('returns the theme class alone when only it is configured', () => {
32
- expect(darkClassesFor({ darkClass: 'my-dark' })).toEqual(['my-dark'])
33
- })
34
-
35
- it('appends sandboxDarkClass when a book still sets it', () => {
36
- expect(darkClassesFor({ darkClass: 'my-dark', sandboxDarkClass: 'legacy' })).toEqual(['my-dark', 'legacy'])
37
- })
38
-
39
- it('returns one class when both names are the same', () => {
40
- expect(darkClassesFor({ darkClass: 'dark', sandboxDarkClass: 'dark' })).toEqual(['dark'])
41
- })
42
-
43
- // `classList.toggle('')` throws, and the sandbox runs it during boot, so an
44
- // empty entry leaves every preview blank rather than merely unstyled. Only
45
- // the empty string reaches here — defu fills an undefined class.
46
- describe('when a configured class name is empty', () => {
47
- it('drops an empty theme class', () => {
48
- expect(darkClassesFor({ darkClass: '' })).toEqual([])
49
- })
50
-
51
- it('drops an empty sandboxDarkClass', () => {
52
- expect(darkClassesFor({ sandboxDarkClass: '' })).toEqual(['dark'])
53
- })
54
- })
55
- })
@@ -1,76 +0,0 @@
1
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
- import { join } from 'node:path'
3
- import { describe, expect, it } from 'vitest'
4
- // @ts-expect-error plain script, no types
5
- import { FONT_PACKAGE, generateFontCss, referencedFiles } from '../../scripts/font.mjs'
6
-
7
- // Vitest runs from the package root, and stubs `?raw` CSS imports to empty.
8
- // Newlines are normalised: the shipped sheet is checked in, so a Windows
9
- // checkout hands it back with CRLF, and this compares content against the
10
- // LF-newline installed package, not line endings (#156).
11
- const read = (path: string) => readFileSync(join(process.cwd(), path), 'utf8').replace(/\r\n/g, '\n')
12
-
13
- const shipped = read('src/app/style/font.css')
14
- const upstream = read(join('node_modules', FONT_PACKAGE, 'index.css'))
15
-
16
- describe('the vendored files', () => {
17
- // The sheet and the files are one artifact now that the package is a
18
- // devDependency: a book asking for a face that is not on disk renders the
19
- // fallback, and nothing else would say so (#306).
20
- it('ships every file the sheet asks for', () => {
21
- const missing = referencedFiles(shipped).filter(file => !existsSync(join(process.cwd(), 'src/app/style/fonts', file)))
22
-
23
- expect(missing).toEqual([])
24
- })
25
-
26
- // The other direction: a subset upstream drops or renames leaves a file the
27
- // sheet no longer names, and `files: ['dist', 'src']` would publish it.
28
- it('ships nothing the sheet does not ask for', () => {
29
- const named = new Set([...referencedFiles(shipped), 'LICENSE'])
30
- const orphans = readdirSync(join(process.cwd(), 'src/app/style/fonts')).filter(file => !named.has(file))
31
-
32
- expect(orphans).toEqual([])
33
- })
34
-
35
- it('asks for at least one, so a sheet that references nothing is not a pass', () => {
36
- expect(referencedFiles(shipped).length).toBeGreaterThan(0)
37
- })
38
-
39
- // SIL OFL requires the notice to travel with the font.
40
- it('carries the licence the font is under', () => {
41
- expect(read('src/app/style/fonts/LICENSE')).toContain('SIL Open Font License')
42
- })
43
-
44
- // Vendoring froze these bytes. A Fontsource patch that fixes hinting or adds
45
- // a glyph keeps the filenames and unicode-ranges, so the sheet comparison
46
- // below stays green while the files it names are stale — the property that
47
- // installing the package used to provide for free.
48
- it('matches upstream byte for byte, since a bump no longer ships itself', () => {
49
- const stale = referencedFiles(shipped).filter(file => !readFileSync(join(process.cwd(), 'src/app/style/fonts', file))
50
- .equals(readFileSync(join(process.cwd(), 'node_modules', FONT_PACKAGE, 'files', file))))
51
-
52
- expect(stale).toEqual([])
53
- })
54
-
55
- // `read`, not `readFileSync`: the licence is checked in, so a Windows
56
- // checkout hands it back with CRLF against the LF of the installed copy.
57
- it('carries upstream\'s own licence text, not a copy that has drifted', () => {
58
- expect(read('src/app/style/fonts/LICENSE')).toBe(read(join('node_modules', FONT_PACKAGE, 'LICENSE')))
59
- })
60
- })
61
-
62
- describe('the bundled app face', () => {
63
- it('matches the installed package, so a bump cannot go unnoticed', () => {
64
- expect(shipped).toBe(generateFontCss(upstream))
65
- })
66
-
67
- it('asks for no format the woff2 already covers', () => {
68
- expect(shipped).not.toContain('format(\'woff\')')
69
- })
70
-
71
- it('keeps every subset upstream ships, so non-latin text stays on the face', () => {
72
- const subsets = (css: string) => (css.match(/unicode-range: [^;]+/g) ?? []).sort()
73
-
74
- expect(subsets(shipped)).toEqual(subsets(upstream))
75
- })
76
- })
@@ -1,84 +0,0 @@
1
- import type { Variant } from '../app/types'
2
- import type { Slot } from '../app/util/grid-slots.js'
3
- import { describe, expect, it } from 'vitest'
4
- import { assignSlots, trimSlots } from '../app/util/grid-slots.js'
5
-
6
- function variants(ids: string[]): Variant[] {
7
- return ids.map(id => ({ id }) as Variant)
8
- }
9
-
10
- function shown(slots: Slot[]) {
11
- return slots.map(s => (s.visible ? `${s.storyId}:${s.variant.id}@${s.order}` : `(${s.storyId}:${s.variant.id})`))
12
- }
13
-
14
- describe('assignSlots', () => {
15
- it('opens a slot per visible variant in screen order', () => {
16
- const slots = assignSlots([], 'a', variants(['v1', 'v2', 'v3']))
17
-
18
- expect(shown(slots)).toEqual(['a:v1@0', 'a:v2@1', 'a:v3@2'])
19
- })
20
-
21
- it('keeps a still-visible variant in its slot when the window moves', () => {
22
- const first = assignSlots([], 'a', variants(['v1', 'v2', 'v3']))
23
-
24
- const next = assignSlots(first, 'a', variants(['v2', 'v3', 'v4']))
25
-
26
- expect(shown(next)).toEqual(['a:v4@2', 'a:v2@0', 'a:v3@1'])
27
- })
28
-
29
- it('hides a slot whose variant left rather than dropping it', () => {
30
- const first = assignSlots([], 'a', variants(['v1', 'v2', 'v3']))
31
-
32
- const next = assignSlots(first, 'a', variants(['v2', 'v3']))
33
-
34
- expect(shown(next)).toEqual(['(a:v1)', 'a:v2@0', 'a:v3@1'])
35
- })
36
-
37
- it('hands a hidden slot to a variant coming in before opening a new one', () => {
38
- const first = assignSlots([], 'a', variants(['v1', 'v2', 'v3']))
39
- const hidden = assignSlots(first, 'a', variants(['v2', 'v3']))
40
-
41
- const next = assignSlots(hidden, 'a', variants(['v2', 'v3', 'v4']))
42
-
43
- expect(shown(next)).toEqual(['a:v4@2', 'a:v2@0', 'a:v3@1'])
44
- })
45
-
46
- it('does not match a variant of another story by id', () => {
47
- const first = assignSlots([], 'a', variants(['v1', 'v2']))
48
-
49
- const next = assignSlots(first, 'b', variants(['v1', 'v2']))
50
-
51
- expect(next.map(s => s.storyId)).toEqual(['b', 'b'])
52
- expect(next.every(s => s.visible)).toBe(true)
53
- expect(next[0].variant).not.toBe(first[0].variant)
54
- })
55
- })
56
-
57
- describe('trimSlots', () => {
58
- it('leaves a pool with nothing hidden alone', () => {
59
- const slots = assignSlots([], 'a', variants(['v1', 'v2']))
60
-
61
- expect(trimSlots(slots)).toBe(slots)
62
- })
63
-
64
- it('drops trailing hidden slots and keeps interior ones as spares', () => {
65
- const first = assignSlots([], 'a', variants(['v1', 'v2', 'v3', 'v4']))
66
- const moved = assignSlots(first, 'a', variants(['v1', 'v3', 'v5']))
67
- expect(shown(moved)).toEqual(['a:v1@0', 'a:v5@2', 'a:v3@1', '(a:v4)'])
68
-
69
- const next = trimSlots(moved)
70
-
71
- expect(shown(next)).toEqual(['a:v1@0', 'a:v5@2', 'a:v3@1'])
72
- })
73
-
74
- it('compacts the pool when hidden slots outnumber visible ones', () => {
75
- const big = assignSlots([], 'a', variants(['v1', 'v2', 'v3', 'v4', 'v5', 'v6']))
76
- const small = assignSlots(big, 'a', variants(['v2', 'v6']))
77
- expect(shown(small)).toEqual(['(a:v1)', 'a:v2@0', '(a:v3)', '(a:v4)', '(a:v5)', 'a:v6@1'])
78
-
79
- const next = trimSlots(small)
80
-
81
- // v2 stays where it is; v6 moves down into the first free slot.
82
- expect(shown(next)).toEqual(['a:v6@1', 'a:v2@0'])
83
- })
84
- })
@@ -1,26 +0,0 @@
1
- import { execFileSync } from 'node:child_process'
2
- import { readFileSync } from 'node:fs'
3
- import { resolve } from 'node:path'
4
- import { describe, expect, it } from 'vitest'
5
-
6
- const packageRoot = resolve(__dirname, '../..')
7
- const script = resolve(packageRoot, 'scripts/icons.mjs')
8
- const generated = resolve(packageRoot, 'src/app/util/icons.generated.ts')
9
-
10
- describe('bundled chrome icons', () => {
11
- // Run as a child process rather than imported: the generator is plain ESM
12
- // with `import.meta.url`, which vitest's transform does not leave intact.
13
- it('icons.generated.ts matches the icons the sources use', () => {
14
- // A stale file means an Icon the chrome renders is missing from the bundle
15
- // and falls back to a CDN fetch at runtime — exactly what bundling is for.
16
- expect(() => execFileSync('node', [script, '--check'], { stdio: 'pipe' })).not.toThrow()
17
- })
18
-
19
- it('carries the icons the chrome is known to use', () => {
20
- const source = readFileSync(generated, 'utf8')
21
- expect(source).toContain('"prefix":"carbon"')
22
- expect(source).toContain('"cube":')
23
- expect(source).toContain('"search":')
24
- expect(source).toContain('"subtract-line":')
25
- })
26
- })
@@ -1,65 +0,0 @@
1
- import postcss from 'postcss'
2
- import { describe, expect, it } from 'vitest'
3
- // @ts-expect-error — plain CJS postcss plugin, no types.
4
- import scopeWrapper from '../../postcss-scope-wrapper.cjs'
5
-
6
- const OPTS = { from: ':root', to: '.__poveste-render-story:not(.__poveste-render-custom-controls)' }
7
-
8
- function run(css: string) {
9
- return postcss([scopeWrapper(OPTS)]).process(css, { from: undefined }).css
10
- }
11
-
12
- describe('postcss-scope-wrapper', () => {
13
- it('wraps the stylesheet in the configured scope', () => {
14
- expect(run('.a { color: red }')).toContain('@scope (:root) to (.__poveste-render-story:not(.__poveste-render-custom-controls))')
15
- })
16
-
17
- it('leaves @import at the top level', () => {
18
- // @import is only valid before other rules; moving it inside @scope drops it.
19
- const out = run('@import "x.css";\n.a { color: red }')
20
-
21
- expect(out.indexOf('@import')).toBeLessThan(out.indexOf('@scope'))
22
- })
23
-
24
- /*
25
- * The three selectors below all mean "the app's root container" once this
26
- * stylesheet is wrapped. `body` is included for the same reason as `html`:
27
- * poveste re-wraps the output a second time rooted at `.poveste-app-root`, so
28
- * both sit above the effective scoping root and can never be matched. Leaving
29
- * `body` out is #102 — the whole UI rendered one size too large.
30
- */
31
- it.each(['html', ':root', 'body'])('rewrites a root-ish `%s` selector to :scope', (selector) => {
32
- expect(run(`${selector} { font-size: .875rem }`)).toContain(':scope {')
33
- })
34
-
35
- it('rewrites root-ish selectors inside a selector list, leaving the rest alone', () => {
36
- const out = run('html, body, #app { height: 100% }')
37
-
38
- expect(out).toContain(':scope,')
39
- expect(out).toContain('#app')
40
- expect(out).not.toMatch(/(?<![\w.\-#])body\s*[,{]/)
41
- })
42
-
43
- it('rewrites nested rules, not just top-level ones', () => {
44
- // Tailwind v4 emits `@layer base { … }` as a real cascade layer, so the
45
- // rules that style the scoping root sit one level down.
46
- const out = run('@layer base { body { font-size: .875rem } }')
47
-
48
- expect(out).toContain(':scope {')
49
- expect(out).not.toMatch(/(?<![\w.\-#])body\s*\{/)
50
- })
51
-
52
- it('rewrites root-ish selectors inside media queries', () => {
53
- // The 640px override is where #102 actually bit.
54
- const out = run('@media (min-width: 640px) { body { font-size: .875rem } }')
55
-
56
- expect(out).toContain(':scope {')
57
- })
58
-
59
- it('leaves ordinary selectors untouched', () => {
60
- const out = run('.poveste-story-list { color: red }')
61
-
62
- expect(out).toContain('.poveste-story-list')
63
- expect(out).not.toContain(':scope {')
64
- })
65
- })
@@ -1,38 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { createRenderTracker } from '../app/util/render-tracker.js'
3
-
4
- describe('createRenderTracker', () => {
5
- it('calls a render clean when nothing was reported during it', () => {
6
- const render = createRenderTracker()
7
-
8
- render.begin()
9
-
10
- expect(render.wasClean()).toBe(true)
11
- })
12
-
13
- it('does not call a render clean when a throw was reported during it', () => {
14
- const render = createRenderTracker()
15
- render.begin()
16
-
17
- render.threw()
18
-
19
- expect(render.wasClean()).toBe(false)
20
- })
21
-
22
- // The half #597 is about. Without it a story that threw once stays marked for
23
- // the life of the tab: every later render inherits the first one's verdict,
24
- // so the marker the old comment promised to lift on a fix never lifts.
25
- it('forgets the previous render when a new one begins', () => {
26
- const render = createRenderTracker()
27
- render.begin()
28
- render.threw()
29
-
30
- render.begin()
31
-
32
- expect(render.wasClean()).toBe(true)
33
- })
34
-
35
- it('treats a render that has reported nothing yet as clean', () => {
36
- expect(createRenderTracker().wasClean()).toBe(true)
37
- })
38
- })
@@ -1,53 +0,0 @@
1
- import { readFileSync } from 'node:fs'
2
- import { resolve } from 'node:path'
3
- import { describe, expect, it } from 'vitest'
4
-
5
- /*
6
- * Preflight is deliberately off and `main.pcss` replaces it by hand, so anything
7
- * the replacement leaves out is a UA default the chrome then renders. The one
8
- * that shipped was the button background: `ButtonFace` reads as a light grey
9
- * panel, close enough to the light chrome to pass review and a white hole in dark.
10
- *
11
- * Asserted against the source rather than the built sheet because the build is
12
- * postcss over this file, and a reader looking for the reset looks here.
13
- */
14
- // `__dirname` rather than `import.meta.url`, which vitest's transform does not
15
- // leave intact here — the same trap `icons.spec.ts` records.
16
- //
17
- // Line endings normalised, and the anchor below is a single line: matching a
18
- // selector list *across* its own line break reads the file as the checkout wrote
19
- // it, which on Windows is CRLF. This found nothing there and passed everywhere
20
- // else, which is the worst way for a check to be wrong.
21
- const source = readFileSync(resolve(__dirname, '../app/style/main.pcss'), 'utf8').replace(/\r\n/g, '\n')
22
-
23
- /** The declarations of the first rule whose selector list contains `anchor`. */
24
- function rule(anchor: string) {
25
- const start = source.indexOf(anchor)
26
- if (start === -1) {
27
- return null
28
- }
29
- const open = source.indexOf('{', start)
30
- return source.slice(open + 1, source.indexOf('}', open))
31
- }
32
-
33
- /** The last line of the button reset's selector list. */
34
- const BUTTON_RESET = '[type=\'submit\']'
35
-
36
- describe('the manual Preflight replacement', () => {
37
- it('clears the UA background off a button', () => {
38
- expect(rule(BUTTON_RESET)).toContain('background-color: transparent')
39
- })
40
-
41
- it('clears the UA background image too', () => {
42
- // Preflight resets both; a gradient theme would otherwise come back.
43
- expect(rule(BUTTON_RESET)).toContain('background-image: none')
44
- })
45
-
46
- it('stays inside `@layer base`, where a utility can still win', () => {
47
- // An unlayered rule beats every layered one, so `bg-*` on a button would stop
48
- // working — the trap the file's own comment records.
49
- const layers = [...source.matchAll(/@layer base \{/g)].map(match => match.index)
50
- const button = source.indexOf('background-color: transparent')
51
- expect(layers.some(start => start < button)).toBe(true)
52
- })
53
- })
@@ -1,55 +0,0 @@
1
- import type { Story, Variant } from '../app/types'
2
- import { describe, expect, it, vi } from 'vitest'
3
- import { getSourceCode } from '../app/util/docs'
4
-
5
- const plugin = vi.hoisted(() => ({ generateSourceCode: (() => undefined) as (variant: unknown) => unknown, loads: 0 }))
6
-
7
- vi.mock('virtual:$poveste-support-plugins-client', () => ({
8
- clientSupportPlugins: {
9
- framework: async () => {
10
- plugin.loads++
11
- return { generateSourceCode: (variant: unknown) => plugin.generateSourceCode(variant) }
12
- },
13
- },
14
- }))
15
-
16
- const FILE = '<Story title="Grid" />'
17
-
18
- function storyWithFile() {
19
- return { file: { supportPluginId: 'framework', source: async () => ({ default: FILE }) } } as unknown as Story
20
- }
21
-
22
- describe('getSourceCode with a framework plugin', () => {
23
- it('returns the story file when the plugin returns nothing synchronously, as plugin-svelte does', async () => {
24
- plugin.generateSourceCode = () => undefined
25
-
26
- const source = await getSourceCode(storyWithFile(), {} as Variant)
27
-
28
- expect(source).toBe(FILE)
29
- })
30
-
31
- it('returns the story file when the plugin resolves to an empty string', async () => {
32
- plugin.generateSourceCode = async () => ''
33
-
34
- const source = await getSourceCode(storyWithFile(), {} as Variant)
35
-
36
- expect(source).toBe(FILE)
37
- })
38
-
39
- it('prefers what the plugin generates', async () => {
40
- plugin.generateSourceCode = async () => '<Button />'
41
-
42
- const source = await getSourceCode(storyWithFile(), {} as Variant)
43
-
44
- expect(source).toBe('<Button />')
45
- })
46
-
47
- it('returns a variant\'s declared source without loading the plugin', async () => {
48
- plugin.loads = 0
49
-
50
- const source = await getSourceCode(storyWithFile(), { source: '<Declared />' } as Variant)
51
-
52
- expect(source).toBe('<Declared />')
53
- expect(plugin.loads).toBe(0)
54
- })
55
- })
@@ -1,155 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { createStateBridge } from '../app/util/state-bridge.js'
3
-
4
- // A stand-in for `postMessage`: messages queue rather than being delivered, so a
5
- // test can put two of them in flight at once — which is the whole point. Real
6
- // `postMessage` gives the same shape, one task apart, which is exactly how long
7
- // it takes for both sides to have written before either has heard.
8
- function link() {
9
- const queues = { host: [] as any[], sandbox: [] as any[] }
10
- const state = { host: {} as any, sandbox: {} as any }
11
-
12
- const bridges = {
13
- host: createStateBridge(changes => queues.sandbox.push(changes)),
14
- sandbox: createStateBridge(changes => queues.host.push(changes)),
15
- }
16
-
17
- return {
18
- state,
19
- /** The side's state changed; it composes and sends. */
20
- send(side: 'host' | 'sandbox') {
21
- bridges[side].send(structuredClone(state[side]))
22
- },
23
- /** Deliver everything currently in flight to the given side. */
24
- deliver(side: 'host' | 'sandbox') {
25
- const inbox = queues[side].splice(0)
26
- for (const changes of inbox) {
27
- bridges[side].receive(state[side], changes)
28
- }
29
- },
30
- settle() {
31
- for (let i = 0; i < 6; i++) {
32
- this.deliver('host')
33
- this.send('host')
34
- this.deliver('sandbox')
35
- this.send('sandbox')
36
- }
37
- },
38
- }
39
- }
40
-
41
- describe('createStateBridge', () => {
42
- it('carries an edit in each direction', () => {
43
- const bridge = link()
44
- bridge.state.host = { a: 0, b: 0 }
45
- bridge.state.sandbox = { a: 0, b: 0 }
46
- bridge.settle()
47
-
48
- bridge.state.host.a = 1
49
- bridge.send('host')
50
- bridge.deliver('sandbox')
51
- expect(bridge.state.sandbox.a).toBe(1)
52
-
53
- bridge.state.sandbox.b = 2
54
- bridge.send('sandbox')
55
- bridge.deliver('host')
56
- expect(bridge.state.host.b).toBe(2)
57
- })
58
-
59
- it('does not echo what it was just sent', () => {
60
- const bridge = link()
61
- bridge.state.host = { a: 0 }
62
- bridge.state.sandbox = { a: 0 }
63
- bridge.settle()
64
-
65
- bridge.state.host.a = 1
66
- bridge.send('host')
67
- bridge.deliver('sandbox')
68
-
69
- // The sandbox's own watcher fires on the write it just applied. It has
70
- // nothing of its own to report, so nothing goes back.
71
- bridge.send('sandbox')
72
- expect(bridge.state.host.a).toBe(1)
73
- bridge.deliver('host')
74
- expect(bridge.state.host.a).toBe(1)
75
- })
76
-
77
- it('keeps both edits when the two sides write before either has heard', () => {
78
- // The race, exactly: both write, both compose, then both deliver. Sending
79
- // the whole state here meant each message carried its sender's stale copy of
80
- // the other's key, and each end applied that over its own fresh value — so
81
- // the two sides ended up disagreeing with nothing left to settle it.
82
- const bridge = link()
83
- bridge.state.host = { label: 'start', bumps: 0 }
84
- bridge.state.sandbox = { label: 'start', bumps: 0 }
85
- bridge.settle()
86
-
87
- bridge.state.host.label = 'raced'
88
- bridge.state.sandbox.bumps = 1
89
- bridge.send('host')
90
- bridge.send('sandbox')
91
-
92
- bridge.deliver('host')
93
- bridge.deliver('sandbox')
94
- bridge.settle()
95
-
96
- expect(bridge.state.host).toEqual({ label: 'raced', bumps: 1 })
97
- expect(bridge.state.sandbox).toEqual({ label: 'raced', bumps: 1 })
98
- })
99
-
100
- it('does not revert the sender when the receiver refuses the write', () => {
101
- // `applyState` swallows a write a setter refuses — a read-only `computed` in
102
- // state is the case that occurs — and reports it as written anyway. Agreeing
103
- // to it regardless is worse than losing it: the receiver still holds the old
104
- // value, its next firing diffs that against a baseline claiming the new one,
105
- // and it comes back as a fresh edit that reverts the sender. The reader's
106
- // control edit undoes itself with nothing to show for it.
107
- const queues: any = { host: [], sandbox: [] }
108
- const host: any = { a: 0 }
109
- const sandbox: any = {}
110
- Object.defineProperty(sandbox, 'a', { get: () => 0, enumerable: true, configurable: true })
111
-
112
- const hostBridge = createStateBridge(changes => queues.sandbox.push(changes))
113
- const sandboxBridge = createStateBridge(changes => queues.host.push(changes))
114
-
115
- hostBridge.send({ ...host })
116
- for (const changes of queues.sandbox.splice(0)) sandboxBridge.receive(sandbox, changes)
117
-
118
- host.a = 1
119
- hostBridge.send({ a: 1 })
120
- for (const changes of queues.sandbox.splice(0)) sandboxBridge.receive(sandbox, changes)
121
-
122
- // The sandbox refused it, so its own next firing still reads 0.
123
- sandboxBridge.send({ a: sandbox.a })
124
- for (const changes of queues.host.splice(0)) hostBridge.receive(host, changes)
125
-
126
- expect(host.a).toBe(1)
127
- })
128
-
129
- it('leaves a simultaneous write to the same key unresolved, as it always did', () => {
130
- // Not something a diff can fix, and not something this changed. Both ends
131
- // accept what the other sent, so the two values swap. Landing them on one
132
- // value needs each end to know which of its own writes the other has seen
133
- // yet — acknowledgements, a real protocol — and picking a winner without
134
- // that would mean dropping legitimate later edits from the losing side.
135
- //
136
- // Recorded here so the limit is a decision rather than a surprise. Writing
137
- // the same key from the story and the panel in one task is a genuine
138
- // conflict; writing *different* keys, which is the case above, is not.
139
- const bridge = link()
140
- bridge.state.host = { a: 0 }
141
- bridge.state.sandbox = { a: 0 }
142
- bridge.settle()
143
-
144
- bridge.state.host.a = 1
145
- bridge.state.sandbox.a = 2
146
- bridge.send('host')
147
- bridge.send('sandbox')
148
- bridge.deliver('host')
149
- bridge.deliver('sandbox')
150
- bridge.settle()
151
-
152
- expect(bridge.state.host.a).toBe(2)
153
- expect(bridge.state.sandbox.a).toBe(1)
154
- })
155
- })