@asteby/metacore-runtime-react 9.2.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 +139 -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/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 +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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/package.json +3 -3
- package/src/__tests__/hotswap-reload-policy.test.ts +249 -0
- package/src/__tests__/manifest-hotswap-subscriber.test.ts +179 -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/hotswap-reload-policy.ts +360 -0
- package/src/index.ts +28 -0
- package/src/manifest-hotswap-subscriber.ts +164 -0
- package/src/metadata-cache.ts +86 -0
|
@@ -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,82 @@
|
|
|
1
|
+
// Wasm-client SRI smoke tests — lives in runtime-react because the SDK
|
|
2
|
+
// package does not (yet) carry a vitest setup of its own. We import the
|
|
3
|
+
// helper through the workspace specifier so the test exercises the same
|
|
4
|
+
// public boundary downstream apps see.
|
|
5
|
+
//
|
|
6
|
+
// The SRI logic is decoupled from WebAssembly instantiation by design, so
|
|
7
|
+
// these tests run in plain Node without needing a real `.wasm` module.
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest'
|
|
10
|
+
import {
|
|
11
|
+
verifyIntegrity,
|
|
12
|
+
WasmIntegrityError,
|
|
13
|
+
} from '@asteby/metacore-sdk'
|
|
14
|
+
|
|
15
|
+
function bytes(input: string): Uint8Array {
|
|
16
|
+
return new TextEncoder().encode(input)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function digestB64(algo: 'SHA-256' | 'SHA-384' | 'SHA-512', input: string) {
|
|
20
|
+
const buf = await crypto.subtle.digest(algo, bytes(input))
|
|
21
|
+
const view = new Uint8Array(buf)
|
|
22
|
+
let bin = ''
|
|
23
|
+
for (let i = 0; i < view.byteLength; i++) bin += String.fromCharCode(view[i]!)
|
|
24
|
+
return btoa(bin)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('verifyIntegrity', () => {
|
|
28
|
+
it('accepts a matching sha384 digest', async () => {
|
|
29
|
+
const payload = 'hello, metacore wasm world'
|
|
30
|
+
const hash = await digestB64('SHA-384', payload)
|
|
31
|
+
await expect(
|
|
32
|
+
verifyIntegrity(bytes(payload), `sha384-${hash}`),
|
|
33
|
+
).resolves.toBeUndefined()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('accepts a matching sha256 digest', async () => {
|
|
37
|
+
const payload = '{"hello":"world"}'
|
|
38
|
+
const hash = await digestB64('SHA-256', payload)
|
|
39
|
+
await expect(
|
|
40
|
+
verifyIntegrity(bytes(payload), `sha256-${hash}`),
|
|
41
|
+
).resolves.toBeUndefined()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('throws WasmIntegrityError on digest mismatch', async () => {
|
|
45
|
+
const payload = 'hello'
|
|
46
|
+
const wrong = await digestB64('SHA-384', 'tampered')
|
|
47
|
+
await expect(
|
|
48
|
+
verifyIntegrity(bytes(payload), `sha384-${wrong}`),
|
|
49
|
+
).rejects.toBeInstanceOf(WasmIntegrityError)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('passes when ANY space-separated token matches', async () => {
|
|
53
|
+
const payload = 'multi-hash'
|
|
54
|
+
const bad = await digestB64('SHA-384', 'other')
|
|
55
|
+
const good = await digestB64('SHA-256', payload)
|
|
56
|
+
await expect(
|
|
57
|
+
verifyIntegrity(bytes(payload), `sha384-${bad} sha256-${good}`),
|
|
58
|
+
).resolves.toBeUndefined()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('is a no-op when integrity is the empty string', async () => {
|
|
62
|
+
await expect(verifyIntegrity(bytes('anything'), '')).resolves.toBeUndefined()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('rejects malformed integrity tokens', async () => {
|
|
66
|
+
await expect(
|
|
67
|
+
verifyIntegrity(bytes('x'), 'this-has-no-algo'),
|
|
68
|
+
).rejects.toBeInstanceOf(WasmIntegrityError)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('rejects unsupported algorithms', async () => {
|
|
72
|
+
await expect(
|
|
73
|
+
verifyIntegrity(bytes('x'), 'md5-deadbeef'),
|
|
74
|
+
).rejects.toBeInstanceOf(WasmIntegrityError)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('handles length-mismatched digests as a clean mismatch', async () => {
|
|
78
|
+
await expect(
|
|
79
|
+
verifyIntegrity(bytes('x'), 'sha256-short'),
|
|
80
|
+
).rejects.toBeInstanceOf(WasmIntegrityError)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// AddonLayoutContext — broadcast the active addon entry's layout selection
|
|
2
|
+
// (`shell` vs `immersive`) up to the host so it can hide/show its chrome
|
|
3
|
+
// (Sidebar, Topbar, breadcrumbs) when an immersive addon is mounted.
|
|
4
|
+
//
|
|
5
|
+
// Why a context rather than a prop on the host shell:
|
|
6
|
+
//
|
|
7
|
+
// 1. The host shell is rendered ABOVE the addon route in the tree, but the
|
|
8
|
+
// decision about what layout the addon wants comes from the addon itself
|
|
9
|
+
// (manifest.frontend.layout) which the AddonLoader knows about at mount
|
|
10
|
+
// time. A bottom-up signal via context inverts the dependency cleanly.
|
|
11
|
+
//
|
|
12
|
+
// 2. Addon entries can swap layouts at runtime (think a kiosk-mode toggle
|
|
13
|
+
// inside a POS). A context value reactively updates the host without
|
|
14
|
+
// asking each route to wire props.
|
|
15
|
+
//
|
|
16
|
+
// 3. When the user navigates AWAY from an immersive addon, the AddonLoader
|
|
17
|
+
// unmounts, its layout context updater fires `setLayout("shell")` from
|
|
18
|
+
// a cleanup effect, and the chrome restores automatically.
|
|
19
|
+
//
|
|
20
|
+
// Host integration (starter-core, ops, …):
|
|
21
|
+
//
|
|
22
|
+
// function AppShell({ children }) {
|
|
23
|
+
// const layout = useAddonLayout()
|
|
24
|
+
// const chrome = layout !== "immersive"
|
|
25
|
+
// return (
|
|
26
|
+
// <div className={chrome ? "grid grid-cols-[280px_1fr]" : "h-dvh w-dvw"}>
|
|
27
|
+
// {chrome && <Sidebar />}
|
|
28
|
+
// <main>{chrome && <Topbar />}{children}</main>
|
|
29
|
+
// </div>
|
|
30
|
+
// )
|
|
31
|
+
// }
|
|
32
|
+
//
|
|
33
|
+
// The context defaults to `"shell"`, so apps that never mount an
|
|
34
|
+
// `<AddonLayoutProvider>` keep the legacy behaviour.
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
createContext,
|
|
38
|
+
useCallback,
|
|
39
|
+
useContext,
|
|
40
|
+
useEffect,
|
|
41
|
+
useMemo,
|
|
42
|
+
useState,
|
|
43
|
+
} from 'react'
|
|
44
|
+
import type { AddonLayout } from '@asteby/metacore-sdk'
|
|
45
|
+
|
|
46
|
+
export type { AddonLayout }
|
|
47
|
+
|
|
48
|
+
interface AddonLayoutState {
|
|
49
|
+
/** Active layout. `"shell"` (default) or `"immersive"`. */
|
|
50
|
+
layout: AddonLayout
|
|
51
|
+
/**
|
|
52
|
+
* Imperative setter for the host or an addon-loader to mutate the active
|
|
53
|
+
* layout. Exposed for advanced use; most callers should use
|
|
54
|
+
* `useDeclareAddonLayout(layout)` from a route component, which scopes the
|
|
55
|
+
* change to the route's mount lifetime.
|
|
56
|
+
*/
|
|
57
|
+
setLayout: (layout: AddonLayout) => void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const defaultState: AddonLayoutState = {
|
|
61
|
+
layout: 'shell',
|
|
62
|
+
setLayout: () => {
|
|
63
|
+
/* noop — provider missing; consumers degrade to legacy "shell" */
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const AddonLayoutContext = createContext<AddonLayoutState>(defaultState)
|
|
68
|
+
|
|
69
|
+
export interface AddonLayoutProviderProps {
|
|
70
|
+
/** Initial layout — usually `"shell"`. */
|
|
71
|
+
initial?: AddonLayout
|
|
72
|
+
children: React.ReactNode
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Wrap the host app once, above the router outlet. The provider keeps the
|
|
77
|
+
* currently-active layout in state; addon-loader and `useDeclareAddonLayout`
|
|
78
|
+
* mutate it from below.
|
|
79
|
+
*/
|
|
80
|
+
export function AddonLayoutProvider({
|
|
81
|
+
initial = 'shell',
|
|
82
|
+
children,
|
|
83
|
+
}: AddonLayoutProviderProps) {
|
|
84
|
+
const [layout, setLayout] = useState<AddonLayout>(initial)
|
|
85
|
+
const value = useMemo<AddonLayoutState>(
|
|
86
|
+
() => ({ layout, setLayout }),
|
|
87
|
+
[layout],
|
|
88
|
+
)
|
|
89
|
+
return (
|
|
90
|
+
<AddonLayoutContext.Provider value={value}>
|
|
91
|
+
{children}
|
|
92
|
+
</AddonLayoutContext.Provider>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Read the currently-active layout. The host shell calls this and decides
|
|
98
|
+
* whether to render its chrome. Returns `"shell"` when no provider is
|
|
99
|
+
* mounted, so apps that have not adopted immersive addons keep working.
|
|
100
|
+
*/
|
|
101
|
+
export function useAddonLayout(): AddonLayout {
|
|
102
|
+
return useContext(AddonLayoutContext).layout
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Imperative API — the value returned mirrors `useAddonLayout()` but also
|
|
107
|
+
* exposes the setter for hosts that need to flip the layout outside of a
|
|
108
|
+
* route lifecycle (e.g. a hotkey forcing kiosk mode). Most addon entries do
|
|
109
|
+
* NOT need this; prefer `useDeclareAddonLayout`.
|
|
110
|
+
*/
|
|
111
|
+
export function useAddonLayoutControl(): AddonLayoutState {
|
|
112
|
+
return useContext(AddonLayoutContext)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Declare the layout from the addon side. Mounts the value, restores
|
|
117
|
+
* `"shell"` on unmount. Skip when `layout` is undefined so route components
|
|
118
|
+
* can pass `manifest.frontend?.layout` directly without branching.
|
|
119
|
+
*
|
|
120
|
+
* function PosEntry({ manifest }: { manifest: Manifest }) {
|
|
121
|
+
* useDeclareAddonLayout(manifest.frontend?.layout)
|
|
122
|
+
* return <PosScreen />
|
|
123
|
+
* }
|
|
124
|
+
*/
|
|
125
|
+
export function useDeclareAddonLayout(layout: AddonLayout | undefined): void {
|
|
126
|
+
const { setLayout } = useAddonLayoutControl()
|
|
127
|
+
// useCallback so the effect only re-runs on a real layout change, not on
|
|
128
|
+
// every render of the consumer that happens to forward an inline literal.
|
|
129
|
+
const apply = useCallback(setLayout, [setLayout])
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (!layout || layout === 'shell') return
|
|
132
|
+
apply(layout)
|
|
133
|
+
return () => {
|
|
134
|
+
apply('shell')
|
|
135
|
+
}
|
|
136
|
+
}, [layout, apply])
|
|
137
|
+
}
|