@asteby/metacore-runtime-react 9.1.0 → 10.0.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/CHANGELOG.md +175 -0
- package/dist/addon-layout-context.d.ts +49 -0
- package/dist/addon-layout-context.d.ts.map +1 -0
- package/dist/addon-layout-context.js +94 -0
- package/dist/addon-loader.d.ts +14 -2
- package/dist/addon-loader.d.ts.map +1 -1
- package/dist/addon-loader.js +7 -1
- package/dist/dynamic-form-schema.d.ts +5 -0
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +34 -0
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +18 -2
- package/dist/dynamic-relation.d.ts.map +1 -1
- package/dist/dynamic-relation.js +59 -22
- package/dist/hotswap-reload-policy.d.ts +155 -0
- package/dist/hotswap-reload-policy.d.ts.map +1 -0
- package/dist/hotswap-reload-policy.js +227 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/manifest-hotswap-subscriber.d.ts +83 -0
- package/dist/manifest-hotswap-subscriber.d.ts.map +1 -0
- package/dist/manifest-hotswap-subscriber.js +104 -0
- package/dist/metadata-cache.d.ts +35 -0
- package/dist/metadata-cache.d.ts.map +1 -1
- package/dist/metadata-cache.js +55 -0
- package/dist/types.d.ts +20 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-options-resolver.d.ts +87 -0
- package/dist/use-options-resolver.d.ts.map +1 -0
- package/dist/use-options-resolver.js +147 -0
- package/dist/use-org-config-bridge.d.ts +28 -0
- package/dist/use-org-config-bridge.d.ts.map +1 -0
- package/dist/use-org-config-bridge.js +50 -0
- package/package.json +4 -4
- package/src/__tests__/hotswap-reload-policy.test.ts +249 -0
- package/src/__tests__/manifest-hotswap-subscriber.test.ts +179 -0
- package/src/__tests__/use-options-resolver.test.ts +127 -0
- package/src/__tests__/wasm-client-sri.test.ts +82 -0
- package/src/addon-layout-context.tsx +137 -0
- package/src/addon-loader.tsx +21 -1
- package/src/dynamic-form-schema.ts +36 -0
- package/src/dynamic-form.tsx +40 -2
- package/src/dynamic-relation.tsx +55 -20
- package/src/hotswap-reload-policy.ts +360 -0
- package/src/index.ts +43 -0
- package/src/manifest-hotswap-subscriber.ts +164 -0
- package/src/metadata-cache.ts +86 -0
- package/src/types.ts +24 -0
- package/src/use-options-resolver.ts +232 -0
- package/src/use-org-config-bridge.ts +60 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Bridge to `useOrgConfig` from `@asteby/metacore-app-providers` without
|
|
2
|
+
// adding it as a hard dependency of `runtime-react`. The provider package
|
|
3
|
+
// is a peer; in apps that mount it the hook returns the live config, in
|
|
4
|
+
// apps that don't the SDK falls through to a no-op shim that resolves
|
|
5
|
+
// every reference to null. Forms then leave $org.<key> tokens in place
|
|
6
|
+
// rather than crashing — the operator notices the missing config when
|
|
7
|
+
// the validator fails to fire, not at app boot.
|
|
8
|
+
//
|
|
9
|
+
// Why a bridge: runtime-react cannot import `@asteby/metacore-app-providers`
|
|
10
|
+
// directly without inverting the dependency graph (app-providers depends
|
|
11
|
+
// on runtime-react today via peerDependenciesMeta). The shim shape
|
|
12
|
+
// matches `OrgConfigContextValue` so DynamicForm code reads through one
|
|
13
|
+
// stable interface regardless of provider mount.
|
|
14
|
+
const NULL_BRIDGE = {
|
|
15
|
+
resolveValidator: () => null,
|
|
16
|
+
available: false,
|
|
17
|
+
};
|
|
18
|
+
let activeBridge = NULL_BRIDGE;
|
|
19
|
+
/**
|
|
20
|
+
* Apps that consume `runtime-react` AND `@asteby/metacore-app-providers`
|
|
21
|
+
* call this once near the root (typically inside the OrgConfigProvider
|
|
22
|
+
* children) so the SDK reads the same resolver. Hosts without an org
|
|
23
|
+
* provider can ignore this entirely; the SDK's null bridge keeps every
|
|
24
|
+
* call returning `null` so $org.<key> tokens stay verbatim in the form
|
|
25
|
+
* — same fallback the kernel uses for unresolved references.
|
|
26
|
+
*/
|
|
27
|
+
export function setOrgConfigBridge(bridge) {
|
|
28
|
+
activeBridge = bridge ?? NULL_BRIDGE;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Returns the active bridge. Pure read — no React hook so it can be
|
|
32
|
+
* called from non-component code (zod schema builders, helpers).
|
|
33
|
+
*/
|
|
34
|
+
export function getOrgConfigBridge() {
|
|
35
|
+
return activeBridge;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Resolves a Validation token into the validator identifier the SDK
|
|
39
|
+
* should apply. Returns the resolved literal when the org config knows
|
|
40
|
+
* the key, or the original token when it doesn't (so apps can decide).
|
|
41
|
+
* Plain literals (no `$org.` prefix) pass through.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveValidatorToken(token) {
|
|
44
|
+
if (!token)
|
|
45
|
+
return null;
|
|
46
|
+
if (!token.startsWith('$org.'))
|
|
47
|
+
return token;
|
|
48
|
+
const resolved = activeBridge.resolveValidator(token);
|
|
49
|
+
return resolved ?? token;
|
|
50
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "10.0.0",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"lucide-react": ">=0.460",
|
|
33
33
|
"date-fns": ">=3",
|
|
34
34
|
"react-day-picker": ">=8",
|
|
35
|
-
"@asteby/metacore-sdk": "^2.
|
|
35
|
+
"@asteby/metacore-sdk": "^2.5.0",
|
|
36
36
|
"@asteby/metacore-ui": "^2.0.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependenciesMeta": {
|
|
@@ -57,10 +57,10 @@
|
|
|
57
57
|
"react-i18next": "^17.0.0",
|
|
58
58
|
"sonner": "^2.0.0",
|
|
59
59
|
"tsx": "^4.21.0",
|
|
60
|
-
"typescript": "^
|
|
60
|
+
"typescript": "^6.0.0",
|
|
61
61
|
"vitest": "^4.0.0",
|
|
62
62
|
"zustand": "^5.0.0",
|
|
63
|
-
"@asteby/metacore-sdk": "2.
|
|
63
|
+
"@asteby/metacore-sdk": "2.5.0",
|
|
64
64
|
"@asteby/metacore-ui": "2.0.0"
|
|
65
65
|
},
|
|
66
66
|
"scripts": {
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
applyHotSwapReload,
|
|
4
|
+
withVersionParam,
|
|
5
|
+
clearFederationContainer,
|
|
6
|
+
shortenHash,
|
|
7
|
+
type HotSwapReloadConfig,
|
|
8
|
+
type HotSwapReloadDeps,
|
|
9
|
+
} from '../hotswap-reload-policy'
|
|
10
|
+
import { type AddonManifestChangedMessage } from '../manifest-hotswap-subscriber'
|
|
11
|
+
|
|
12
|
+
const msg = (
|
|
13
|
+
addonKey: string,
|
|
14
|
+
newHash: string | undefined = 'sha256:abcdef1234567890',
|
|
15
|
+
): AddonManifestChangedMessage => ({
|
|
16
|
+
type: 'ADDON_MANIFEST_CHANGED',
|
|
17
|
+
payload: { addonKey, newHash },
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
interface FakeState {
|
|
21
|
+
map: Record<string, string>
|
|
22
|
+
deps: HotSwapReloadDeps
|
|
23
|
+
reload: ReturnType<typeof vi.fn>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeFakeState(): FakeState {
|
|
27
|
+
const reload = vi.fn()
|
|
28
|
+
const state: FakeState = {
|
|
29
|
+
map: {},
|
|
30
|
+
// populated below
|
|
31
|
+
deps: {} as HotSwapReloadDeps,
|
|
32
|
+
reload,
|
|
33
|
+
}
|
|
34
|
+
state.deps = {
|
|
35
|
+
setVersionMap: (updater) => {
|
|
36
|
+
state.map = updater(state.map)
|
|
37
|
+
},
|
|
38
|
+
reload,
|
|
39
|
+
}
|
|
40
|
+
return state
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('shortenHash', () => {
|
|
44
|
+
it('strips the algorithm prefix and truncates to 8 hex chars', () => {
|
|
45
|
+
expect(shortenHash('sha256:ABCDEF1234567890')).toBe('abcdef12')
|
|
46
|
+
expect(shortenHash('abcdef1234567890')).toBe('abcdef12')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('returns undefined for missing / empty input', () => {
|
|
50
|
+
expect(shortenHash(undefined)).toBeUndefined()
|
|
51
|
+
expect(shortenHash('')).toBeUndefined()
|
|
52
|
+
expect(shortenHash('sha256:')).toBeUndefined()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('withVersionParam', () => {
|
|
57
|
+
it('appends ?v=<hash8> to a URL with no query', () => {
|
|
58
|
+
expect(
|
|
59
|
+
withVersionParam('/api/addons/pos/frontend/remoteEntry.js', 'sha256:abcdef1234'),
|
|
60
|
+
).toBe('/api/addons/pos/frontend/remoteEntry.js?v=abcdef12')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('appends &v=<hash8> when other query params exist', () => {
|
|
64
|
+
expect(withVersionParam('/r.js?foo=1&bar=2', 'abcdef1234')).toBe(
|
|
65
|
+
'/r.js?foo=1&bar=2&v=abcdef12',
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('replaces a prior v= entry instead of accumulating', () => {
|
|
70
|
+
expect(withVersionParam('/r.js?v=oldhash&foo=1', 'newhash99')).toBe(
|
|
71
|
+
'/r.js?foo=1&v=newhash9',
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('preserves the URL fragment', () => {
|
|
76
|
+
expect(withVersionParam('/r.js#section', 'abcdef1234')).toBe(
|
|
77
|
+
'/r.js?v=abcdef12#section',
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('is idempotent for the same hash', () => {
|
|
82
|
+
const once = withVersionParam('/r.js', 'abcdef1234')
|
|
83
|
+
const twice = withVersionParam(once, 'abcdef1234')
|
|
84
|
+
expect(twice).toBe(once)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('returns the URL unchanged for falsy hash', () => {
|
|
88
|
+
expect(withVersionParam('/r.js', undefined)).toBe('/r.js')
|
|
89
|
+
expect(withVersionParam('/r.js', '')).toBe('/r.js')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('clearFederationContainer', () => {
|
|
94
|
+
it('removes the container from window and returns true', () => {
|
|
95
|
+
// Node test environment — globalThis is functionally `window` here.
|
|
96
|
+
// We set a property and expect it to be removed.
|
|
97
|
+
;(globalThis as Record<string, unknown>).metacore_test_addon = {
|
|
98
|
+
init: () => {},
|
|
99
|
+
get: () => {},
|
|
100
|
+
}
|
|
101
|
+
// The function checks `typeof window === 'undefined'`. In node env
|
|
102
|
+
// that's true → returns false. We only assert this code path doesn't
|
|
103
|
+
// throw and returns a boolean. The window-present case is exercised
|
|
104
|
+
// indirectly through manual integration in the hot-swap docs.
|
|
105
|
+
const result = clearFederationContainer('metacore_test_addon')
|
|
106
|
+
expect(typeof result).toBe('boolean')
|
|
107
|
+
delete (globalThis as Record<string, unknown>).metacore_test_addon
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('returns false when called for an unregistered scope in SSR (no window)', () => {
|
|
111
|
+
expect(clearFederationContainer('metacore_never_registered')).toBe(false)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('applyHotSwapReload', () => {
|
|
116
|
+
it('returns "noop" and does not touch the map when addonKey is missing', async () => {
|
|
117
|
+
const state = makeFakeState()
|
|
118
|
+
const action = await applyHotSwapReload(
|
|
119
|
+
{
|
|
120
|
+
type: 'ADDON_MANIFEST_CHANGED',
|
|
121
|
+
// @ts-expect-error — exercising the guard
|
|
122
|
+
payload: {},
|
|
123
|
+
},
|
|
124
|
+
{},
|
|
125
|
+
state.deps,
|
|
126
|
+
)
|
|
127
|
+
expect(action).toBe('noop')
|
|
128
|
+
expect(state.map).toEqual({})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('default strategy rekeys the map with the short hash and fires onSwap("rekey")', async () => {
|
|
132
|
+
const state = makeFakeState()
|
|
133
|
+
const onSwap = vi.fn()
|
|
134
|
+
const action = await applyHotSwapReload(
|
|
135
|
+
msg('pos', 'sha256:abcdef1234'),
|
|
136
|
+
{ onSwap },
|
|
137
|
+
state.deps,
|
|
138
|
+
)
|
|
139
|
+
expect(action).toBe('rekey')
|
|
140
|
+
expect(state.map).toEqual({ pos: 'abcdef12' })
|
|
141
|
+
expect(onSwap).toHaveBeenCalledWith(expect.anything(), 'rekey')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('successive bumps for the same addon update the hash in place', async () => {
|
|
145
|
+
const state = makeFakeState()
|
|
146
|
+
await applyHotSwapReload(msg('pos', 'sha256:abcdef1234'), {}, state.deps)
|
|
147
|
+
await applyHotSwapReload(msg('pos', 'sha256:99999999'), {}, state.deps)
|
|
148
|
+
expect(state.map).toEqual({ pos: '99999999' })
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('bumps for different addons accumulate as independent entries', async () => {
|
|
152
|
+
const state = makeFakeState()
|
|
153
|
+
await applyHotSwapReload(msg('pos', 'sha256:aaaaaaaa'), {}, state.deps)
|
|
154
|
+
await applyHotSwapReload(msg('kitchen', 'sha256:bbbbbbbb'), {}, state.deps)
|
|
155
|
+
expect(state.map).toEqual({ pos: 'aaaaaaaa', kitchen: 'bbbbbbbb' })
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('cancels rekey when onBeforeReload returns false (sync)', async () => {
|
|
159
|
+
const state = makeFakeState()
|
|
160
|
+
const onBeforeReload = vi.fn().mockReturnValue(false)
|
|
161
|
+
const onSwap = vi.fn()
|
|
162
|
+
const action = await applyHotSwapReload(
|
|
163
|
+
msg('pos'),
|
|
164
|
+
{ strategy: 'rekey', onBeforeReload, onSwap },
|
|
165
|
+
state.deps,
|
|
166
|
+
)
|
|
167
|
+
expect(action).toBe('cancelled')
|
|
168
|
+
expect(state.map).toEqual({})
|
|
169
|
+
expect(onSwap).toHaveBeenCalledWith(expect.anything(), 'cancelled')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('proceeds with rekey when async onBeforeReload resolves true', async () => {
|
|
173
|
+
const state = makeFakeState()
|
|
174
|
+
const onBeforeReload = vi.fn().mockResolvedValue(true)
|
|
175
|
+
const action = await applyHotSwapReload(
|
|
176
|
+
msg('pos'),
|
|
177
|
+
{ strategy: 'rekey', onBeforeReload },
|
|
178
|
+
state.deps,
|
|
179
|
+
)
|
|
180
|
+
expect(action).toBe('rekey')
|
|
181
|
+
expect(state.map).toEqual({ pos: 'abcdef12' })
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('strategy=manual updates the map and reports "manual" without reloading', async () => {
|
|
185
|
+
const state = makeFakeState()
|
|
186
|
+
const onSwap = vi.fn()
|
|
187
|
+
const action = await applyHotSwapReload(
|
|
188
|
+
msg('pos'),
|
|
189
|
+
{ strategy: 'manual', onSwap },
|
|
190
|
+
state.deps,
|
|
191
|
+
)
|
|
192
|
+
expect(action).toBe('manual')
|
|
193
|
+
expect(state.map).toEqual({ pos: 'abcdef12' })
|
|
194
|
+
expect(state.reload).not.toHaveBeenCalled()
|
|
195
|
+
expect(onSwap).toHaveBeenCalledWith(expect.anything(), 'manual')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('strategy=page-reload invokes the reload function via microtask', async () => {
|
|
199
|
+
const state = makeFakeState()
|
|
200
|
+
const onSwap = vi.fn()
|
|
201
|
+
const action = await applyHotSwapReload(
|
|
202
|
+
msg('pos'),
|
|
203
|
+
{ strategy: 'page-reload', onSwap },
|
|
204
|
+
state.deps,
|
|
205
|
+
)
|
|
206
|
+
expect(action).toBe('page-reload')
|
|
207
|
+
expect(onSwap).toHaveBeenCalledWith(expect.anything(), 'page-reload')
|
|
208
|
+
|
|
209
|
+
// Yield to the microtask queue so the deferred reload fires.
|
|
210
|
+
await Promise.resolve()
|
|
211
|
+
expect(state.reload).toHaveBeenCalledTimes(1)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('strategy=page-reload + onBeforeReload(false) skips the reload', async () => {
|
|
215
|
+
const state = makeFakeState()
|
|
216
|
+
const onBeforeReload = vi.fn().mockReturnValue(false)
|
|
217
|
+
const onSwap = vi.fn()
|
|
218
|
+
const action = await applyHotSwapReload(
|
|
219
|
+
msg('pos'),
|
|
220
|
+
{ strategy: 'page-reload', onBeforeReload, onSwap },
|
|
221
|
+
state.deps,
|
|
222
|
+
)
|
|
223
|
+
expect(action).toBe('cancelled')
|
|
224
|
+
await Promise.resolve()
|
|
225
|
+
expect(state.reload).not.toHaveBeenCalled()
|
|
226
|
+
expect(onSwap).toHaveBeenCalledWith(expect.anything(), 'cancelled')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('skips map update when newHash is missing but still rekeys (no-op effect)', async () => {
|
|
230
|
+
const state = makeFakeState()
|
|
231
|
+
const onSwap = vi.fn()
|
|
232
|
+
// Build a message with an explicitly missing newHash. Going through
|
|
233
|
+
// `msg(..., undefined)` would trigger the default-value substitution,
|
|
234
|
+
// so we hand-roll the payload here.
|
|
235
|
+
const action = await applyHotSwapReload(
|
|
236
|
+
{
|
|
237
|
+
type: 'ADDON_MANIFEST_CHANGED',
|
|
238
|
+
payload: { addonKey: 'pos' },
|
|
239
|
+
},
|
|
240
|
+
{ onSwap },
|
|
241
|
+
state.deps,
|
|
242
|
+
)
|
|
243
|
+
expect(action).toBe('rekey')
|
|
244
|
+
// Without a hash, we have nothing to write; the map stays empty
|
|
245
|
+
// but onSwap still fires for telemetry.
|
|
246
|
+
expect(state.map).toEqual({})
|
|
247
|
+
expect(onSwap).toHaveBeenCalledWith(expect.anything(), 'rekey')
|
|
248
|
+
})
|
|
249
|
+
})
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
ADDON_MANIFEST_CHANGED_TYPE,
|
|
4
|
+
wireHotSwapInvalidation,
|
|
5
|
+
type AddonManifestChangedMessage,
|
|
6
|
+
type ManifestHotSwapClient,
|
|
7
|
+
} from '../manifest-hotswap-subscriber'
|
|
8
|
+
import { useMetadataCache, defaultAddonKeyMatcher } from '../metadata-cache'
|
|
9
|
+
import type { TableMetadata } from '../types'
|
|
10
|
+
|
|
11
|
+
// Minimal stub TableMetadata; the cache stores values opaquely so the only
|
|
12
|
+
// requirement is the type-shape compiles.
|
|
13
|
+
const fakeMeta = (label: string): TableMetadata => ({ label }) as unknown as TableMetadata
|
|
14
|
+
|
|
15
|
+
interface FakeClient extends ManifestHotSwapClient {
|
|
16
|
+
emit: (msg: AddonManifestChangedMessage) => void
|
|
17
|
+
subscriberCount: () => number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeFakeClient(): FakeClient {
|
|
21
|
+
const subs = new Map<string, Set<(m: AddonManifestChangedMessage) => void>>()
|
|
22
|
+
return {
|
|
23
|
+
subscribe(type, handler) {
|
|
24
|
+
let set = subs.get(type)
|
|
25
|
+
if (!set) {
|
|
26
|
+
set = new Set()
|
|
27
|
+
subs.set(type, set)
|
|
28
|
+
}
|
|
29
|
+
set.add(handler)
|
|
30
|
+
return () => {
|
|
31
|
+
subs.get(type)?.delete(handler)
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
emit(msg) {
|
|
35
|
+
const set = subs.get(msg.type)
|
|
36
|
+
if (!set) return
|
|
37
|
+
for (const h of set) h(msg)
|
|
38
|
+
},
|
|
39
|
+
subscriberCount() {
|
|
40
|
+
return Array.from(subs.values()).reduce((n, s) => n + s.size, 0)
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('manifest-hotswap-subscriber', () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
// Reset the zustand cache between tests so each starts from a
|
|
48
|
+
// known-empty state. The store persists by default; resetState
|
|
49
|
+
// here keeps the in-memory copy isolated.
|
|
50
|
+
useMetadataCache.setState({
|
|
51
|
+
cache: {},
|
|
52
|
+
modalCache: {},
|
|
53
|
+
metadataVersion: 'v-old',
|
|
54
|
+
prefetched: true,
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
vi.restoreAllMocks()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('emits the topic name the kernel/bridge publishes', () => {
|
|
63
|
+
expect(ADDON_MANIFEST_CHANGED_TYPE).toBe('ADDON_MANIFEST_CHANGED')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('subscribes to ADDON_MANIFEST_CHANGED and invalidates the named addon on receipt', () => {
|
|
67
|
+
const cache = useMetadataCache.getState()
|
|
68
|
+
cache.setMetadata('kitchen_display.tickets', fakeMeta('tickets'))
|
|
69
|
+
cache.setMetadata('kitchen_display:settings', fakeMeta('settings'))
|
|
70
|
+
cache.setMetadata('kitchen_display/lines', fakeMeta('lines'))
|
|
71
|
+
cache.setMetadata('pos.cart', fakeMeta('cart'))
|
|
72
|
+
cache.setModalMetadata('kitchen_display.printer', fakeMeta('printer'))
|
|
73
|
+
cache.setModalMetadata('inventory.batch', fakeMeta('batch'))
|
|
74
|
+
|
|
75
|
+
const client = makeFakeClient()
|
|
76
|
+
const unsubscribe = wireHotSwapInvalidation(client)
|
|
77
|
+
expect(client.subscriberCount()).toBe(1)
|
|
78
|
+
|
|
79
|
+
client.emit({
|
|
80
|
+
type: 'ADDON_MANIFEST_CHANGED',
|
|
81
|
+
payload: {
|
|
82
|
+
addonKey: 'kitchen_display',
|
|
83
|
+
oldHash: 'sha256:aaa',
|
|
84
|
+
newHash: 'sha256:bbb',
|
|
85
|
+
version: '1.2.0',
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const after = useMetadataCache.getState()
|
|
90
|
+
// Three table-cache entries scoped to kitchen_display should be gone.
|
|
91
|
+
expect(after.cache).toEqual({ 'pos.cart': fakeMeta('cart') })
|
|
92
|
+
// One modal entry gone; the inventory entry stays.
|
|
93
|
+
expect(after.modalCache).toEqual({ 'inventory.batch': fakeMeta('batch') })
|
|
94
|
+
// prefetched flipped to false so prefetchAll() can re-run.
|
|
95
|
+
expect(after.prefetched).toBe(false)
|
|
96
|
+
|
|
97
|
+
unsubscribe()
|
|
98
|
+
expect(client.subscriberCount()).toBe(0)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('invokes the onSwap callback with the message and the removed count', () => {
|
|
102
|
+
const cache = useMetadataCache.getState()
|
|
103
|
+
cache.setMetadata('pos.cart', fakeMeta('cart'))
|
|
104
|
+
cache.setMetadata('pos:lines', fakeMeta('lines'))
|
|
105
|
+
cache.setMetadata('inventory.batch', fakeMeta('batch'))
|
|
106
|
+
|
|
107
|
+
const onSwap = vi.fn()
|
|
108
|
+
const client = makeFakeClient()
|
|
109
|
+
wireHotSwapInvalidation(client, { onSwap })
|
|
110
|
+
|
|
111
|
+
const message: AddonManifestChangedMessage = {
|
|
112
|
+
type: 'ADDON_MANIFEST_CHANGED',
|
|
113
|
+
payload: { addonKey: 'pos', newHash: 'sha256:new' },
|
|
114
|
+
}
|
|
115
|
+
client.emit(message)
|
|
116
|
+
|
|
117
|
+
expect(onSwap).toHaveBeenCalledTimes(1)
|
|
118
|
+
expect(onSwap).toHaveBeenCalledWith(message, 2)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('honours a custom matcher when the host uses a different key convention', () => {
|
|
122
|
+
const cache = useMetadataCache.getState()
|
|
123
|
+
cache.setMetadata('addons/pos/cart', fakeMeta('cart'))
|
|
124
|
+
cache.setMetadata('addons/pos/lines', fakeMeta('lines'))
|
|
125
|
+
cache.setMetadata('addons/inv/batch', fakeMeta('batch'))
|
|
126
|
+
|
|
127
|
+
const matcher = (cacheKey: string, addonKey: string) =>
|
|
128
|
+
cacheKey.startsWith(`addons/${addonKey}/`)
|
|
129
|
+
|
|
130
|
+
const client = makeFakeClient()
|
|
131
|
+
wireHotSwapInvalidation(client, { matcher })
|
|
132
|
+
|
|
133
|
+
client.emit({
|
|
134
|
+
type: 'ADDON_MANIFEST_CHANGED',
|
|
135
|
+
payload: { addonKey: 'pos' },
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const after = useMetadataCache.getState()
|
|
139
|
+
expect(Object.keys(after.cache)).toEqual(['addons/inv/batch'])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('ignores malformed messages (missing addonKey) without throwing', () => {
|
|
143
|
+
const cache = useMetadataCache.getState()
|
|
144
|
+
cache.setMetadata('pos.cart', fakeMeta('cart'))
|
|
145
|
+
|
|
146
|
+
const client = makeFakeClient()
|
|
147
|
+
wireHotSwapInvalidation(client)
|
|
148
|
+
|
|
149
|
+
expect(() =>
|
|
150
|
+
client.emit({
|
|
151
|
+
type: 'ADDON_MANIFEST_CHANGED',
|
|
152
|
+
// @ts-expect-error — exercising the malformed-payload guard
|
|
153
|
+
payload: {},
|
|
154
|
+
}),
|
|
155
|
+
).not.toThrow()
|
|
156
|
+
|
|
157
|
+
// Cache untouched.
|
|
158
|
+
expect(useMetadataCache.getState().cache).toEqual({
|
|
159
|
+
'pos.cart': fakeMeta('cart'),
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('returns a no-op unsubscribe when the client is undefined', () => {
|
|
164
|
+
const unsubscribe = wireHotSwapInvalidation(undefined)
|
|
165
|
+
expect(typeof unsubscribe).toBe('function')
|
|
166
|
+
expect(() => unsubscribe()).not.toThrow()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('default matcher recognises addonKey, prefix., prefix:, prefix/', () => {
|
|
170
|
+
expect(defaultAddonKeyMatcher('pos', 'pos')).toBe(true)
|
|
171
|
+
expect(defaultAddonKeyMatcher('pos.cart', 'pos')).toBe(true)
|
|
172
|
+
expect(defaultAddonKeyMatcher('pos:lines', 'pos')).toBe(true)
|
|
173
|
+
expect(defaultAddonKeyMatcher('pos/checkout', 'pos')).toBe(true)
|
|
174
|
+
expect(defaultAddonKeyMatcher('possible.x', 'pos')).toBe(false)
|
|
175
|
+
expect(defaultAddonKeyMatcher('inventory.cart', 'pos')).toBe(false)
|
|
176
|
+
expect(defaultAddonKeyMatcher('', 'pos')).toBe(false)
|
|
177
|
+
expect(defaultAddonKeyMatcher('pos.cart', '')).toBe(false)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { afterEach, describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { projectOption } from '../use-options-resolver'
|
|
3
|
+
import {
|
|
4
|
+
resolveValidatorToken,
|
|
5
|
+
setOrgConfigBridge,
|
|
6
|
+
} from '../use-org-config-bridge'
|
|
7
|
+
|
|
8
|
+
// `useOptionsResolver` itself is a React hook and would need jsdom +
|
|
9
|
+
// react-test-renderer to exercise end-to-end. The bridge tests here
|
|
10
|
+
// pin down the projection layer (the only impure shape conversion the
|
|
11
|
+
// hook performs) so consumers can rely on the v0.9.0 envelope reading
|
|
12
|
+
// without spinning up a renderer.
|
|
13
|
+
|
|
14
|
+
describe('projectOption', () => {
|
|
15
|
+
it('mirrors id into value and label into name when missing', () => {
|
|
16
|
+
const out = projectOption({ id: 'abc', label: 'Hello' })
|
|
17
|
+
expect(out.id).toBe('abc')
|
|
18
|
+
expect(out.value).toBe('abc')
|
|
19
|
+
expect(out.label).toBe('Hello')
|
|
20
|
+
expect(out.name).toBe('Hello')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('preserves explicit value and name when provided', () => {
|
|
24
|
+
const out = projectOption({ id: 1, value: 'one', label: 'L', name: 'N' })
|
|
25
|
+
expect(out.value).toBe('one')
|
|
26
|
+
expect(out.name).toBe('N')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('coerces label to string from numeric id when none provided', () => {
|
|
30
|
+
const out = projectOption({ id: 42 })
|
|
31
|
+
expect(out.label).toBe('42')
|
|
32
|
+
expect(out.name).toBe('42')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('preserves optional decoration fields', () => {
|
|
36
|
+
const out = projectOption({
|
|
37
|
+
id: 'x', label: 'X',
|
|
38
|
+
description: 'desc', image: '/a.png',
|
|
39
|
+
color: '#fff', icon: 'IconStar',
|
|
40
|
+
})
|
|
41
|
+
expect(out.description).toBe('desc')
|
|
42
|
+
expect(out.image).toBe('/a.png')
|
|
43
|
+
expect(out.color).toBe('#fff')
|
|
44
|
+
expect(out.icon).toBe('IconStar')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('null-safes missing optionals to null', () => {
|
|
48
|
+
const out = projectOption({ id: 'x', label: 'X' })
|
|
49
|
+
expect(out.description).toBeNull()
|
|
50
|
+
expect(out.image).toBeNull()
|
|
51
|
+
expect(out.color).toBeNull()
|
|
52
|
+
expect(out.icon).toBeNull()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('survives empty payload (defensive)', () => {
|
|
56
|
+
const out = projectOption({})
|
|
57
|
+
expect(out.id).toBe('')
|
|
58
|
+
expect(out.value).toBe('')
|
|
59
|
+
expect(out.label).toBe('')
|
|
60
|
+
expect(out.name).toBe('')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// The envelope shape the hook expects from the kernel is exercised here
|
|
65
|
+
// with a mocked transport so apps can document the wire contract in a
|
|
66
|
+
// single place. `useOptionsResolver` reads `body.data` for options and
|
|
67
|
+
// `body.meta.{type, count}` for the discriminator — the legacy
|
|
68
|
+
// root-level `body.type` is also accepted for grace-period upgrades.
|
|
69
|
+
describe('options envelope contract', () => {
|
|
70
|
+
it('v0.9.0 shape carries meta.type and meta.count', () => {
|
|
71
|
+
const wire = {
|
|
72
|
+
success: true,
|
|
73
|
+
data: [
|
|
74
|
+
{ id: '1', label: 'One' },
|
|
75
|
+
{ id: '2', label: 'Two' },
|
|
76
|
+
],
|
|
77
|
+
meta: { type: 'dynamic', count: 2 },
|
|
78
|
+
}
|
|
79
|
+
// Smoke-check the projection a real call would do.
|
|
80
|
+
expect(wire.data.map(projectOption)).toHaveLength(2)
|
|
81
|
+
expect(wire.meta.type).toBe('dynamic')
|
|
82
|
+
expect(wire.meta.count).toBe(2)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('legacy shape is identifiable but consumers should migrate', () => {
|
|
86
|
+
const legacy = {
|
|
87
|
+
success: true,
|
|
88
|
+
data: [{ id: '1', label: 'One' }],
|
|
89
|
+
// root-level type, not under meta — the SDK reads it as a
|
|
90
|
+
// fallback but logs no warning (kernel ≥ v0.9.0 emits the
|
|
91
|
+
// canonical shape; older deployments are an interop case).
|
|
92
|
+
type: 'static',
|
|
93
|
+
} as any
|
|
94
|
+
expect(legacy.type).toBe('static')
|
|
95
|
+
expect(legacy.meta).toBeUndefined()
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// Sanity-check the resolver bridge: when no provider is mounted
|
|
100
|
+
// `resolveValidatorToken` returns the original token. Apps that mount
|
|
101
|
+
// `OrgConfigProvider` swap that for the resolved literal.
|
|
102
|
+
describe('OrgConfigBridge integration', () => {
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
// Reset to the null bridge so independent tests do not leak state.
|
|
105
|
+
setOrgConfigBridge(null)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('resolveValidatorToken passes through plain literals', () => {
|
|
109
|
+
expect(resolveValidatorToken('mx.rfc')).toBe('mx.rfc')
|
|
110
|
+
expect(resolveValidatorToken(null)).toBeNull()
|
|
111
|
+
expect(resolveValidatorToken('')).toBeNull()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('resolveValidatorToken returns the $org reference verbatim when no bridge mounted', () => {
|
|
115
|
+
// Default null bridge: ref keys resolve to null → token preserved.
|
|
116
|
+
expect(resolveValidatorToken('$org.tax_id')).toBe('$org.tax_id')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('setOrgConfigBridge swaps the active resolver and survives clearing', () => {
|
|
120
|
+
const spy = vi.fn((key: string) => (key === '$org.tax_id' ? 'mx.rfc' : null))
|
|
121
|
+
setOrgConfigBridge({ resolveValidator: spy, available: true })
|
|
122
|
+
expect(resolveValidatorToken('$org.tax_id')).toBe('mx.rfc')
|
|
123
|
+
expect(spy).toHaveBeenCalledWith('$org.tax_id')
|
|
124
|
+
setOrgConfigBridge(null)
|
|
125
|
+
expect(resolveValidatorToken('$org.tax_id')).toBe('$org.tax_id')
|
|
126
|
+
})
|
|
127
|
+
})
|