@wix/zero-config-implementation 1.85.0 → 1.87.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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "registry": "https://registry.npmjs.org/",
5
5
  "access": "public"
6
6
  },
7
- "version": "1.85.0",
7
+ "version": "1.87.0",
8
8
  "description": "Core library for extracting component manifests from JS and CSS files",
9
9
  "type": "module",
10
10
  "main": "dist/index.js",
@@ -39,8 +39,32 @@
39
39
  }
40
40
  },
41
41
  "dependencies": {
42
- "@wix/builder-services-wrapper": "^1.56.0",
43
- "@wix/react-component-schema": "1.8.0"
42
+ "@wix/react-component-schema": "1.8.0",
43
+ "@wix/seo-service": "^1.9.0",
44
+ "@wix/services-manager": "^1.0.7",
45
+ "@wix/services-manager-react": "^1.0.8",
46
+ "@wix/site-service-configuration": "^1.0.1",
47
+ "@wix/site-service-device-info": "^1.0.2",
48
+ "@wix/site-service-editor-context": "^1.0.1",
49
+ "@wix/site-service-experiments": "^1.0.3",
50
+ "@wix/site-service-locale": "^1.0.2",
51
+ "@wix/site-service-pages": "^1.0.2",
52
+ "@wix/site-service-provide-css": "^1.0.1",
53
+ "@wix/site-service-rendering-context": "^1.0.1",
54
+ "@wix/site-service-url": "^1.0.1",
55
+ "@wix/viewer-service-anchors": "^1.0.40",
56
+ "@wix/viewer-service-consent-policy": "^1.0.100",
57
+ "@wix/viewer-service-environment": "^1.0.47",
58
+ "@wix/viewer-service-link-utils": "^1.0.62",
59
+ "@wix/viewer-service-named-signals": "^1.0.16",
60
+ "@wix/viewer-service-pages": "^1.0.9",
61
+ "@wix/viewer-service-provide-css": "^1.0.31",
62
+ "@wix/viewer-service-renderer-configuration": "^1.0.7",
63
+ "@wix/viewer-service-sdk-state": "^1.0.28",
64
+ "@wix/viewer-service-site-scroll-blocker": "^1.0.63",
65
+ "@wix/viewer-service-topology": "^1.0.26",
66
+ "@wix/viewer-service-translations": "^1.0.15",
67
+ "@wix/viewer-service-url": "^1.0.69"
44
68
  },
45
69
  "devDependencies": {
46
70
  "@faker-js/faker": "^10.2.0",
@@ -82,5 +106,5 @@
82
106
  ]
83
107
  }
84
108
  },
85
- "falconPackageHash": "57c46654a2a7b434be3282e37ce2fff52e061ef822797e00a88ba528"
109
+ "falconPackageHash": "1db79cb96c2d3ad1aa00f8c608385635757b7899b0f11f7a74f228e4"
86
110
  }
