@webspatial/core-sdk 1.5.0 → 1.6.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.
@@ -0,0 +1,402 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+
3
+ vi.mock('./JSBCommand', () => {
4
+ return {
5
+ createSpatialSceneCommand: vi.fn().mockImplementation(() => ({
6
+ executeSync: vi.fn().mockReturnValue({
7
+ data: { id: 'scene-1', windowProxy: {} },
8
+ }),
9
+ })),
10
+ FocusScene: vi.fn().mockImplementation(() => ({
11
+ execute: vi.fn().mockResolvedValue(undefined),
12
+ })),
13
+ }
14
+ })
15
+
16
+ function addDataManifest(manifest: any) {
17
+ const link = document.createElement('link')
18
+ link.rel = 'manifest'
19
+ const json = JSON.stringify(manifest)
20
+ link.href = 'data:application/manifest+json,' + encodeURIComponent(json)
21
+ document.head.appendChild(link)
22
+ return () => {
23
+ document.head.removeChild(link)
24
+ }
25
+ }
26
+
27
+ async function waitTick() {
28
+ await Promise.resolve()
29
+ await new Promise(r => setTimeout(r, 0))
30
+ }
31
+
32
+ describe('setupManifest applies overrides to xr_window_defaults / xr_volume_defaults', () => {
33
+ it('applies window overrides without affecting volume defaults', async () => {
34
+ vi.resetModules()
35
+ const cleanup = addDataManifest({
36
+ xr_spatial_scene: {
37
+ defaultSize: { width: '200px', height: '300px' },
38
+ resizability: { minWidth: '300px', minHeight: '400px' },
39
+ worldScaling: 'dynamic',
40
+ worldAlignment: 'gravityAligned',
41
+ baseplateVisibility: 'visible',
42
+ overrides: {
43
+ window_scene: {
44
+ defaultSize: { width: '1000px' },
45
+ resizability: { minWidth: '500px', maxWidth: '1200px' },
46
+ worldAlignment: 'automatic',
47
+ },
48
+ },
49
+ },
50
+ })
51
+ const { hijackWindowOpen, initScene } = await import('./scene-polyfill')
52
+ hijackWindowOpen(window)
53
+ await waitTick()
54
+
55
+ let winDefaults: any
56
+ initScene(
57
+ 'w',
58
+ pre => {
59
+ winDefaults = pre
60
+ return pre
61
+ },
62
+ { type: 'window' },
63
+ )
64
+
65
+ let volDefaults: any
66
+ initScene(
67
+ 'v',
68
+ pre => {
69
+ volDefaults = pre
70
+ return pre
71
+ },
72
+ { type: 'volume' },
73
+ )
74
+
75
+ // window uses overrides + formatted to px
76
+ expect(winDefaults).toEqual(
77
+ expect.objectContaining({
78
+ defaultSize: { width: '1000px', height: '300px' },
79
+ resizability: expect.objectContaining({
80
+ minWidth: '500px',
81
+ minHeight: '400px',
82
+ maxWidth: '1200px',
83
+ }),
84
+ worldAlignment: 'automatic',
85
+ worldScaling: 'dynamic',
86
+ baseplateVisibility: 'visible',
87
+ }),
88
+ )
89
+
90
+ // volume remains from top-level only (no volume override) and formats:
91
+ // - pre passed to initScene is the raw manifest values (unformatted)
92
+ expect(volDefaults).toEqual(
93
+ expect.objectContaining({
94
+ defaultSize: {
95
+ width: '200px',
96
+ height: '300px',
97
+ // depth may be omitted in manifest; defaults keep only provided keys
98
+ },
99
+ resizability: expect.objectContaining({
100
+ minWidth: '300px',
101
+ minHeight: '400px',
102
+ }),
103
+ worldAlignment: 'gravityAligned',
104
+ worldScaling: 'dynamic',
105
+ baseplateVisibility: 'visible',
106
+ }),
107
+ )
108
+
109
+ cleanup()
110
+ })
111
+
112
+ it('applies volume overrides without affecting window defaults', async () => {
113
+ vi.resetModules()
114
+ const cleanup = addDataManifest({
115
+ xr_spatial_scene: {
116
+ defaultSize: { width: '120px', height: '240px' },
117
+ resizability: { minWidth: '200px', minHeight: '300px' },
118
+ overrides: {
119
+ volume_scene: {
120
+ defaultSize: { width: '2m', height: '2m', depth: '2m' },
121
+ resizability: { minWidth: '1m' },
122
+ baseplateVisibility: 'automatic',
123
+ },
124
+ },
125
+ },
126
+ })
127
+ const { hijackWindowOpen, initScene } = await import('./scene-polyfill')
128
+ hijackWindowOpen(window)
129
+ await waitTick()
130
+
131
+ let volDefaults: any
132
+ initScene(
133
+ 'v',
134
+ pre => {
135
+ volDefaults = pre
136
+ return pre
137
+ },
138
+ { type: 'volume' },
139
+ )
140
+ let winDefaults: any
141
+ initScene(
142
+ 'w',
143
+ pre => {
144
+ winDefaults = pre
145
+ return pre
146
+ },
147
+ { type: 'window' },
148
+ )
149
+
150
+ // volume uses overrides + formatting (m for defaultSize, px for resizability)
151
+ expect(volDefaults).toEqual(
152
+ expect.objectContaining({
153
+ defaultSize: { width: '2m', height: '2m', depth: '2m' },
154
+ resizability: expect.objectContaining({
155
+ minWidth: '1m',
156
+ }),
157
+ baseplateVisibility: 'automatic',
158
+ }),
159
+ )
160
+
161
+ // window remains from top-level only (no window override)
162
+ expect(winDefaults).toEqual(
163
+ expect.objectContaining({
164
+ defaultSize: { width: '120px', height: '240px' },
165
+ resizability: expect.objectContaining({
166
+ minWidth: '200px',
167
+ minHeight: '300px',
168
+ }),
169
+ }),
170
+ )
171
+
172
+ cleanup()
173
+ })
174
+
175
+ it('applies provided mixed-case defaults and empty volume override; volume pre is formatted as expected', async () => {
176
+ vi.resetModules()
177
+ const cleanup = addDataManifest({
178
+ xr_spatial_scene: {
179
+ default_size: {
180
+ width: '1024px',
181
+ height: '1024px',
182
+ depth: '55px',
183
+ },
184
+ resizability: {
185
+ minWidth: '1024px',
186
+ minHeight: '1024px',
187
+ maxWidth: '2000px',
188
+ maxHeight: '2000px',
189
+ },
190
+ worldScaling: 'automatic',
191
+ worldAlignment: 'automatic',
192
+ baseplateVisibility: 'visible',
193
+ overrides: {
194
+ window_scene: {
195
+ default_size: {
196
+ width: '500px',
197
+ height: '500px',
198
+ depth: '55px',
199
+ },
200
+ resizability: {
201
+ minWidth: '500px',
202
+ minHeight: '500px',
203
+ maxWidth: '1000px',
204
+ maxHeight: '1000px',
205
+ },
206
+ },
207
+ volume_scene: {},
208
+ },
209
+ },
210
+ })
211
+ const { hijackWindowOpen, initScene } = await import('./scene-polyfill')
212
+ hijackWindowOpen(window)
213
+ await waitTick()
214
+
215
+ const pxToM = (px: number) => px / 1360
216
+
217
+ let volDefaults: any
218
+ initScene(
219
+ 'sa',
220
+ pre => {
221
+ volDefaults = pre
222
+ return { ...pre }
223
+ },
224
+ { type: 'volume' },
225
+ )
226
+
227
+ expect(volDefaults).toEqual(
228
+ expect.objectContaining({
229
+ defaultSize: {
230
+ width: '1024px',
231
+ height: '1024px',
232
+ depth: '55px',
233
+ },
234
+ resizability: expect.objectContaining({
235
+ minWidth: '1024px',
236
+ minHeight: '1024px',
237
+ maxWidth: '2000px',
238
+ maxHeight: '2000px',
239
+ }),
240
+ worldScaling: 'automatic',
241
+ worldAlignment: 'automatic',
242
+ baseplateVisibility: 'visible',
243
+ }),
244
+ )
245
+
246
+ // Snapshot current 'sa' internal config before cleanup
247
+ const { __getSceneConfigSnapshotForTest } = await import('./scene-polyfill')
248
+ const snap = __getSceneConfigSnapshotForTest('sa')
249
+ expect(snap).toEqual(
250
+ expect.objectContaining({
251
+ type: 'volume',
252
+ defaultSize: {
253
+ width: pxToM(1024),
254
+ height: pxToM(1024),
255
+ depth: pxToM(55),
256
+ },
257
+ resizability: expect.objectContaining({
258
+ minWidth: 1024,
259
+ minHeight: 1024,
260
+ maxWidth: 2000,
261
+ maxHeight: 2000,
262
+ }),
263
+ }),
264
+ )
265
+
266
+ cleanup()
267
+ })
268
+ })
269
+
270
+ describe('manifest error paths and empty configs', () => {
271
+ function addInvalidDataManifest() {
272
+ const link = document.createElement('link')
273
+ link.rel = 'manifest'
274
+ // Invalid JSON payload to force parse failure in getPWAManifest
275
+ link.href = 'data:application/manifest+json,INVALID_JSON'
276
+ document.head.appendChild(link)
277
+ return () => {
278
+ document.head.removeChild(link)
279
+ }
280
+ }
281
+
282
+ it('falls back to built-in defaults when no manifest link is present', async () => {
283
+ vi.resetModules()
284
+ const { hijackWindowOpen, initScene } = await import('./scene-polyfill')
285
+ hijackWindowOpen(window)
286
+ await waitTick()
287
+
288
+ let winPre: any
289
+ initScene(
290
+ 'no-manifest-win',
291
+ pre => {
292
+ winPre = pre
293
+ return pre
294
+ },
295
+ { type: 'window' },
296
+ )
297
+ let volPre: any
298
+ initScene(
299
+ 'no-manifest-vol',
300
+ pre => {
301
+ volPre = pre
302
+ return pre
303
+ },
304
+ { type: 'volume' },
305
+ )
306
+
307
+ // Window defaults: numbers in px domain for width/height
308
+ expect(winPre).toEqual(
309
+ expect.objectContaining({
310
+ defaultSize: { width: 1280, height: 720 },
311
+ }),
312
+ )
313
+ // Volume defaults: strings in meters for width/height/depth
314
+ expect(volPre).toEqual(
315
+ expect.objectContaining({
316
+ defaultSize: { width: '0.94m', height: '0.94m', depth: '0.94m' },
317
+ }),
318
+ )
319
+ })
320
+
321
+ it('falls back to built-in defaults when manifest parsing fails', async () => {
322
+ vi.resetModules()
323
+ const cleanup = addInvalidDataManifest()
324
+ const { hijackWindowOpen, initScene } = await import('./scene-polyfill')
325
+ hijackWindowOpen(window)
326
+ await waitTick()
327
+
328
+ let winPre: any
329
+ initScene(
330
+ 'bad-manifest-win',
331
+ pre => {
332
+ winPre = pre
333
+ return pre
334
+ },
335
+ { type: 'window' },
336
+ )
337
+ let volPre: any
338
+ initScene(
339
+ 'bad-manifest-vol',
340
+ pre => {
341
+ volPre = pre
342
+ return pre
343
+ },
344
+ { type: 'volume' },
345
+ )
346
+
347
+ expect(winPre).toEqual(
348
+ expect.objectContaining({
349
+ defaultSize: { width: 1280, height: 720 },
350
+ }),
351
+ )
352
+ expect(volPre).toEqual(
353
+ expect.objectContaining({
354
+ defaultSize: { width: '0.94m', height: '0.94m', depth: '0.94m' },
355
+ }),
356
+ )
357
+
358
+ cleanup()
359
+ })
360
+
361
+ it('ignores empty xr_spatial_scene object and preserves built-in defaults', async () => {
362
+ vi.resetModules()
363
+ const cleanup = addDataManifest({
364
+ xr_spatial_scene: {},
365
+ })
366
+ const { hijackWindowOpen, initScene } = await import('./scene-polyfill')
367
+ hijackWindowOpen(window)
368
+ await waitTick()
369
+
370
+ let winPre: any
371
+ initScene(
372
+ 'empty-xr-win',
373
+ pre => {
374
+ winPre = pre
375
+ return pre
376
+ },
377
+ { type: 'window' },
378
+ )
379
+ let volPre: any
380
+ initScene(
381
+ 'empty-xr-vol',
382
+ pre => {
383
+ volPre = pre
384
+ return pre
385
+ },
386
+ { type: 'volume' },
387
+ )
388
+
389
+ expect(winPre).toEqual(
390
+ expect.objectContaining({
391
+ defaultSize: { width: 1280, height: 720 },
392
+ }),
393
+ )
394
+ expect(volPre).toEqual(
395
+ expect.objectContaining({
396
+ defaultSize: { width: '0.94m', height: '0.94m', depth: '0.94m' },
397
+ }),
398
+ )
399
+
400
+ cleanup()
401
+ })
402
+ })
@@ -1,6 +1,13 @@
1
1
  import { describe, expect, test, vi, beforeEach, afterEach, it } from 'vitest'
