@webspatial/core-sdk 1.6.0 → 1.7.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/iife/index.d.ts +386 -236
  3. package/dist/iife/index.global.js +7 -7
  4. package/dist/iife/index.global.js.map +1 -1
  5. package/dist/index.d.ts +386 -236
  6. package/dist/index.js +1479 -1226
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/JSBCommand.ts +30 -100
  10. package/src/Spatial.ts +0 -21
  11. package/src/SpatialScene.ts +1 -5
  12. package/src/SpatialSession.ts +17 -3
  13. package/src/SpatializedDynamic3DElement.ts +0 -1
  14. package/src/SpatializedElementCreator.ts +6 -4
  15. package/src/SpatializedStatic3DElement.test.ts +99 -0
  16. package/src/SpatializedStatic3DElement.ts +99 -1
  17. package/src/WebMsgCommand.ts +10 -0
  18. package/src/coverage-boost.test.ts +38 -119
  19. package/src/index.ts +3 -1
  20. package/src/jsbcommand.coverage.test.ts +19 -48
  21. package/src/platform-adapter/CommandResultUtils.ts +2 -2
  22. package/src/platform-adapter/createPlatformSync.ts +34 -0
  23. package/src/platform-adapter/index.ts +5 -51
  24. package/src/platform-adapter/interface.ts +35 -23
  25. package/src/platform-adapter/pico-os/PicoOSPlatform.ts +84 -52
  26. package/src/platform-adapter/puppeteer/PuppeteerPlatform.ts +37 -11
  27. package/src/platform-adapter/spatialSceneQuery.ts +17 -0
  28. package/src/platform-adapter/ssr/SSRPlatform.ts +24 -15
  29. package/src/platform-adapter/vision-os/VisionOSPlatform.ts +55 -24
  30. package/src/platform-runtime.ts +13 -0
  31. package/src/reality/Attachment.ts +2 -2
  32. package/src/reality/entity/SpatialEntity.ts +0 -2
  33. package/src/reality/realityCreator.ts +15 -1
  34. package/src/reality/resource/SpatialTextureResource.ts +16 -0
  35. package/src/reality/resource/index.ts +1 -0
  36. package/src/runtime/WebSpatialRuntimeError.ts +16 -0
  37. package/src/runtime/capability-data.ts +113 -0
  38. package/src/runtime/contract-review.test.ts +44 -0
  39. package/src/runtime/index.ts +28 -0
  40. package/src/runtime/jsbAdapterPlatform.test.ts +36 -0
  41. package/src/runtime/jsbAdapterPlatform.ts +51 -0
  42. package/src/runtime/keys.ts +129 -0
  43. package/src/runtime/semver.ts +33 -0
  44. package/src/runtime/supports.test.ts +207 -0
  45. package/src/runtime/supports.ts +110 -0
  46. package/src/runtime/types.ts +11 -0
  47. package/src/runtime/userAgent.ts +64 -0
  48. package/src/scene-polyfill.manifest.test.ts +7 -5
  49. package/src/scene-polyfill.test.ts +60 -0
  50. package/src/scene-polyfill.ts +23 -22
  51. package/src/spatial-host.ts +25 -0
  52. package/src/types/{global.d.ts → global.ts} +10 -5
  53. package/src/types/types.ts +9 -0
  54. package/src/platform-adapter/android/AndroidPlatform.ts +0 -133
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Versioned capability rows transcribed from the product matrix (`capability-matrix.template.md`).
3
+ * visionOS **WSAppShell/1.5.0** & **1.6.0**; picoOS **PicoWebApp/0.1.1** & **0.1.2** — see matrix in OpenSpec / product docs.
4
+ *
5
+ * **picoOS** rows use dedicated builders (alpha2.0 / alpha2.1 subtokens); visionOS rows are separate.
6
+ */
7
+ import { SUB_TOKENS_BY_NAME, TOP_LEVEL_KEYS } from './keys'
8
+
9
+ export type CapabilityVersionRow = {
10
+ /** Semver string from `WSAppShell` / `PicoWebApp` (see `review.md` §4). */
11
+ version: string
12
+ /** Top-level keys and `name:subToken` compound keys. */
13
+ flags: Record<string, boolean>
14
+ }
15
+
16
+ function baseTrueFlags(): Record<string, boolean> {
17
+ const flags: Record<string, boolean> = {}
18
+ for (const k of TOP_LEVEL_KEYS) {
19
+ flags[k] = true
20
+ }
21
+ for (const [name, toks] of Object.entries(SUB_TOKENS_BY_NAME)) {
22
+ for (const t of toks) {
23
+ flags[`${name}:${t}`] = true
24
+ }
25
+ }
26
+ return flags
27
+ }
28
+
29
+ /**
30
+ * visionOS **WSAppShell/1.5.0**.
31
+ * Model: only `ready`, `currentSrc`, `entityTransform` among tracked sub-tokens are Y; SpatialRotateEvent:constrainedToAxis Y.
32
+ */
33
+ function matrixVision_1_5_0_Flags(): Record<string, boolean> {
34
+ const flags = baseTrueFlags()
35
+ const modelNo = [
36
+ 'autoplay',
37
+ 'loop',
38
+ 'stagemode',
39
+ 'poster',
40
+ 'loading',
41
+ 'source',
42
+ 'paused',
43
+ 'duration',
44
+ 'playbackRate',
45
+ 'play',
46
+ 'pause',
47
+ 'currentTime',
48
+ ] as const
49
+ for (const t of modelNo) {
50
+ flags[`Model:${t}`] = false
51
+ }
52
+ flags['SpatialRotateEvent:constrainedToAxis'] = true
53
+ return flags
54
+ }
55
+
56
+ /**
57
+ * visionOS **WSAppShell/1.6.0** — WebSpatial April / playback expansion (see product matrix).
58
+ * Model: `stagemode`, `poster`, `loading`, `currentTime` remain N; rest of tracked Model sub-tokens Y.
59
+ */
60
+ function matrixVision_1_6_0_Flags(): Record<string, boolean> {
61
+ const flags = baseTrueFlags()
62
+ for (const t of ['stagemode', 'poster', 'loading', 'currentTime'] as const) {
63
+ flags[`Model:${t}`] = false
64
+ }
65
+ flags['SpatialRotateEvent:constrainedToAxis'] = true
66
+ return flags
67
+ }
68
+
69
+ /**
70
+ * picoOS **PicoWebApp/0.1.1** — alpha2.0 baseline (product matrix).
71
+ * WindowScene / VolumeScene / Material subtokens Y; Model sub-tokens per matrix; **`xrInnerDepth` / `xrOuterDepth` N**.
72
+ */
73
+ function matrixPico_0_1_1_Flags(): Record<string, boolean> {
74
+ const flags = matrixVision_1_5_0_Flags()
75
+ flags.xrInnerDepth = false
76
+ flags.xrOuterDepth = false
77
+ return flags
78
+ }
79
+
80
+ /**
81
+ * picoOS **PicoWebApp/0.1.2** — alpha2.1 playback expansion (product matrix).
82
+ * **`xrInnerDepth` / `xrOuterDepth` N** (same as 0.1.1).
83
+ */
84
+ function matrixPico_0_1_2_Flags(): Record<string, boolean> {
85
+ const flags = matrixVision_1_6_0_Flags()
86
+ flags.xrInnerDepth = false
87
+ flags.xrOuterDepth = false
88
+ return flags
89
+ }
90
+
91
+ function visionOsRow_1_5_0(): CapabilityVersionRow {
92
+ return { version: '1.5.0', flags: matrixVision_1_5_0_Flags() }
93
+ }
94
+
95
+ function visionOsRow_1_6_0(): CapabilityVersionRow {
96
+ return { version: '1.6.0', flags: matrixVision_1_6_0_Flags() }
97
+ }
98
+
99
+ function picoOsRow_0_1_1(): CapabilityVersionRow {
100
+ return { version: '0.1.1', flags: matrixPico_0_1_1_Flags() }
101
+ }
102
+
103
+ function picoOsRow_0_1_2(): CapabilityVersionRow {
104
+ return { version: '0.1.2', flags: matrixPico_0_1_2_Flags() }
105
+ }
106
+
107
+ export const CAPABILITY_TABLE: {
108
+ visionos: CapabilityVersionRow[]
109
+ picoos: CapabilityVersionRow[]
110
+ } = {
111
+ visionos: [visionOsRow_1_5_0(), visionOsRow_1_6_0()],
112
+ picoos: [picoOsRow_0_1_1(), picoOsRow_0_1_2()],
113
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test, vi, beforeEach } from 'vitest'
2
+ import { ELEMENT_DOM_DEPTH_KEYS, WINDOW_DOM_DEPTH_KEYS } from './keys'
3
+
4
+ describe('OpenSpec review.md §3.5.1 — Window DOM depth (xrInnerDepth / xrOuterDepth)', () => {
5
+ beforeEach(() => {
6
+ vi.resetModules()
7
+ })
8
+
9
+ test('plain browser UA: unsupported keys absent from window surface', async () => {
10
+ vi.stubGlobal('navigator', {
11
+ userAgent:
12
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36',
13
+ } as Navigator)
14
+ const { supports, resetRuntimeCacheForTests } = await import('./supports')
15
+ resetRuntimeCacheForTests()
16
+
17
+ for (const key of WINDOW_DOM_DEPTH_KEYS) {
18
+ expect(supports(key)).toBe(false)
19
+ expect(key in window).toBe(false)
20
+ expect(Object.prototype.hasOwnProperty.call(window, key)).toBe(false)
21
+ }
22
+ })
23
+ })
24
+
25
+ describe('OpenSpec review.md §3.5.2 — Element depth keys must not use Window surface', () => {
26
+ beforeEach(() => {
27
+ vi.resetModules()
28
+ })
29
+
30
+ test('plain browser: xrClientDepth / xrOffsetBack are not on window (live on spatialized ref)', async () => {
31
+ vi.stubGlobal('navigator', {
32
+ userAgent:
33
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36',
34
+ } as Navigator)
35
+ const { supports, resetRuntimeCacheForTests } = await import('./supports')
36
+ resetRuntimeCacheForTests()
37
+
38
+ for (const key of ELEMENT_DOM_DEPTH_KEYS) {
39
+ expect(supports(key)).toBe(false)
40
+ expect(key in window).toBe(false)
41
+ expect(Object.prototype.hasOwnProperty.call(window, key)).toBe(false)
42
+ }
43
+ })
44
+ })
@@ -0,0 +1,28 @@
1
+ export type { WebSpatialRuntimeSnapshot, WebSpatialRuntimeType } from './types'
2
+ export { WebSpatialRuntimeError } from './WebSpatialRuntimeError'
3
+ export {
4
+ supports,
5
+ getRuntime,
6
+ resetRuntimeCacheForTests,
7
+ VISIONOS_DEBUG_SHELL_VERSION_PLACEHOLDER,
8
+ } from './supports'
9
+ export { compareSemver, parseSemverOrNull } from './semver'
10
+ export { computeRuntimeFromUserAgent, parseShellToken } from './userAgent'
11
+ export {
12
+ resolveJsbAdapterPlatform,
13
+ type JsbAdapterPlatformKind,
14
+ } from './jsbAdapterPlatform'
15
+ export type { CapabilityVersionRow } from './capability-data'
16
+ export { CAPABILITY_TABLE } from './capability-data'
17
+ export type { CapabilityKey } from './keys'
18
+ export {
19
+ TOP_LEVEL_KEYS,
20
+ SUB_TOKENS_BY_NAME,
21
+ COMPONENT_KEYS,
22
+ CSS_KEYS,
23
+ GESTURE_KEYS,
24
+ JS_SCENE_KEYS,
25
+ DOM_DEPTH_KEYS,
26
+ ELEMENT_DOM_DEPTH_KEYS,
27
+ WINDOW_DOM_DEPTH_KEYS,
28
+ } from './keys'
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { resolveJsbAdapterPlatform } from './jsbAdapterPlatform'
3
+
4
+ describe('resolveJsbAdapterPlatform', () => {
5
+ test('Puppeteer UA maps to puppeteer', () => {
6
+ expect(
7
+ resolveJsbAdapterPlatform(
8
+ 'Mozilla/5.0 Puppeteer Chrome/120 WSAppShell/1.5.0 WebSpatial/1.5.0',
9
+ ),
10
+ ).toBe('puppeteer')
11
+ })
12
+
13
+ test('PicoWebApp with WebSpatial > 0.0.1 maps to picoos', () => {
14
+ expect(
15
+ resolveJsbAdapterPlatform(
16
+ 'Mozilla/5.0 PicoWebApp/0.1.1 WebSpatial/1.5.0',
17
+ ),
18
+ ).toBe('picoos')
19
+ })
20
+
21
+ test('Linux without Pico pico branch maps to visionos', () => {
22
+ expect(
23
+ resolveJsbAdapterPlatform(
24
+ 'Mozilla/5.0 (X11; Linux x86_64) WSAppShell/1.5.0 WebSpatial/1.5.0',
25
+ ),
26
+ ).toBe('visionos')
27
+ })
28
+
29
+ test('Mac visionOS-class UA maps to visionos', () => {
30
+ expect(
31
+ resolveJsbAdapterPlatform(
32
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) WSAppShell/1.5.0 WebSpatial/1.5.0',
33
+ ),
34
+ ).toBe('visionos')
35
+ })
36
+ })
@@ -0,0 +1,51 @@
1
+ import { computeRuntimeFromUserAgent } from './userAgent'
2
+
3
+ function getWebSpatialVersion(ua: string): number[] | null {
4
+ const match = ua.match(/WebSpatial\/(\d+)\.(\d+)\.(\d+)/)
5
+ if (!match) {
6
+ return null
7
+ }
8
+ return [Number(match[1]), Number(match[2]), Number(match[3])]
9
+ }
10
+
11
+ function isVersionGreater(a: number[] | null, b: number[]): boolean {
12
+ if (!a) {
13
+ return false
14
+ }
15
+ for (let index = 0; index < 3; index += 1) {
16
+ const diff = a[index] - b[index]
17
+ if (diff > 0) {
18
+ return true
19
+ }
20
+ if (diff < 0) {
21
+ return false
22
+ }
23
+ }
24
+ return false
25
+ }
26
+
27
+ /**
28
+ * Which native JSB / protocol adapter to load in the browser.
29
+ * Derived from {@link computeRuntimeFromUserAgent} plus legacy fallbacks
30
+ * (Pico WebSpatial semver gate; otherwise default visionOS shell).
31
+ */
32
+ export type JsbAdapterPlatformKind = 'puppeteer' | 'picoos' | 'visionos'
33
+
34
+ export function resolveJsbAdapterPlatform(
35
+ userAgent: string,
36
+ ): JsbAdapterPlatformKind {
37
+ const rt = computeRuntimeFromUserAgent(userAgent)
38
+ if (rt.type === 'puppeteer') {
39
+ return 'puppeteer'
40
+ }
41
+
42
+ const webSpatialVersion = getWebSpatialVersion(userAgent)
43
+ if (
44
+ userAgent.includes('PicoWebApp') &&
45
+ isVersionGreater(webSpatialVersion, [0, 0, 1])
46
+ ) {
47
+ return 'picoos'
48
+ }
49
+
50
+ return 'visionos'
51
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Canonical `supports(name)` keys (OpenSpec `review.md` §3).
3
+ * Aliases are normalized before lookup (see `normalizeCapabilityName`).
4
+ */
5
+
6
+ export const COMPONENT_KEYS = [
7
+ 'Model',
8
+ 'Reality',
9
+ 'Entity',
10
+ 'BoxEntity',
11
+ 'SphereEntity',
12
+ 'ConeEntity',
13
+ 'CylinderEntity',
14
+ 'PlaneEntity',
15
+ 'SceneGraph',
16
+ 'ModelAsset',
17
+ 'ModelEntity',
18
+ 'UnlitMaterial',
19
+ 'Material',
20
+ 'AttachmentAsset',
21
+ 'AttachmentEntity',
22
+ ] as const
23
+
24
+ export const CSS_KEYS = [
25
+ '-xr-background-material',
26
+ '-xr-back',
27
+ '-xr-depth',
28
+ '-xr-transform',
29
+ ] as const
30
+
31
+ export const GESTURE_KEYS = [
32
+ 'SpatialTapEvent',
33
+ 'SpatialDragStartEvent',
34
+ 'SpatialDragEvent',
35
+ 'SpatialDragEndEvent',
36
+ 'SpatialRotateEvent',
37
+ 'SpatialRotateEndEvent',
38
+ 'SpatialMagnifyEvent',
39
+ 'SpatialMagnifyEndEvent',
40
+ ] as const
41
+
42
+ export const JS_SCENE_KEYS = [
43
+ 'useMetrics',
44
+ 'convertCoordinate',
45
+ 'initScene',
46
+ 'WindowScene',
47
+ 'VolumeScene',
48
+ ] as const
49
+
50
+ /**
51
+ * Spatialized container `ref` readbacks (`HTMLElement` / React `useDomProxy` Proxy).
52
+ * See `global.d.ts` (`HTMLElement`) and `useDomProxy.ts` (`has` / `get` traps).
53
+ */
54
+ export const ELEMENT_DOM_DEPTH_KEYS = ['xrClientDepth', 'xrOffsetBack'] as const
55
+
56
+ /**
57
+ * Scene / global readbacks on `Window` (`global.d.ts` → `Window`).
58
+ */
59
+ export const WINDOW_DOM_DEPTH_KEYS = ['xrInnerDepth', 'xrOuterDepth'] as const
60
+
61
+ export const DOM_DEPTH_KEYS = [
62
+ ...ELEMENT_DOM_DEPTH_KEYS,
63
+ ...WINDOW_DOM_DEPTH_KEYS,
64
+ ] as const
65
+
66
+ /** Top-level names accepted by `supports(name)` (canonical keys after alias normalization). */
67
+ export const TOP_LEVEL_KEYS = [
68
+ ...COMPONENT_KEYS,
69
+ ...CSS_KEYS,
70
+ ...GESTURE_KEYS,
71
+ ...JS_SCENE_KEYS,
72
+ ...DOM_DEPTH_KEYS,
73
+ ] as const
74
+
75
+ /** Union of documented capability keys; unknown strings may still be passed to `supports` (returns `false`). */
76
+ export type CapabilityKey = (typeof TOP_LEVEL_KEYS)[number]
77
+
78
+ const ALIAS_TO_CANONICAL: Record<string, string> = {
79
+ Box: 'BoxEntity',
80
+ Sphere: 'SphereEntity',
81
+ Cone: 'ConeEntity',
82
+ Cylinder: 'CylinderEntity',
83
+ Plane: 'PlaneEntity',
84
+ World: 'SceneGraph',
85
+ }
86
+
87
+ export function normalizeCapabilityName(name: string): string {
88
+ return ALIAS_TO_CANONICAL[name] ?? name
89
+ }
90
+
91
+ /** Known sub-tokens per canonical top-level `name` (AND semantics). */
92
+ export const SUB_TOKENS_BY_NAME: Readonly<Record<string, readonly string[]>> = {
93
+ Material: ['unlit'],
94
+ WindowScene: ['defaultSize', 'resizability'],
95
+ VolumeScene: [
96
+ 'defaultSize',
97
+ 'resizability',
98
+ 'worldScaling',
99
+ 'worldAlignment',
100
+ 'baseplateVisibility',
101
+ ],
102
+ SpatialRotateEvent: ['constrainedToAxis'],
103
+ Model: [
104
+ 'autoplay',
105
+ 'loop',
106
+ 'stagemode',
107
+ 'poster',
108
+ 'loading',
109
+ 'source',
110
+ 'ready',
111
+ 'currentSrc',
112
+ 'entityTransform',
113
+ 'paused',
114
+ 'duration',
115
+ 'playbackRate',
116
+ 'play',
117
+ 'pause',
118
+ 'currentTime',
119
+ ],
120
+ }
121
+
122
+ export function isKnownTopLevel(name: string): boolean {
123
+ return (TOP_LEVEL_KEYS as readonly string[]).includes(name)
124
+ }
125
+
126
+ export function isKnownSubToken(name: string, token: string): boolean {
127
+ const allowed = SUB_TOKENS_BY_NAME[name]
128
+ return allowed !== undefined && allowed.includes(token)
129
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Minimal semver compare for capability table rows (`major.minor.patch`).
3
+ * Returns negative if a < b, zero if equal, positive if a > b.
4
+ */
5
+ export function compareSemver(a: string, b: string): number {
6
+ const pa = parseSemverParts(a)
7
+ const pb = parseSemverParts(b)
8
+ if (!pa || !pb) {
9
+ return String(a).localeCompare(String(b))
10
+ }
11
+ const len = Math.max(pa.length, pb.length)
12
+ for (let i = 0; i < len; i++) {
13
+ const da = pa[i] ?? 0
14
+ const db = pb[i] ?? 0
15
+ if (da !== db) return da - db
16
+ }
17
+ return 0
18
+ }
19
+
20
+ function parseSemverParts(v: string): number[] | null {
21
+ const m = /^(\d+)(?:\.(\d+)(?:\.(\d+))?)?/.exec(v.trim())
22
+ if (!m) return null
23
+ const major = Number(m[1])
24
+ const minor = m[2] !== undefined ? Number(m[2]) : 0
25
+ const patch = m[3] !== undefined ? Number(m[3]) : 0
26
+ if ([major, minor, patch].some(n => Number.isNaN(n))) return null
27
+ return [major, minor, patch]
28
+ }
29
+
30
+ export function parseSemverOrNull(v: string): string | null {
31
+ const m = /^(\d+(?:\.\d+){0,2})/.exec(v.trim())
32
+ return m ? m[1] : null
33
+ }
@@ -0,0 +1,207 @@
1
+ import { describe, expect, test, vi, beforeEach } from 'vitest'
2
+
3
+ describe('getRuntime / supports', () => {
4
+ beforeEach(() => {
5
+ vi.resetModules()
6
+ })
7
+
8
+ test('plain browser: no shell token → supports is false', async () => {
9
+ vi.stubGlobal('navigator', {
10
+ userAgent:
11
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36',
12
+ } as Navigator)
13
+ const { supports, resetRuntimeCacheForTests } = await import('./supports')
14
+ resetRuntimeCacheForTests()
15
+ expect(supports('Model')).toBe(false)
16
+ expect(supports('useMetrics')).toBe(false)
17
+ })
18
+
19
+ test('like Quest without Pico tokens does not force picoos (WSAppShell + no Mac → type null)', async () => {
20
+ vi.stubGlobal('navigator', {
21
+ userAgent:
22
+ 'Mozilla/5.0 (X11; Linux x86_64; unknown OS0.11.0 like Quest) AppleWebKit/537.36 WSAppShell/1.5.0 WebSpatial/1.5.0',
23
+ } as Navigator)
24
+ const { supports, getRuntime, resetRuntimeCacheForTests } = await import(
25
+ './supports'
26
+ )
27
+ resetRuntimeCacheForTests()
28
+ const rt = getRuntime()
29
+ expect(rt.type).toBe(null)
30
+ expect(rt.shellVersion).toBe('1.5.0')
31
+ expect(supports('Model')).toBe(false)
32
+ })
33
+
34
+ test('WSAppShell without Mac OS X: not visionos; spatial supports false', async () => {
35
+ vi.stubGlobal('navigator', {
36
+ userAgent:
37
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 WSAppShell/9.9.9 WebSpatial/1.5.0',
38
+ } as Navigator)
39
+ const { supports, getRuntime, resetRuntimeCacheForTests } = await import(
40
+ './supports'
41
+ )
42
+ resetRuntimeCacheForTests()
43
+ const rt = getRuntime()
44
+ expect(rt.type).toBe(null)
45
+ expect(rt.shellVersion).toBe('9.9.9')
46
+ expect(supports('Model')).toBe(false)
47
+ })
48
+
49
+ test('visionOS UA with WSAppShell: resolves and Model can be true (stub table)', async () => {
50
+ vi.stubGlobal('navigator', {
51
+ userAgent:
52
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7; wv) AppleWebKit/605.1.15 (KHTML, like Gecko) WSAppShell/1.5.0 WebSpatial/1.5.0 Safari/537.36',
53
+ } as Navigator)
54
+ const { supports, getRuntime, resetRuntimeCacheForTests } = await import(
55
+ './supports'
56
+ )
57
+ resetRuntimeCacheForTests()
58
+ const rt = getRuntime()
59
+ expect(rt.type).toBe('visionos')
60
+ expect(rt.shellVersion).toBe('1.5.0')
61
+ expect(supports('Model')).toBe(true)
62
+ expect(supports('xrInnerDepth')).toBe(true)
63
+ expect(supports('xrOuterDepth')).toBe(true)
64
+ expect(supports('UnknownThing' as any)).toBe(false)
65
+ })
66
+
67
+ test('pico UA: type picoos; matrix 0.1.1 sub-tokens (alpha2.0 baseline)', async () => {
68
+ vi.stubGlobal('navigator', {
69
+ userAgent:
70
+ 'Mozilla/5.0 (X11; Linux x86_64; unknown OS0.11.0 like Quest) AppleWebKit/537.36 PicoWebApp/0.1.1 (like PicoBrowser) Chrome/138.0 WebSpatial/1.5.0',
71
+ } as Navigator)
72
+ const { supports, resetRuntimeCacheForTests } = await import('./supports')
73
+ resetRuntimeCacheForTests()
74
+ expect(supports('initScene')).toBe(true)
75
+ expect(supports('Model', ['not-a-token' as any])).toBe(false)
76
+ expect(supports('Model', ['source'])).toBe(false)
77
+ expect(supports('Model', ['ready', 'currentSrc'])).toBe(true)
78
+ expect(supports('Model', ['stagemode'])).toBe(false)
79
+ expect(supports('WindowScene', ['defaultSize', 'resizability'])).toBe(true)
80
+ expect(supports('xrInnerDepth')).toBe(false)
81
+ expect(supports('xrOuterDepth')).toBe(false)
82
+ })
83
+
84
+ test('pico UA PicoWebApp/0.1.2: matrix playback row (alpha2.0)', async () => {
85
+ vi.stubGlobal('navigator', {
86
+ userAgent:
87
+ 'Mozilla/5.0 (X11; Linux x86_64; unknown OS0.11.0 like Quest) AppleWebKit/537.36 PicoWebApp/0.1.2 (like PicoBrowser) Chrome/138.0 WebSpatial/1.5.0',
88
+ } as Navigator)
89
+ const { supports, resetRuntimeCacheForTests } = await import('./supports')
90
+ resetRuntimeCacheForTests()
91
+ expect(supports('Model', ['autoplay', 'loop', 'source'])).toBe(true)
92
+ expect(supports('Model', ['currentTime'])).toBe(false)
93
+ expect(supports('Model', ['poster'])).toBe(false)
94
+ expect(supports('xrInnerDepth')).toBe(false)
95
+ expect(supports('xrOuterDepth')).toBe(false)
96
+ })
97
+
98
+ test('alias Box → BoxEntity', async () => {
99
+ vi.stubGlobal('navigator', {
100
+ userAgent:
101
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 WSAppShell/1.5.0 WebSpatial/1.5.0',
102
+ } as Navigator)
103
+ const { supports, resetRuntimeCacheForTests } = await import('./supports')
104
+ resetRuntimeCacheForTests()
105
+ expect(supports('Box')).toBe(supports('BoxEntity'))
106
+ })
107
+
108
+ test('supports(name, []) ≡ supports(name)', async () => {
109
+ vi.stubGlobal('navigator', {
110
+ userAgent:
111
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 WSAppShell/1.5.0 WebSpatial/1.5.0',
112
+ } as Navigator)
113
+ const { supports, resetRuntimeCacheForTests } = await import('./supports')
114
+ resetRuntimeCacheForTests()
115
+ expect(supports('Reality')).toBe(supports('Reality', []))
116
+ })
117
+
118
+ test('initScene is top-level only; WindowScene / VolumeScene sub-tokens', async () => {
119
+ vi.stubGlobal('navigator', {
120
+ userAgent:
121
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 WSAppShell/1.5.0 WebSpatial/1.5.0',
122
+ } as Navigator)
123
+ const { supports, resetRuntimeCacheForTests } = await import('./supports')
124
+ resetRuntimeCacheForTests()
125
+ expect(supports('initScene')).toBe(true)
126
+ expect(supports('initScene', ['defaultSize' as any])).toBe(false)
127
+ expect(supports('WindowScene')).toBe(true)
128
+ expect(supports('WindowScene', ['defaultSize', 'resizability'])).toBe(true)
129
+ expect(supports('VolumeScene', ['baseplateVisibility'])).toBe(true)
130
+ })
131
+
132
+ test('Puppeteer UA: type puppeteer; all valid supports() true', async () => {
133
+ vi.stubGlobal('navigator', {
134
+ userAgent:
135
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Puppeteer WSAppShell/1.5.0 WebSpatial/1.5.0',
136
+ } as Navigator)
137
+ const { supports, getRuntime, resetRuntimeCacheForTests } = await import(
138
+ './supports'
139
+ )
140
+ resetRuntimeCacheForTests()
141
+ const rt = getRuntime()
142
+ expect(rt.type).toBe('puppeteer')
143
+ expect(rt.shellVersion).toBe('1.5.0')
144
+ expect(supports('Model')).toBe(true)
145
+ expect(supports('Model', ['poster'])).toBe(true)
146
+ expect(supports('UnknownThing' as any)).toBe(false)
147
+ expect(supports('Model', ['not-a-token' as any])).toBe(false)
148
+ })
149
+
150
+ test('Puppeteer UA without shell token: still puppeteer; valid supports() true', async () => {
151
+ vi.stubGlobal('navigator', {
152
+ userAgent: 'Mozilla/5.0 Puppeteer HeadlessChrome/120.0.0.0',
153
+ } as Navigator)
154
+ const { supports, getRuntime, resetRuntimeCacheForTests } = await import(
155
+ './supports'
156
+ )
157
+ resetRuntimeCacheForTests()
158
+ expect(getRuntime().type).toBe('puppeteer')
159
+ expect(getRuntime().shellVersion).toBe(null)
160
+ expect(supports('Model')).toBe(true)
161
+ })
162
+
163
+ test('visionOS WS_SHELL_VERSION placeholder: debug mode; all valid supports() true', async () => {
164
+ vi.stubGlobal('navigator', {
165
+ userAgent:
166
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7; wv) AppleWebKit/605.1.15 WSAppShell/WS_SHELL_VERSION WebSpatial/1.5.0 Safari/537.36',
167
+ } as Navigator)
168
+ const { supports, getRuntime, resetRuntimeCacheForTests } = await import(
169
+ './supports'
170
+ )
171
+ resetRuntimeCacheForTests()
172
+ const rt = getRuntime()
173
+ expect(rt.type).toBe('visionos')
174
+ expect(rt.shellVersion).toBe('WS_SHELL_VERSION')
175
+ expect(supports('Model')).toBe(true)
176
+ expect(supports('Model', ['poster'])).toBe(true)
177
+ expect(supports('Model', ['stagemode'])).toBe(true)
178
+ expect(supports('UnknownThing' as any)).toBe(false)
179
+ expect(supports('Model', ['not-a-token' as any])).toBe(false)
180
+ })
181
+
182
+ test('visionOS WSAppShell/1.5.0: Model sub-tokens (alpha2.0 baseline)', async () => {
183
+ vi.stubGlobal('navigator', {
184
+ userAgent:
185
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 WSAppShell/1.5.0 WebSpatial/1.5.0',
186
+ } as Navigator)
187
+ const { supports, resetRuntimeCacheForTests } = await import('./supports')
188
+ resetRuntimeCacheForTests()
189
+ expect(supports('Model', ['autoplay'])).toBe(false)
190
+ expect(supports('Model', ['poster'])).toBe(false)
191
+ expect(supports('Model', ['ready', 'currentSrc'])).toBe(true)
192
+ expect(supports('Model', ['currentTime'])).toBe(false)
193
+ })
194
+
195
+ test('visionOS WSAppShell/1.6.0: Model playback row (WebSpatial April)', async () => {
196
+ vi.stubGlobal('navigator', {
197
+ userAgent:
198
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7; wv) AppleWebKit/605.1.15 (KHTML, like Gecko) WSAppShell/1.6.0 WebSpatial/1.5.0 Safari/537.36',
199
+ } as Navigator)
200
+ const { supports, resetRuntimeCacheForTests } = await import('./supports')
201
+ resetRuntimeCacheForTests()
202
+ expect(supports('Model', ['autoplay', 'loop', 'source'])).toBe(true)
203
+ expect(supports('Model', ['currentTime'])).toBe(false)
204
+ expect(supports('Model', ['poster'])).toBe(false)
205
+ expect(supports('Model', ['play', 'pause', 'duration'])).toBe(true)
206
+ })
207
+ })