@@ -0,0 +1,149 @@
1
+ import { DISPLAY_GROUPS } from '@wix/react-component-schema'
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import type { CoupledProp } from '../information-extractors/react/types'
4
+ import { buildActiveItemDisplayGroups, buildActiveItemIndexDisplayFilters } from './active-item-index-builder'
5
+
6
+ function arrayProp(name: string): CoupledProp {
7
+ return { name, type: 'Array<Item>', resolvedType: { kind: 'array' } } as unknown as CoupledProp
8
+ }
9
+
10
+ function activeIndexProp(name: string, targetArrayProp: string): CoupledProp {
11
+ return {
12
+ name,
13
+ type: 'number',
14
+ resolvedType: { kind: 'primitive', value: 'number' },
15
+ activeItemIndexTarget: targetArrayProp,
16
+ } as unknown as CoupledProp
17
+ }
18
+
19
+ function numberProp(name: string): CoupledProp {
20
+ return { name, type: 'number', resolvedType: { kind: 'primitive', value: 'number' } } as unknown as CoupledProp
21
+ }
22
+
23
+ afterEach(() => {
24
+ vi.restoreAllMocks()
25
+ })
26
+
27
+ describe('buildActiveItemDisplayGroups', () => {
28
+ it('emits displayGroups when ActiveItemIndex pairs with an array prop', () => {
29
+ const props = {
30
+ tabs: arrayProp('tabs'),
31
+ activeTab: activeIndexProp('activeTab', 'tabs'),
32
+ }
33
+
34
+ expect(buildActiveItemDisplayGroups(props)).toEqual({
35
+ tabs: {
36
+ displayName: 'Tab',
37
+ groupType: DISPLAY_GROUPS.GROUP_TYPE.arrayItems,
38
+ arrayItems: {
39
+ arrayItems: 'tabs',
40
+ selectedItemIndex: 'activeTab',
41
+ },
42
+ },
43
+ })
44
+ })
45
+
46
+ it('keeps only the first ActiveItemIndex link when multiple pairs exist', () => {
47
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
48
+ const props = {
49
+ tabs: arrayProp('tabs'),
50
+ activeTab: activeIndexProp('activeTab', 'tabs'),
51
+ slides: arrayProp('slides'),
52
+ activeSlide: activeIndexProp('activeSlide', 'slides'),
53
+ }
54
+
55
+ expect(buildActiveItemDisplayGroups(props)).toEqual({
56
+ tabs: {
57
+ displayName: 'Tab',
58
+ groupType: DISPLAY_GROUPS.GROUP_TYPE.arrayItems,
59
+ arrayItems: {
60
+ arrayItems: 'tabs',
61
+ selectedItemIndex: 'activeTab',
62
+ },
63
+ },
64
+ })
65
+ expect(warnSpy).toHaveBeenCalledWith(
66
+ 'Multiple ActiveItemIndex<> props found; only "activeTab" -> "tabs" is used. Dropping "activeSlide" -> "slides".',
67
+ )
68
+ })
69
+
70
+ it('keeps the first link and drops later indexes targeting the same array', () => {
71
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
72
+ const props = {
73
+ tabs: arrayProp('tabs'),
74
+ activeTab: activeIndexProp('activeTab', 'tabs'),
75
+ selectedTab: activeIndexProp('selectedTab', 'tabs'),
76
+ }
77
+
78
+ expect(buildActiveItemDisplayGroups(props)).toEqual({
79
+ tabs: {
80
+ displayName: 'Tab',
81
+ groupType: DISPLAY_GROUPS.GROUP_TYPE.arrayItems,
82
+ arrayItems: {
83
+ arrayItems: 'tabs',
84
+ selectedItemIndex: 'activeTab',
85
+ },
86
+ },
87
+ })
88
+ expect(warnSpy).toHaveBeenCalledWith(
89
+ 'ActiveItemIndex<> link "selectedTab" -> "tabs" conflicts with an existing link — dropping.',
90
+ )
91
+ })
92
+
93
+ it('returns undefined when no ActiveItemIndex props exist', () => {
94
+ const props = {
95
+ tabs: arrayProp('tabs'),
96
+ count: numberProp('count'),
97
+ }
98
+
99
+ expect(buildActiveItemDisplayGroups(props)).toBeUndefined()
100
+ })
101
+
102
+ it('returns undefined when the target array prop does not exist', () => {
103
+ const props = {
104
+ activeTab: activeIndexProp('activeTab', 'nonexistent'),
105
+ }
106
+
107
+ expect(buildActiveItemDisplayGroups(props)).toBeUndefined()
108
+ })
109
+
110
+ it('returns undefined when the target prop is not an array type', () => {
111
+ const props = {
112
+ tabs: numberProp('tabs'),
113
+ activeTab: activeIndexProp('activeTab', 'tabs'),
114
+ }
115
+
116
+ expect(buildActiveItemDisplayGroups(props)).toBeUndefined()
117
+ })
118
+ })
119
+
120
+ describe('buildActiveItemIndexDisplayFilters', () => {
121
+ it('hides active index props from Settings data panel', () => {
122
+ const props = {
123
+ tabs: arrayProp('tabs'),
124
+ activeTab: activeIndexProp('activeTab', 'tabs'),
125
+ }
126
+
127
+ expect(buildActiveItemIndexDisplayFilters(props)).toEqual({
128
+ data: { hide: ['activeTab'] },
129
+ })
130
+ })
131
+
132
+ it('returns undefined when no ActiveItemIndex props exist', () => {
133
+ const props = {
134
+ tabs: arrayProp('tabs'),
135
+ count: numberProp('count'),
136
+ }
137
+
138
+ expect(buildActiveItemIndexDisplayFilters(props)).toBeUndefined()
139
+ })
140
+
141
+ it('returns undefined when the target prop is not an array type', () => {
142
+ const props = {
143
+ tabs: numberProp('tabs'),
144
+ activeTab: activeIndexProp('activeTab', 'tabs'),
145
+ }
146
+
147
+ expect(buildActiveItemIndexDisplayFilters(props)).toBeUndefined()
148
+ })
149
+ })
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Manifest emission for the `ActiveItemIndex<'<arrayProp>'>` marker type.
3
+ *
4
+ * The TS extractor sets `activeItemIndexTarget` on index props. This module is the
5
+ * single place that interprets that marker for the generated manifest:
6
+ *
7
+ * - `buildActiveItemDisplayGroups` → `displayGroups.<arrayProp>` (`arrayItems` hat)
8
+ * - `buildActiveItemIndexDisplayFilters` → `displayFilters.data.hide` (hide raw index)
9
+ *
10
+ * `displayGroups` and `displayFilters` stay separate exports because they are
11
+ * orthogonal manifest fields — they only share the same trigger here.
12
+ *
13
+ * Default value `0` for unset index props is applied in `buildData` inside
14
+ * `to-editor-component`, not here.
15
+ *
16
+ * Each index prop and array prop may participate in at most one link. Conflicts
17
+ * are logged and dropped; only the first valid link is emitted (one hat per component).
18
+ */
19
+
20
+ import type { DisplayFilters, DisplayGroupItem } from '@wix/react-component-schema'
21
+ import { DISPLAY_GROUPS } from '@wix/react-component-schema'
22
+ import type { CoupledProp } from '../information-extractors/react'
23
+ import { formatDisplayName, singularize } from './utils'
24
+
25
+ interface ActiveItemIndexLink {
26
+ indexPropName: string
27
+ arrayPropName: string
28
+ }
29
+
30
+ export function buildActiveItemDisplayGroups(
31
+ props: Record<string, CoupledProp>,
32
+ ): Record<string, DisplayGroupItem> | undefined {
33
+ const groups: Record<string, DisplayGroupItem> = {}
34
+ const activeItemIndexLinks = collectValidActiveItemIndexLinks(props)
35
+
36
+ for (const { indexPropName, arrayPropName } of activeItemIndexLinks) {
37
+ groups[arrayPropName] = {
38
+ displayName: formatDisplayName(singularize(arrayPropName)),
39
+ groupType: DISPLAY_GROUPS.GROUP_TYPE.arrayItems,
40
+ arrayItems: {
41
+ arrayItems: arrayPropName,
42
+ selectedItemIndex: indexPropName,
43
+ },
44
+ }
45
+ }
46
+
47
+ return Object.keys(groups).length > 0 ? groups : undefined
48
+ }
49
+
50
+ export function buildActiveItemIndexDisplayFilters(props: Record<string, CoupledProp>): DisplayFilters | undefined {
51
+ const hiddenIndexProps = collectValidActiveItemIndexLinks(props).map((link) => link.indexPropName)
52
+ return hiddenIndexProps.length > 0 ? { data: { hide: hiddenIndexProps } } : undefined
53
+ }
54
+
55
+ function collectValidActiveItemIndexLinks(props: Record<string, CoupledProp>): ActiveItemIndexLink[] {
56
+ const links: ActiveItemIndexLink[] = []
57
+ const registeredIndexProps = new Set<string>()
58
+ const registeredArrayProps = new Set<string>()
59
+
60
+ for (const [indexPropName, prop] of Object.entries(props)) {
61
+ const arrayPropName = prop.activeItemIndexTarget
62
+ if (!arrayPropName) continue
63
+
64
+ const arrayProp = props[arrayPropName]
65
+ if (!arrayProp || arrayProp.resolvedType.kind !== 'array') continue
66
+
67
+ const conflictsWithRegisteredLink =
68
+ registeredIndexProps.has(indexPropName) || registeredArrayProps.has(arrayPropName)
69
+ if (conflictsWithRegisteredLink) {
70
+ console.warn(
71
+ `ActiveItemIndex<> link "${indexPropName}" -> "${arrayPropName}" conflicts with an existing link — dropping.`,
72
+ )
73
+ continue
74
+ }
75
+
76
+ const hasRegisteredLink = links.length > 0
77
+ if (hasRegisteredLink) {
78
+ console.warn(
79
+ `Multiple ActiveItemIndex<> props found; only "${links[0].indexPropName}" -> "${links[0].arrayPropName}" is used. Dropping "${indexPropName}" -> "${arrayPropName}".`,
80
+ )
81
+ continue
82
+ }
83
+
84
+ links.push({ indexPropName, arrayPropName })
85
+ registeredIndexProps.add(indexPropName)
86
+ registeredArrayProps.add(arrayPropName)
87
+ }
88
+
89
+ return links
90
+ }
@@ -318,3 +318,67 @@ describe('toEditorReactComponent — @property design tokens', () => {
318
318
  expect(editorElement?.cssProperties?.['--sdf-safelight-color']).toBeUndefined()
319
319
  })