2
- import { formatSceneConfig, initScene, injectSceneHook } from './scene-polyfill'
2
+ import {
3
+ formatSceneConfig,
4
+ initScene,
5
+ injectSceneHook,
6
+ __getSceneConfigSnapshotForTest,
7
+ hijackWindowATag,
8
+ } from './scene-polyfill'
3
9
  import { SpatialSceneCreationOptions } from './types/types'
10
+ import { pointToPhysical } from './physicalMetrics'
4
11
 
5
12
  describe('test formatSceneConfig in window', () => {
6
13
  test('should format window with no unit', () => {
@@ -79,6 +86,23 @@ describe('test formatSceneConfig in window', () => {
79
86
  ])
80
87
  })
81
88
 
89
+ test('window mixed units: cm invalid and numbers treated as px', () => {
90
+ const config = {
91
+ defaultSize: {
92
+ width: '10cm',
93
+ height: 800,
94
+ },
95
+ } satisfies SpatialSceneCreationOptions
96
+ const [formatted, errors] = formatSceneConfig(config, 'window')
97
+ expect(errors).toEqual(['defaultSize.width'])
98
+ expect(formatted.defaultSize).toEqual(
99
+ expect.objectContaining({
100
+ height: 800,
101
+ }),
102
+ )
103
+ expect((formatted.defaultSize as any).width).toBeUndefined()
104
+ })
105
+
82
106
  test('should format window with meter', () => {
83
107
  const config = {
84
108
  defaultSize: {
@@ -106,6 +130,27 @@ describe('test formatSceneConfig in window', () => {
106
130
  })
107
131
  })
108
132
 
133
+ describe('formatSceneConfig invalid unit (mixed)', () => {
134
+ test('volume defaultSize width cm invalid while numbers convert', () => {
135
+ const config = {
136
+ defaultSize: {
137
+ width: '10cm',
138
+ height: 1000,
139
+ depth: 100,
140
+ },
141
+ } satisfies SpatialSceneCreationOptions
142
+ const [formattedConfig, errors] = formatSceneConfig(config, 'volume')
143
+ expect(errors).toEqual(['defaultSize.width'])
144
+ expect(formattedConfig.defaultSize).toEqual(
145
+ expect.objectContaining({
146
+ height: pointToPhysical(1000),
147
+ depth: pointToPhysical(100),
148
+ }),
149
+ )
150
+ expect((formattedConfig.defaultSize as any).width).toBeUndefined()
151
+ })
152
+ })
153
+
109
154
  describe('test formatSceneConfig in volume', () => {
110
155
  test('should format volume with no unit', () => {
111
156
  const config = {
@@ -123,15 +168,15 @@ describe('test formatSceneConfig in volume', () => {
123
168
  } satisfies SpatialSceneCreationOptions
124
169
  const [formattedConfig] = formatSceneConfig(config, 'volume')
125
170
  expect(formattedConfig.defaultSize).toEqual({
126
- width: 1,
127
- height: 1,
128
- depth: 1,
171
+ width: pointToPhysical(1),
172
+ height: pointToPhysical(1),
173
+ depth: pointToPhysical(1),
129
174
  })
130
175
  expect(formattedConfig.resizability).toEqual({
131
- minWidth: 1360,
132
- minHeight: 1360,
133
- maxWidth: 1360,
134
- maxHeight: 1360,
176
+ minWidth: 1,
177
+ minHeight: 1,
178
+ maxWidth: 1,
179
+ maxHeight: 1,
135
180
  })
136
181
  })
137
182
 
@@ -316,7 +361,7 @@ describe('injectScenePolyfill should call xrCurrentSceneDefaults and update scen
316
361
 
317
362
  expect(mockFn).toHaveBeenCalledWith(
318
363
  expect.objectContaining({
319
- defaultSize: { width: 0.94, height: 0.94, depth: 0.94 },
364
+ defaultSize: { width: '0.94m', height: '0.94m', depth: '0.94m' },
320
365
  }),
321
366
  )
322
367
 
@@ -324,12 +369,16 @@ describe('injectScenePolyfill should call xrCurrentSceneDefaults and update scen
324
369
  const { UpdateSceneConfig } = await import('./JSBCommand')
325
370
  expect(UpdateSceneConfig).toHaveBeenCalledWith({
326
371
  type: 'volume',
327
- defaultSize: { width: 1, height: 1, depth: 1 },
372
+ defaultSize: {
373
+ width: pointToPhysical(1),
374
+ height: pointToPhysical(1),
375
+ depth: pointToPhysical(1),
376
+ },
328
377
  resizability: {
329
- minWidth: 0.5 * 1360,
330
- minHeight: 1 * 1360,
331
- maxWidth: 0.5 * 1360,
332
- maxHeight: 1 * 1360,
378
+ minWidth: 0.5,
379
+ minHeight: 1,
380
+ maxWidth: 0.5,
381
+ maxHeight: 1,
333
382
  },
334
383
  })
335
384
  })
@@ -341,7 +390,7 @@ describe('initScene should receive defaultScene config by type', () => {
341
390
  .fn()
342
391
  .mockResolvedValue({ defaultSize: { width: 800, height: 600 } })
343
392
 
344
- initScene('sa', mockFn)
393
+ initScene('sa-no-type', mockFn)
345
394
 
346
395
  expect(mockFn).toHaveBeenCalledWith(
347
396
  expect.objectContaining({ defaultSize: { width: 1280, height: 720 } }),
@@ -353,7 +402,7 @@ describe('initScene should receive defaultScene config by type', () => {
353
402
  .fn()
354
403
  .mockResolvedValue({ defaultSize: { width: 800, height: 600 } })
355
404
 
356
- initScene('sa', mockFn, { type: 'window' })
405
+ initScene('sa-window', mockFn, { type: 'window' })
357
406
 
358
407
  expect(mockFn).toHaveBeenCalledWith(
359
408
  expect.objectContaining({ defaultSize: { width: 1280, height: 720 } }),
@@ -365,12 +414,113 @@ describe('initScene should receive defaultScene config by type', () => {
365
414
  .fn()
366
415
  .mockResolvedValue({ defaultSize: { width: 800, height: 600 } })
367
416
 
368
- initScene('sa', mockFn, { type: 'volume' })
417
+ initScene('sa-volume', mockFn, { type: 'volume' })
369
418
 
370
419
  expect(mockFn).toHaveBeenCalledWith(
371
420
  expect.objectContaining({
372
- defaultSize: { width: 0.94, height: 0.94, depth: 0.94 },
421
+ defaultSize: { width: '0.94m', height: '0.94m', depth: '0.94m' },
373
422
  }),
374
423
  )
375
424
  })
425
+
426
+ it('with volume type and merge pre', () => {
427
+ const cb = vi.fn().mockImplementation(pre => ({
428
+ ...pre,
429
+ defaultSize: { ...(pre.defaultSize as any), depth: '0.1m' },
430
+ }))
431
+
432
+ // pre-snapshot should be empty before initScene writes configMap
433
+ const preSnap = __getSceneConfigSnapshotForTest('sa')
434
+ expect(preSnap).toBeUndefined()
435
+
436
+ initScene('sa', cb, { type: 'volume' })
437
+
438
+ expect(cb).toHaveBeenCalledWith(
439
+ expect.objectContaining({
440
+ defaultSize: { width: '0.94m', height: '0.94m', depth: '0.94m' },
441
+ }),
442
+ )
443
+
444
+ const snap = __getSceneConfigSnapshotForTest('sa')
445
+ expect(snap).toEqual(
446
+ expect.objectContaining({
447
+ type: 'volume',
448
+ defaultSize: { width: 0.94, height: 0.94, depth: 0.1 },
449
+ }),
450
+ )
451
+ })
452
+ })
453
+
454
+ describe('initScene callback chaining', () => {
455
+ it('passes previous return value as next pre argument', () => {
456
+ const firstReturn = { defaultSize: { width: 1000, height: 1000 } }
457
+ const cb1 = vi.fn().mockReturnValue(firstReturn)
458
+ initScene('sa-chain', cb1)
459
+ expect(cb1).toHaveBeenCalledWith(
460
+ expect.objectContaining({ defaultSize: { width: 1280, height: 720 } }),
461
+ )
462
+
463
+ const cb2 = vi.fn().mockReturnValue({})
464
+ initScene('sa-chain', cb2)
465
+ expect(cb2).toHaveBeenCalledWith(firstReturn)
466
+ })
467
+ })
468
+
469
+ describe('hijackWindowATag', () => {
470
+ afterEach(() => {
471
+ document.body.innerHTML = ''
472
+ document.onclick = null
473
+ vi.restoreAllMocks()
474
+ })
475
+
476
+ it('handles clicks on nested elements inside anchor tags', () => {
477
+ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
478
+ hijackWindowATag(window)
479
+
480
+ const anchor = document.createElement('a')
481
+ anchor.href = 'https://example.com/detail'
482
+ anchor.target = '_blank'
483
+
484
+ const image = document.createElement('img')
485
+ anchor.appendChild(image)
486
+ document.body.appendChild(anchor)
487
+
488
+ image.dispatchEvent(
489
+ new MouseEvent('click', { bubbles: true, cancelable: true }),
490
+ )
491
+
492
+ expect(openSpy).toHaveBeenCalledWith('https://example.com/detail', '_blank')
493
+ })
494
+ })
495
+
496
+ describe('hijackWindowATag – defaultPrevented', () => {
497
+ afterEach(() => {
498
+ document.body.innerHTML = ''
499
+ document.onclick = null
500
+ vi.restoreAllMocks()
501
+ })
502
+
503
+ it('does not open a new window when the click was already preventDefault-ed', () => {
504
+ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
505
+ hijackWindowATag(window)
506
+
507
+ const anchor = document.createElement('a')
508
+ anchor.href = 'https://example.com/detail'
509
+ anchor.target = '_blank'
510
+
511
+ const span = document.createElement('span')
512
+ anchor.appendChild(span)
513
+ document.body.appendChild(anchor)
514
+
515
+ // Simulate an app-level handler that cancels the click before the polyfill sees it.
516
+ span.addEventListener('click', ev => {
517
+ ev.preventDefault()
518
+ })
519
+
520
+ span.dispatchEvent(
521
+ new MouseEvent('click', { bubbles: true, cancelable: true }),
522
+ )
523
+
524
+ expect(openSpy).not.toHaveBeenCalled()
525
+ })
376
526
  })