320
320
  })
321
+
322
+ describe('toEditorReactComponent — ActiveItemIndex display groups', () => {
323
+ it('emits displayGroups and hides the active index from Settings', () => {
324
+ const stringType = { kind: 'primitive' as const, value: 'string' as const }
325
+ const tabItemType = {
326
+ kind: 'object' as const,
327
+ properties: {
328
+ name: { name: 'name', type: 'string', required: false, resolvedType: stringType },
329
+ body: { name: 'body', type: 'string', required: false, resolvedType: stringType },
330
+ },
331
+ }
332
+
333
+ const component: ComponentInfoWithCss = {
334
+ componentName: 'SimpleTabs',
335
+ props: {
336
+ tabs: {
337
+ name: 'tabs',
338
+ type: 'Array<TabItem>',
339
+ required: false,
340
+ resolvedType: { kind: 'array', elementType: tabItemType },
341
+ propPath: 'props.tabs',
342
+ logicOnly: false,
343
+ },
344
+ activeTab: {
345
+ name: 'activeTab',
346
+ type: 'number',
347
+ required: false,
348
+ resolvedType: { kind: 'primitive', value: 'number' },
349
+ activeItemIndexTarget: 'tabs',
350
+ propPath: 'props.activeTab',
351
+ logicOnly: false,
352
+ },
353
+ },
354
+ elements: [
355
+ createSemanticElement({
356
+ traceId: 'trace-root',
357
+ name: 'root',
358
+ className: 'simple-tabs',
359
+ }),
360
+ ],
361
+ propUsages: new Map(),
362
+ css: [],
363
+ varUsedByTraceId: new Map(),
364
+ }
365
+
366
+ const editorElement = toEditorReactComponent(component).editorElement
367
+
368
+ expect(editorElement?.displayGroups).toEqual({
369
+ tabs: {
370
+ displayName: 'Tab',
371
+ groupType: 'arrayItems',
372
+ arrayItems: {
373
+ arrayItems: 'tabs',
374
+ selectedItemIndex: 'activeTab',
375
+ },
376
+ },
377
+ })
378
+ expect(editorElement?.displayFilters).toEqual({
379
+ data: { hide: ['activeTab'] },
380
+ })
381
+ expect(editorElement?.data?.activeTab?.defaultValue).toBe(0)
382
+ expect(editorElement?.data?.activeTab?.dataType).toBe('number')
383
+ })
384
+ })
@@ -25,6 +25,7 @@ import type {
25
25
  } from '../information-extractors/react'
26
26
  import { getDefaultDisplayForTag, resolveDisplayValue } from '../information-extractors/react'
27
27
  import { findPreferredSemanticClass, normalizeClassNames } from '../utils/css-class'
28
+ import { buildActiveItemDisplayGroups, buildActiveItemIndexDisplayFilters } from './active-item-index-builder'
28
29
  import { resolveLonghand } from './css-longhand-resolver'
29
30
  import { type CustomClassTrigger, buildCustomStatesBlock } from './custom-states-builder'
30
31
  import { buildDataItem } from './data-item-builder'
@@ -94,6 +95,9 @@ function buildEditorElement(
94
95
  )
95
96
  : undefined
96
97
 
98
+ const displayGroups = buildActiveItemDisplayGroups(component.props)
99
+ const displayFilters = buildActiveItemIndexDisplayFilters(component.props)
100
+
97
101
  return {
98
102
  selector: buildSelector(rootElement),
99
103
  displayName: formatDisplayName(component.componentName),
@@ -109,6 +113,8 @@ function buildEditorElement(
109
113
  cssProperties: buildCssProperties(rootElement),
110
114
  cssCustomProperties: rootCustomProps,
111
115
  ...(rootStates && { states: rootStates }),
116
+ ...(displayGroups && { displayGroups }),
117
+ ...(displayFilters && { displayFilters }),
112
118
  }
113
119
  }
114
120
 
@@ -157,6 +163,10 @@ function buildData(
157
163
 
158
164
  const result = buildDataItem(prop, defaultValue, propUsages, prop.propPath)
159
165
  if (result.isErr()) throw result.error
166
+ // ActiveItemIndex props default to 0 when no default is specified
167
+ if (prop.activeItemIndexTarget && result.value.defaultValue === undefined) {
168
+ result.value.defaultValue = 0
169
+ }
160
170
  data[name] = result.value
161
171
  }
162
172
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { formatDisplayName } from './utils'
2
+ import { formatDisplayName, singularize } from './utils'
3
3
 
4
4
  describe('formatDisplayName', () => {
5
5
  it('maps a11y to Accessibility', () => {
@@ -47,3 +47,16 @@ describe('formatDisplayName', () => {
47
47
  expect(formatDisplayName(longSingleWord).length).toBe(50)
48
48
  })
49
49
  })
50
+
51
+ describe('singularize', () => {
52
+ it.each([
53
+ ['slides', 'slide'],
54
+ ['entries', 'entry'],
55
+ ['boxes', 'box'],
56
+ ['tabs', 'tab'],
57
+ ['glass', 'glass'],
58
+ ['item', 'item'],
59
+ ])('singularizes "%s" to "%s"', (input, expected) => {
60
+ expect(singularize(input)).toBe(expected)
61
+ })
62
+ })
@@ -41,3 +41,14 @@ export function formatDisplayName(input: string): string {
41
41
  const result = words.join(' ')
42
42
  return result.slice(0, DISPLAY_NAME_MAX_LENGTH)
43
43
  }
44
+
45
+ /**
46
+ * Naive singularization for display names.
47
+ * Falls back to the original if no rule matches.
48
+ */
49
+ export function singularize(name: string): string {
50
+ if (name.endsWith('ies')) return `${name.slice(0, -3)}y`
51
+ if (name.endsWith('ses') || name.endsWith('xes') || name.endsWith('zes')) return name.slice(0, -2)
52
+ if (name.endsWith('s') && !name.endsWith('ss')) return name.slice(0, -1)
53
+ return name
54
+ }
@@ -0,0 +1,149 @@
1
+ import { createServicesManager, createServicesMap } from '@wix/services-manager'
2
+ import type { ServiceDefinition } from '@wix/services-manager/types'
3
+ import React from 'react'
4
+ import { renderToStaticMarkup } from 'react-dom/server'
5
+ import { describe, expect, it, vi } from 'vitest'
6
+ import { createAutoStubFallbackManager } from './service-stub'
7
+
8
+ // Definitions are plain strings at runtime, so a cast string is a faithful stand-in.
9
+ function defineTestService(serviceId: string): ServiceDefinition<never, never> {
10
+ return serviceId as unknown as ServiceDefinition<never, never>
11
+ }
12
+
13
+ const UnregisteredDefinition = defineTestService('@wix/viewer-service-topology')
14
+
15
+ // biome-ignore lint/suspicious/noExplicitAny: stubs are intentionally untyped at the call site
16
+ function stubFor(serviceId: string): any {
17
+ return createAutoStubFallbackManager().getService(defineTestService(serviceId))
18
+ }
19
+
20
+ describe('createAutoStubFallbackManager', () => {
21
+ it('returns a referentially stable stub across repeated lookups', () => {
22
+ const fallbackManager = createAutoStubFallbackManager()
23
+
24
+ expect(fallbackManager.getService(UnregisteredDefinition)).toBe(fallbackManager.getService(UnregisteredDefinition))
25
+ })
26
+
27
+ it('notifies once per distinct service id', () => {
28
+ const onServiceStubbed = vi.fn()
29
+ const fallbackManager = createAutoStubFallbackManager({ onServiceStubbed })
30
+
31
+ fallbackManager.getService(UnregisteredDefinition)
32
+ fallbackManager.getService(UnregisteredDefinition)
33
+ fallbackManager.getService(defineTestService('@wix/seo-service'))
34
+
35
+ expect(onServiceStubbed.mock.calls).toEqual([['@wix/viewer-service-topology'], ['@wix/seo-service']])
36
+ })
37
+ })
38
+
39
+ describe('service stub value semantics', () => {
40
+ it('resolves nested reads and curried calls to memoized stubs', () => {
41
+ const pagesStub = stubFor('@wix/site-service-pages')
42
+ expect(pagesStub.currentPage.title).toBe(pagesStub.currentPage.title)
43
+
44
+ const translationsStub = stubFor('@wix/viewer-service-translations')
45
+ expect(translationsStub.translate('app')('greeting')).toBe(translationsStub.translate('app')('greeting'))
46
+ })
47
+
48
+ it('keeps well-known symbol handlers referentially stable', () => {
49
+ const stub = stubFor('@wix/viewer-service-topology')
50
+
51
+ expect(stub.toString).toBe(stub.toString)
52
+ expect(stub[Symbol.iterator]).toBe(stub[Symbol.iterator])
53
+ })
54
+
55
+ it('agrees between `in` and reads for symbols', () => {
56
+ const stub = stubFor('@wix/viewer-service-topology')
57
+ const unhandledSymbol = Symbol.for('some-library-symbol')
58
+
59
+ // Reporting a symbol as present while reading undefined would break
60
+ // `if (sym in service) service[sym]()`.
61
+ expect(unhandledSymbol in stub).toBe(false)
62
+ expect(stub[unhandledSymbol]).toBeUndefined()
63
+
64
+ expect(Symbol.iterator in stub).toBe(true)
65
+ expect(stub[Symbol.iterator]).toBeDefined()
66
+ })
67
+
68
+ it('is not thenable, so awaiting it cannot hang', async () => {
69
+ const stub = stubFor('@wix/seo-service')
70
+
71
+ expect(stub.then).toBeUndefined()
72
+ expect('then' in stub).toBe(false)
73
+ await expect(Promise.resolve(stub)).resolves.toBe(stub)
74
+ })
75
+
76
+ it('terminates on spread and key enumeration instead of recursing', () => {
77
+ const stub = stubFor('@wix/viewer-service-sdk-state')
78
+
79
+ expect(Object.keys(stub)).toEqual([])
80
+ expect({ ...stub }).toEqual({})
81
+ })
82
+
83
+ it('reads as an empty string and iterates as empty', () => {
84
+ const stub = stubFor('@wix/viewer-service-topology')
85
+
86
+ expect(`${stub}`).toBe('')
87
+ expect([...stub]).toEqual([])
88
+ })
89
+
90
+ it('does not crash React when handed to JSX as a child', () => {
91
+ // Unconverted, so React sees the Proxy itself — and throws on plain-object children.
92
+ const reactWarning = vi.spyOn(console, 'error').mockImplementation(() => {})
93
+ const stub = stubFor('@wix/viewer-service-topology')
94
+
95
+ expect(() => renderToStaticMarkup(React.createElement('span', null, stub))).not.toThrow()
96
+
97
+ reactWarning.mockRestore()
98
+ })
99
+ })
100
+
101
+ describe('used as a services-manager fallback', () => {
102
+ const RegisteredDefinition = defineTestService('@wix/registered-test-service')
103
+
104
+ function createManagerWithFallback(onServiceStubbed?: (serviceId: string) => void) {
105
+ return createServicesManager(
106
+ createServicesMap([
107
+ {
108
+ definition: RegisteredDefinition,
109
+ impl: (() => ({ realValue: 'from-real-service' })) as never,
110
+ config: {} as never,
111
+ },
112
+ ]),
113
+ createAutoStubFallbackManager({ onServiceStubbed }),
114
+ )
115
+ }
116
+
117
+ it('keeps extended bindings real and still stubs the rest', () => {
118
+ const ExtendedDefinition = defineTestService('@wix/extended-test-service')
119
+ const extended = createAutoStubFallbackManager().extend(
120
+ createServicesMap([
121
+ {
122
+ definition: ExtendedDefinition,
123
+ impl: (() => ({ realValue: 'from-extension' })) as never,
124
+ config: {} as never,
125
+ },
126
+ ]),
127
+ )
128
+
129
+ expect(extended.getService(ExtendedDefinition)).toEqual({ realValue: 'from-extension' })
130
+ expect(() => extended.getService(UnregisteredDefinition)).not.toThrow()
131
+ })
132
+
133
+ it('leaves registered services untouched', () => {
134
+ const servicesManager = createManagerWithFallback()
135
+
136
+ expect(servicesManager.getService(RegisteredDefinition)).toEqual({ realValue: 'from-real-service' })
137
+ })
138
+
139
+ it('substitutes a stub where an unregistered service would otherwise throw', () => {
140
+ const onServiceStubbed = vi.fn()
141
+
142
+ expect(() => createServicesManager(createServicesMap([])).getService(UnregisteredDefinition)).toThrow(
143
+ /is not provided/,
144
+ )
145
+
146
+ expect(() => createManagerWithFallback(onServiceStubbed).getService(UnregisteredDefinition)).not.toThrow()
147
+ expect(onServiceStubbed).toHaveBeenCalledWith('@wix/viewer-service-topology')
148
+ })
149
+ })