@morscherlab/mint-sdk 1.2.0-beta.4 → 1.2.0-beta.5
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/dist/{EmptyState-p90quq8Z.js → EmptyState-NBqf2_8z.js} +2 -2
- package/dist/{EmptyState-p90quq8Z.js.map → EmptyState-NBqf2_8z.js.map} +1 -1
- package/dist/{ExperimentPopover-tLwJ7XF4.js → ExperimentPopover-CfOdh5Rm.js} +3 -3
- package/dist/{ExperimentPopover-tLwJ7XF4.js.map → ExperimentPopover-CfOdh5Rm.js.map} +1 -1
- package/dist/{ExperimentSelectorModal-B6P2msT4.js → ExperimentSelectorModal-DvIagrxY.js} +4 -4
- package/dist/{ExperimentSelectorModal-B6P2msT4.js.map → ExperimentSelectorModal-DvIagrxY.js.map} +1 -1
- package/dist/{SettingsModal-CkNM8qAI.js → SettingsModal-W3TYfnP9.js} +4 -7
- package/dist/SettingsModal-W3TYfnP9.js.map +1 -0
- package/dist/__tests__/components/PluginWorkspaceView.internal.test.d.ts +1 -0
- package/dist/__tests__/components/SecretInput.test.d.ts +1 -0
- package/dist/__tests__/components/WellEditPopupInternal.test.d.ts +1 -0
- package/dist/components/AppAvatarMenu.vue.d.ts +4 -0
- package/dist/components/AppSidebar.vue.d.ts +1 -1
- package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
- package/dist/components/DropdownButton.vue.d.ts +2 -0
- package/dist/components/FitPanel.vue.d.ts +1 -1
- package/dist/components/PluginWorkspaceView.props.d.ts +29 -1
- package/dist/components/PluginWorkspaceView.vue.d.ts +5 -5
- package/dist/components/SecretInput.vue.d.ts +28 -0
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +1 -1
- package/dist/components/index.d.ts +9 -0
- package/dist/components/index.js +8 -8
- package/dist/components/internal/WellEditPopupInternal.vue.d.ts +3 -1
- package/dist/components/internal/WorkspaceControlsInternal.vue.d.ts +23 -0
- package/dist/components/internal/WorkspaceShellInternal.vue.d.ts +70 -0
- package/dist/components/internal/WorkspaceSidebarInternal.vue.d.ts +43 -0
- package/dist/{components-MGNL6tRn.js → components-B80gV0P6.js} +575 -561
- package/dist/components-B80gV0P6.js.map +1 -0
- package/dist/composables/flattenWorkspaceBindings.d.ts +61 -0
- package/dist/composables/index.d.ts +5 -0
- package/dist/composables/index.js +9 -9
- package/dist/composables/pluginWorkspaceModel.d.ts +164 -0
- package/dist/composables/useAsync.d.ts +5 -1
- package/dist/composables/useBioTemplateWorkspace.d.ts +4 -20
- package/dist/composables/useCommandHistory.d.ts +5 -1
- package/dist/composables/useFocusTrap.d.ts +21 -0
- package/dist/composables/useMenuKeyboard.d.ts +29 -0
- package/dist/composables/useOptimisticMutation.d.ts +5 -1
- package/dist/composables/usePluginWorkspace.d.ts +51 -0
- package/dist/composables/useResourceCrud.d.ts +5 -1
- package/dist/composables/useRovingFocus.d.ts +24 -0
- package/dist/composables/useWellPainting.d.ts +5 -1
- package/dist/composables/useWellPlateAdapter.d.ts +5 -1
- package/dist/composables/useWellPlateValidation.d.ts +5 -1
- package/dist/composables/workspaceRecipes.d.ts +188 -0
- package/dist/{composables-CKxOcvgh.js → composables-C8mmD5ta.js} +65 -17
- package/dist/composables-C8mmD5ta.js.map +1 -0
- package/dist/{experiment-utils-CLnzPPq5.js → experiment-utils-DV3DrvE_.js} +70 -51
- package/dist/experiment-utils-DV3DrvE_.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +13 -13
- package/dist/install.js +7 -7
- package/dist/styles.css +100 -49
- package/dist/templates/builderReadUtils.d.ts +1 -1
- package/dist/templates/index.js +4 -4
- package/dist/{templates-BTJ9C9Kb.js → templates-CK72aKO1.js} +14 -23
- package/dist/templates-CK72aKO1.js.map +1 -0
- package/dist/types/form-builder.d.ts +1 -1
- package/dist/{useControlSchema-CXhxTul7.js → useControlSchema-_OVhavKq.js} +280 -63
- package/dist/useControlSchema-_OVhavKq.js.map +1 -0
- package/dist/{useDropdownState-DSb3BC3N.js → useDropdownState-D5G4UvWd.js} +2 -2
- package/dist/{useDropdownState-DSb3BC3N.js.map → useDropdownState-D5G4UvWd.js.map} +1 -1
- package/dist/{useExperimentSelector-D0pjj9WH.js → useExperimentSelector-DSEckf_g.js} +3 -3
- package/dist/{useExperimentSelector-D0pjj9WH.js.map → useExperimentSelector-DSEckf_g.js.map} +1 -1
- package/dist/{useFormBuilder-CuOFB3aP.js → useFormBuilder-Bd714C7y.js} +2 -2
- package/dist/{useFormBuilder-CuOFB3aP.js.map → useFormBuilder-Bd714C7y.js.map} +1 -1
- package/dist/{useProtocolTemplates-BWhQIWV5.js → useProtocolTemplates-Dq5Pzwyd.js} +481 -46
- package/dist/useProtocolTemplates-Dq5Pzwyd.js.map +1 -0
- package/dist/utils/pluginSecrets.d.ts +53 -0
- package/dist/utils/records.d.ts +4 -0
- package/package.json +1 -1
- package/src/__tests__/components/AppAvatarMenu.test.ts +123 -0
- package/src/__tests__/components/BaseTabs.test.ts +67 -0
- package/src/__tests__/components/DropdownButton.test.ts +134 -0
- package/src/__tests__/components/PluginWorkspaceView.internal.test.ts +230 -0
- package/src/__tests__/components/SecretInput.test.ts +87 -0
- package/src/__tests__/components/WellEditPopupInternal.test.ts +108 -0
- package/src/__tests__/composables/usePluginClient.test.ts +140 -0
- package/src/components/AppAvatarMenu.vue +34 -3
- package/src/components/BaseModal.vue +8 -56
- package/src/components/BaseTabs.vue +26 -1
- package/src/components/BioTemplateExperimentWorkspaceView.vue +10 -21
- package/src/components/BioTemplatePackWorkspaceView.vue +11 -15
- package/src/components/BioTemplatePresetWorkspaceView.vue +8 -13
- package/src/components/ComponentBindingRenderer.vue +1 -3
- package/src/components/ControlWorkspaceView.vue +11 -22
- package/src/components/DoseDesignWorkspaceView.vue +6 -25
- package/src/components/DropdownButton.vue +32 -2
- package/src/components/PluginWorkspaceView.props.ts +38 -0
- package/src/components/PluginWorkspaceView.vue +53 -224
- package/src/components/SecretInput.story.vue +79 -0
- package/src/components/SecretInput.vue +150 -0
- package/src/components/index.ts +9 -0
- package/src/components/internal/WellEditPopupInternal.vue +20 -1
- package/src/components/internal/WorkspaceControlsInternal.vue +20 -0
- package/src/components/internal/WorkspaceShellInternal.vue +125 -0
- package/src/components/internal/WorkspaceSidebarInternal.vue +82 -0
- package/src/composables/experimentDesignData.ts +1 -3
- package/src/composables/flattenWorkspaceBindings.ts +104 -0
- package/src/composables/formBuilderRegistry.ts +5 -0
- package/src/composables/index.ts +26 -0
- package/src/composables/pluginWorkspaceModel.ts +182 -0
- package/src/composables/useAsync.ts +5 -1
- package/src/composables/useBioTemplatePackWorkspace.ts +15 -9
- package/src/composables/useBioTemplatePresetWorkspace.ts +14 -15
- package/src/composables/useBioTemplateWorkspace.ts +18 -43
- package/src/composables/useCommandHistory.ts +5 -1
- package/src/composables/useFocusTrap.ts +91 -0
- package/src/composables/useMenuKeyboard.ts +90 -0
- package/src/composables/useOptimisticMutation.ts +5 -1
- package/src/composables/usePluginClient.ts +42 -4
- package/src/composables/usePluginWorkspace.ts +208 -0
- package/src/composables/useResourceCrud.ts +5 -1
- package/src/composables/useRovingFocus.ts +66 -0
- package/src/composables/useWellPainting.ts +5 -1
- package/src/composables/useWellPlateAdapter.ts +5 -1
- package/src/composables/useWellPlateValidation.ts +5 -1
- package/src/composables/workspaceRecipes.ts +267 -0
- package/src/index.ts +16 -0
- package/src/styles/components/secret-input.css +60 -0
- package/src/styles/index.css +1 -0
- package/src/templates/builderReadUtils.ts +1 -3
- package/src/templates/componentTargetResolvers.ts +1 -3
- package/src/templates/controlSchemaTargets.ts +1 -3
- package/src/types/form-builder.ts +1 -0
- package/src/utils/formModelSync.ts +1 -3
- package/src/utils/pluginIcon.ts +1 -3
- package/src/utils/pluginSecrets.ts +102 -0
- package/src/utils/records.ts +9 -0
- package/dist/SettingsModal-CkNM8qAI.js.map +0 -1
- package/dist/components-MGNL6tRn.js.map +0 -1
- package/dist/composables-CKxOcvgh.js.map +0 -1
- package/dist/experiment-utils-CLnzPPq5.js.map +0 -1
- package/dist/templates-BTJ9C9Kb.js.map +0 -1
- package/dist/useControlSchema-CXhxTul7.js.map +0 -1
- package/dist/useProtocolTemplates-BWhQIWV5.js.map +0 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable references for plugin secret settings.
|
|
3
|
+
*
|
|
4
|
+
* A settings response never carries a secret's value. Every secret leaf is a
|
|
5
|
+
* marker instead:
|
|
6
|
+
*
|
|
7
|
+
* - `{ "$secret": "sec_…", "set": true }` — a stored secret, referenced by a
|
|
8
|
+
* stable opaque id. Send the marker back unchanged to keep the stored value.
|
|
9
|
+
* - `null` — nothing stored at that position.
|
|
10
|
+
* - `{ "$secret": null, "locked": true }` — the effective value comes from the
|
|
11
|
+
* environment and is never written back.
|
|
12
|
+
*
|
|
13
|
+
* On save each secret leaf accepts the reference, a plain string (a new value),
|
|
14
|
+
* or `null` (clear). Payloads carry `secret_format: "ref"` so a `null` reads as
|
|
15
|
+
* a deliberate clear rather than the deprecated "unchanged" placeholder.
|
|
16
|
+
*/
|
|
17
|
+
/** Reserved marker key that references a stored secret. */
|
|
18
|
+
export declare const PLUGIN_SECRET_REF_KEY = "$secret";
|
|
19
|
+
/** Reserved payload key declaring which secret wire format a client speaks. */
|
|
20
|
+
export declare const PLUGIN_SECRET_FORMAT_KEY = "secret_format";
|
|
21
|
+
/** Wire format published and understood by this SDK. */
|
|
22
|
+
export declare const PLUGIN_SECRET_FORMAT_REF = "ref";
|
|
23
|
+
/** One `$secret` marker as published by a settings response. */
|
|
24
|
+
export interface PluginSecretRef {
|
|
25
|
+
$secret: string | null;
|
|
26
|
+
set?: boolean;
|
|
27
|
+
locked?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/** What a settings form should tell the user about one secret field. */
|
|
30
|
+
export type PluginSecretState = 'set' | 'unset' | 'environment';
|
|
31
|
+
/** Return whether one settings value is a `$secret` reference marker. */
|
|
32
|
+
export declare function isPluginSecretRef(value: unknown): value is PluginSecretRef;
|
|
33
|
+
/** Return the stored secret id behind a marker, or `null` when nothing is stored. */
|
|
34
|
+
export declare function pluginSecretId(value: unknown): string | null;
|
|
35
|
+
/** Classify one secret field for display: stored, unset, or environment-provided. */
|
|
36
|
+
export declare function pluginSecretState(value: unknown): PluginSecretState;
|
|
37
|
+
/** Human label for one secret field's state. */
|
|
38
|
+
export declare function pluginSecretLabel(value: unknown): string;
|
|
39
|
+
/** Whether a secret field is read-only because the environment supplies it. */
|
|
40
|
+
export declare function isPluginSecretLocked(value: unknown): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Value to submit for an untouched secret field: the reference, unchanged.
|
|
43
|
+
*
|
|
44
|
+
* A locked marker round-trips too, which is how the platform keeps a stored
|
|
45
|
+
* value that an environment variable currently hides.
|
|
46
|
+
*/
|
|
47
|
+
export declare function keepPluginSecret(current: unknown): PluginSecretRef | null;
|
|
48
|
+
/** Value to submit when the user typed a new secret. */
|
|
49
|
+
export declare function setPluginSecret(value: string): string;
|
|
50
|
+
/** Value to submit when the user cleared a secret. */
|
|
51
|
+
export declare function clearPluginSecret(): null;
|
|
52
|
+
/** Whether a settings response declares the `$secret` reference wire format. */
|
|
53
|
+
export declare function usesPluginSecretReferences(response: unknown): boolean;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Narrow an unknown value to a plain object (arrays and null excluded). */
|
|
2
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
3
|
+
/** Narrow an unknown value to any non-null object, arrays included. */
|
|
4
|
+
export declare function isObjectLike(value: unknown): value is Record<string, unknown>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morscherlab/mint-sdk",
|
|
3
|
-
"version": "1.2.0-beta.
|
|
3
|
+
"version": "1.2.0-beta.5",
|
|
4
4
|
"description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec INtegrated Toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -35,4 +35,127 @@ describe('AppAvatarMenu', () => {
|
|
|
35
35
|
expect(wrapper.emitted('select')).toEqual([[docs]])
|
|
36
36
|
expect(wrapper.find('.mint-avatar-menu__panel').exists()).toBe(false)
|
|
37
37
|
})
|
|
38
|
+
|
|
39
|
+
describe('keyboard handling (menu button pattern)', () => {
|
|
40
|
+
const items = [{ id: 'profile', label: 'Profile' }, { id: 'settings', label: 'Settings' }]
|
|
41
|
+
|
|
42
|
+
function mountMenu() {
|
|
43
|
+
return mount(AppAvatarMenu, {
|
|
44
|
+
props: { userName: 'Ada Lovelace', items, showSignOut: true },
|
|
45
|
+
attachTo: document.body,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
it('sets aria-expanded on the trigger and toggles it open/closed', async () => {
|
|
50
|
+
const wrapper = mountMenu()
|
|
51
|
+
const trigger = wrapper.get('.mint-avatar-menu__trigger')
|
|
52
|
+
expect(trigger.attributes('aria-expanded')).toBe('false')
|
|
53
|
+
|
|
54
|
+
await trigger.trigger('click')
|
|
55
|
+
expect(trigger.attributes('aria-expanded')).toBe('true')
|
|
56
|
+
|
|
57
|
+
wrapper.unmount()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('opens and focuses the first item on ArrowDown', async () => {
|
|
61
|
+
const wrapper = mountMenu()
|
|
62
|
+
await wrapper.get('.mint-avatar-menu__trigger').trigger('keydown', { key: 'ArrowDown' })
|
|
63
|
+
await wrapper.vm.$nextTick()
|
|
64
|
+
|
|
65
|
+
expect(wrapper.find('.mint-avatar-menu__panel').exists()).toBe(true)
|
|
66
|
+
expect(document.activeElement).toBe(wrapper.findAll('[role="menuitem"]')[0].element)
|
|
67
|
+
|
|
68
|
+
wrapper.unmount()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('opens and focuses the last item on ArrowUp', async () => {
|
|
72
|
+
const wrapper = mountMenu()
|
|
73
|
+
await wrapper.get('.mint-avatar-menu__trigger').trigger('keydown', { key: 'ArrowUp' })
|
|
74
|
+
await wrapper.vm.$nextTick()
|
|
75
|
+
|
|
76
|
+
const menuItems = wrapper.findAll('[role="menuitem"]')
|
|
77
|
+
expect(document.activeElement).toBe(menuItems[menuItems.length - 1].element)
|
|
78
|
+
|
|
79
|
+
wrapper.unmount()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('opens and focuses the first item on Enter and Space', async () => {
|
|
83
|
+
for (const key of ['Enter', ' ']) {
|
|
84
|
+
const wrapper = mountMenu()
|
|
85
|
+
await wrapper.get('.mint-avatar-menu__trigger').trigger('keydown', { key })
|
|
86
|
+
await wrapper.vm.$nextTick()
|
|
87
|
+
|
|
88
|
+
expect(document.activeElement).toBe(wrapper.findAll('[role="menuitem"]')[0].element)
|
|
89
|
+
wrapper.unmount()
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('cycles item focus with ArrowDown/ArrowUp, wrapping at the ends', async () => {
|
|
94
|
+
const wrapper = mountMenu()
|
|
95
|
+
await wrapper.get('.mint-avatar-menu__trigger').trigger('keydown', { key: 'ArrowDown' })
|
|
96
|
+
await wrapper.vm.$nextTick()
|
|
97
|
+
|
|
98
|
+
const menuItems = wrapper.findAll('[role="menuitem"]')
|
|
99
|
+
const panel = wrapper.get('.mint-avatar-menu__panel')
|
|
100
|
+
|
|
101
|
+
await panel.trigger('keydown', { key: 'ArrowDown' })
|
|
102
|
+
expect(document.activeElement).toBe(menuItems[1].element)
|
|
103
|
+
|
|
104
|
+
await panel.trigger('keydown', { key: 'ArrowDown' })
|
|
105
|
+
expect(document.activeElement).toBe(menuItems[menuItems.length - 1].element)
|
|
106
|
+
|
|
107
|
+
await panel.trigger('keydown', { key: 'ArrowDown' })
|
|
108
|
+
expect(document.activeElement).toBe(menuItems[0].element)
|
|
109
|
+
|
|
110
|
+
await panel.trigger('keydown', { key: 'ArrowUp' })
|
|
111
|
+
expect(document.activeElement).toBe(menuItems[menuItems.length - 1].element)
|
|
112
|
+
|
|
113
|
+
wrapper.unmount()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('jumps to the first item on Home and the last on End', async () => {
|
|
117
|
+
const wrapper = mountMenu()
|
|
118
|
+
await wrapper.get('.mint-avatar-menu__trigger').trigger('keydown', { key: 'ArrowDown' })
|
|
119
|
+
await wrapper.vm.$nextTick()
|
|
120
|
+
|
|
121
|
+
const menuItems = wrapper.findAll('[role="menuitem"]')
|
|
122
|
+
const panel = wrapper.get('.mint-avatar-menu__panel')
|
|
123
|
+
|
|
124
|
+
await panel.trigger('keydown', { key: 'End' })
|
|
125
|
+
expect(document.activeElement).toBe(menuItems[menuItems.length - 1].element)
|
|
126
|
+
|
|
127
|
+
await panel.trigger('keydown', { key: 'Home' })
|
|
128
|
+
expect(document.activeElement).toBe(menuItems[0].element)
|
|
129
|
+
|
|
130
|
+
wrapper.unmount()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('closes on Escape and returns focus to the trigger', async () => {
|
|
134
|
+
const wrapper = mountMenu()
|
|
135
|
+
const trigger = wrapper.get('.mint-avatar-menu__trigger')
|
|
136
|
+
await trigger.trigger('keydown', { key: 'ArrowDown' })
|
|
137
|
+
await wrapper.vm.$nextTick()
|
|
138
|
+
|
|
139
|
+
await wrapper.get('.mint-avatar-menu__panel').trigger('keydown', { key: 'Escape' })
|
|
140
|
+
await wrapper.vm.$nextTick()
|
|
141
|
+
|
|
142
|
+
expect(wrapper.find('.mint-avatar-menu__panel').exists()).toBe(false)
|
|
143
|
+
expect(document.activeElement).toBe(trigger.element)
|
|
144
|
+
|
|
145
|
+
wrapper.unmount()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('closes on Tab without forcing focus', async () => {
|
|
149
|
+
const wrapper = mountMenu()
|
|
150
|
+
await wrapper.get('.mint-avatar-menu__trigger').trigger('keydown', { key: 'ArrowDown' })
|
|
151
|
+
await wrapper.vm.$nextTick()
|
|
152
|
+
|
|
153
|
+
await wrapper.get('.mint-avatar-menu__panel').trigger('keydown', { key: 'Tab' })
|
|
154
|
+
await wrapper.vm.$nextTick()
|
|
155
|
+
|
|
156
|
+
expect(wrapper.find('.mint-avatar-menu__panel').exists()).toBe(false)
|
|
157
|
+
|
|
158
|
+
wrapper.unmount()
|
|
159
|
+
})
|
|
160
|
+
})
|
|
38
161
|
})
|
|
@@ -37,4 +37,71 @@ describe('BaseTabs', () => {
|
|
|
37
37
|
expect(wrapper.findAll('svg.mint-tab__icon--svg')).toHaveLength(2)
|
|
38
38
|
expect(wrapper.findAll('path')).toHaveLength(3)
|
|
39
39
|
})
|
|
40
|
+
|
|
41
|
+
describe('keyboard navigation (WAI-ARIA tabs pattern)', () => {
|
|
42
|
+
const tabs = ['Run', 'Results', { id: 'settings', label: 'Settings', disabled: true }, 'Logs']
|
|
43
|
+
|
|
44
|
+
it('puts tabindex="0" only on the active tab', () => {
|
|
45
|
+
const wrapper = mount(BaseTabs, { props: { modelValue: 'Results', tabs } })
|
|
46
|
+
const buttons = wrapper.findAll('.mint-tab')
|
|
47
|
+
|
|
48
|
+
expect(buttons.map(b => b.attributes('tabindex'))).toEqual(['-1', '0', '-1', '-1'])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('moves to and activates the next enabled tab on ArrowRight, skipping disabled tabs', async () => {
|
|
52
|
+
const wrapper = mount(BaseTabs, { props: { modelValue: 'Results', tabs } })
|
|
53
|
+
|
|
54
|
+
await wrapper.findAll('.mint-tab')[1].trigger('keydown', { key: 'ArrowRight' })
|
|
55
|
+
|
|
56
|
+
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['Logs'])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('moves to the previous enabled tab on ArrowLeft, wrapping past the start', async () => {
|
|
60
|
+
const wrapper = mount(BaseTabs, { props: { modelValue: 'Run', tabs } })
|
|
61
|
+
|
|
62
|
+
await wrapper.findAll('.mint-tab')[0].trigger('keydown', { key: 'ArrowLeft' })
|
|
63
|
+
|
|
64
|
+
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['Logs'])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('wraps past the end on ArrowRight', async () => {
|
|
68
|
+
const wrapper = mount(BaseTabs, { props: { modelValue: 'Logs', tabs } })
|
|
69
|
+
|
|
70
|
+
await wrapper.findAll('.mint-tab')[3].trigger('keydown', { key: 'ArrowRight' })
|
|
71
|
+
|
|
72
|
+
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['Run'])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('jumps to the first tab on Home and the last on End', async () => {
|
|
76
|
+
const wrapper = mount(BaseTabs, { props: { modelValue: 'Results', tabs } })
|
|
77
|
+
|
|
78
|
+
await wrapper.findAll('.mint-tab')[1].trigger('keydown', { key: 'End' })
|
|
79
|
+
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['Logs'])
|
|
80
|
+
|
|
81
|
+
await wrapper.findAll('.mint-tab')[1].trigger('keydown', { key: 'Home' })
|
|
82
|
+
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['Run'])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('moves DOM focus onto the newly-activated tab', async () => {
|
|
86
|
+
const wrapper = mount(BaseTabs, {
|
|
87
|
+
props: { modelValue: 'Run', tabs },
|
|
88
|
+
attachTo: document.body,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
await wrapper.findAll('.mint-tab')[0].trigger('keydown', { key: 'ArrowRight' })
|
|
92
|
+
await wrapper.setProps({ modelValue: 'Results' })
|
|
93
|
+
await wrapper.vm.$nextTick()
|
|
94
|
+
|
|
95
|
+
expect(document.activeElement).toBe(wrapper.findAll('.mint-tab')[1].element)
|
|
96
|
+
wrapper.unmount()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('keeps aria-selected and aria-controls consistent with the active tab', () => {
|
|
100
|
+
const wrapper = mount(BaseTabs, { props: { modelValue: 'Results', tabs } })
|
|
101
|
+
const active = wrapper.findAll('.mint-tab')[1]
|
|
102
|
+
|
|
103
|
+
expect(active.attributes('aria-selected')).toBe('true')
|
|
104
|
+
expect(active.attributes('id')).toBe(active.attributes('aria-controls')?.replace('tabpanel', 'tab'))
|
|
105
|
+
})
|
|
106
|
+
})
|
|
40
107
|
})
|
|
@@ -458,6 +458,140 @@ describe('DropdownButton', () => {
|
|
|
458
458
|
})
|
|
459
459
|
})
|
|
460
460
|
|
|
461
|
+
describe('listbox keyboard handling', () => {
|
|
462
|
+
it('opens and focuses the first enabled option on ArrowDown', async () => {
|
|
463
|
+
const wrapper = mount(DropdownButton, {
|
|
464
|
+
props: { options: mockOptions },
|
|
465
|
+
attachTo: document.body,
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
await wrapper.get('.mint-dropdown-button__trigger').trigger('keydown', { key: 'ArrowDown' })
|
|
469
|
+
await wrapper.vm.$nextTick()
|
|
470
|
+
|
|
471
|
+
expect(wrapper.find('.mint-dropdown-button__menu').exists()).toBe(true)
|
|
472
|
+
expect(document.activeElement).toBe(wrapper.findAll('.mint-dropdown-button__option')[0].element)
|
|
473
|
+
|
|
474
|
+
wrapper.unmount()
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
it('opens and focuses the last option on ArrowUp', async () => {
|
|
478
|
+
const wrapper = mount(DropdownButton, {
|
|
479
|
+
props: { options: mockOptions },
|
|
480
|
+
attachTo: document.body,
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
await wrapper.get('.mint-dropdown-button__trigger').trigger('keydown', { key: 'ArrowUp' })
|
|
484
|
+
await wrapper.vm.$nextTick()
|
|
485
|
+
|
|
486
|
+
// Option C is disabled and excluded from the navigable set, so the last
|
|
487
|
+
// navigable option is Option B even though it renders second-to-last.
|
|
488
|
+
const options = wrapper.findAll('.mint-dropdown-button__option')
|
|
489
|
+
expect(document.activeElement).toBe(options[1].element)
|
|
490
|
+
|
|
491
|
+
wrapper.unmount()
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
it('cycles option focus with ArrowDown/ArrowUp, skipping disabled options, wrapping at the ends', async () => {
|
|
495
|
+
const wrapper = mount(DropdownButton, {
|
|
496
|
+
props: { options: mockOptions },
|
|
497
|
+
attachTo: document.body,
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
await wrapper.get('.mint-dropdown-button__trigger').trigger('keydown', { key: 'ArrowDown' })
|
|
501
|
+
await wrapper.vm.$nextTick()
|
|
502
|
+
|
|
503
|
+
const options = wrapper.findAll('.mint-dropdown-button__option')
|
|
504
|
+
const menu = wrapper.get('.mint-dropdown-button__menu')
|
|
505
|
+
|
|
506
|
+
expect(document.activeElement).toBe(options[0].element)
|
|
507
|
+
|
|
508
|
+
// Option C is disabled and excluded from the navigable set entirely.
|
|
509
|
+
await menu.trigger('keydown', { key: 'ArrowDown' })
|
|
510
|
+
expect(document.activeElement).toBe(options[1].element)
|
|
511
|
+
|
|
512
|
+
await menu.trigger('keydown', { key: 'ArrowDown' })
|
|
513
|
+
expect(document.activeElement).toBe(options[0].element)
|
|
514
|
+
|
|
515
|
+
await menu.trigger('keydown', { key: 'ArrowUp' })
|
|
516
|
+
expect(document.activeElement).toBe(options[1].element)
|
|
517
|
+
|
|
518
|
+
wrapper.unmount()
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
it('jumps to the first option on Home and the last on End', async () => {
|
|
522
|
+
const wrapper = mount(DropdownButton, {
|
|
523
|
+
props: { options: mockOptions },
|
|
524
|
+
attachTo: document.body,
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
await wrapper.get('.mint-dropdown-button__trigger').trigger('keydown', { key: 'ArrowDown' })
|
|
528
|
+
await wrapper.vm.$nextTick()
|
|
529
|
+
|
|
530
|
+
const options = wrapper.findAll('.mint-dropdown-button__option')
|
|
531
|
+
const menu = wrapper.get('.mint-dropdown-button__menu')
|
|
532
|
+
|
|
533
|
+
await menu.trigger('keydown', { key: 'End' })
|
|
534
|
+
expect(document.activeElement).toBe(options[1].element)
|
|
535
|
+
|
|
536
|
+
await menu.trigger('keydown', { key: 'Home' })
|
|
537
|
+
expect(document.activeElement).toBe(options[0].element)
|
|
538
|
+
|
|
539
|
+
wrapper.unmount()
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
it('selects the focused option on Enter', async () => {
|
|
543
|
+
const wrapper = mount(DropdownButton, {
|
|
544
|
+
props: { options: mockOptions },
|
|
545
|
+
attachTo: document.body,
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
await wrapper.get('.mint-dropdown-button__trigger').trigger('keydown', { key: 'ArrowDown' })
|
|
549
|
+
await wrapper.vm.$nextTick()
|
|
550
|
+
await wrapper.get('.mint-dropdown-button__option').trigger('keydown', { key: 'Enter' })
|
|
551
|
+
|
|
552
|
+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['a'])
|
|
553
|
+
expect(wrapper.find('.mint-dropdown-button__menu').exists()).toBe(false)
|
|
554
|
+
|
|
555
|
+
wrapper.unmount()
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it('closes on Escape and returns focus to the trigger', async () => {
|
|
559
|
+
const wrapper = mount(DropdownButton, {
|
|
560
|
+
props: { options: mockOptions },
|
|
561
|
+
attachTo: document.body,
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
const trigger = wrapper.get('.mint-dropdown-button__trigger')
|
|
565
|
+
await trigger.trigger('keydown', { key: 'ArrowDown' })
|
|
566
|
+
await wrapper.vm.$nextTick()
|
|
567
|
+
|
|
568
|
+
await wrapper.get('.mint-dropdown-button__menu').trigger('keydown', { key: 'Escape' })
|
|
569
|
+
await wrapper.vm.$nextTick()
|
|
570
|
+
|
|
571
|
+
expect(wrapper.find('.mint-dropdown-button__menu').exists()).toBe(false)
|
|
572
|
+
expect(document.activeElement).toBe(trigger.element)
|
|
573
|
+
|
|
574
|
+
wrapper.unmount()
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
it('closes on Tab without forcing focus', async () => {
|
|
578
|
+
const wrapper = mount(DropdownButton, {
|
|
579
|
+
props: { options: mockOptions },
|
|
580
|
+
attachTo: document.body,
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
await wrapper.get('.mint-dropdown-button__trigger').trigger('keydown', { key: 'ArrowDown' })
|
|
584
|
+
await wrapper.vm.$nextTick()
|
|
585
|
+
|
|
586
|
+
await wrapper.get('.mint-dropdown-button__menu').trigger('keydown', { key: 'Tab' })
|
|
587
|
+
await wrapper.vm.$nextTick()
|
|
588
|
+
|
|
589
|
+
expect(wrapper.find('.mint-dropdown-button__menu').exists()).toBe(false)
|
|
590
|
+
|
|
591
|
+
wrapper.unmount()
|
|
592
|
+
})
|
|
593
|
+
})
|
|
594
|
+
|
|
461
595
|
describe('edge cases', () => {
|
|
462
596
|
it('should handle empty options array', () => {
|
|
463
597
|
const wrapper = mount(DropdownButton, {
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils'
|
|
2
|
+
import { computed, h, nextTick } from 'vue'
|
|
3
|
+
import { createPinia } from 'pinia'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import AppLayout from '../../components/AppLayout.vue'
|
|
6
|
+
import AppSidebar from '../../components/AppSidebar.vue'
|
|
7
|
+
import AppTopBar from '../../components/AppTopBar.vue'
|
|
8
|
+
import PluginWorkspaceView from '../../components/PluginWorkspaceView.vue'
|
|
9
|
+
import WorkspaceControlsInternal from '../../components/internal/WorkspaceControlsInternal.vue'
|
|
10
|
+
import WorkspaceShellInternal from '../../components/internal/WorkspaceShellInternal.vue'
|
|
11
|
+
import WorkspaceSidebarInternal from '../../components/internal/WorkspaceSidebarInternal.vue'
|
|
12
|
+
import type { PillNavItem, SidebarToolSection } from '../../types'
|
|
13
|
+
|
|
14
|
+
vi.mock('../../composables/usePlatformContext', () => ({
|
|
15
|
+
usePlatformContext: vi.fn(() => ({
|
|
16
|
+
isIntegrated: computed(() => false),
|
|
17
|
+
context: { value: { isIntegrated: false, theme: 'system' } },
|
|
18
|
+
plugin: computed(() => undefined),
|
|
19
|
+
user: computed(() => undefined),
|
|
20
|
+
theme: computed(() => 'system' as const),
|
|
21
|
+
features: computed(() => undefined),
|
|
22
|
+
navigate: vi.fn(),
|
|
23
|
+
notify: vi.fn(),
|
|
24
|
+
sendToPlatform: vi.fn(),
|
|
25
|
+
})),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
vi.mock('../../composables/useTheme', () => ({
|
|
29
|
+
useTheme: vi.fn(() => ({
|
|
30
|
+
isDark: computed(() => false),
|
|
31
|
+
theme: computed(() => 'light'),
|
|
32
|
+
toggleTheme: vi.fn(),
|
|
33
|
+
})),
|
|
34
|
+
}))
|
|
35
|
+
|
|
36
|
+
const pillNav: PillNavItem[] = [
|
|
37
|
+
{ id: 'data', label: 'Data' },
|
|
38
|
+
{ id: 'results', label: 'Results' },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
const panels: Record<string, SidebarToolSection[]> = {
|
|
42
|
+
data: [
|
|
43
|
+
{ id: 'import', label: 'Import', defaultOpen: true },
|
|
44
|
+
],
|
|
45
|
+
results: [
|
|
46
|
+
{ id: 'display', label: 'Display', defaultOpen: true },
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface WorkspaceSlotProps {
|
|
51
|
+
activeView: string
|
|
52
|
+
setActiveView: (viewId: string) => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const globalOptions = {
|
|
56
|
+
plugins: [createPinia()],
|
|
57
|
+
stubs: {
|
|
58
|
+
'router-link': {
|
|
59
|
+
props: ['to', 'custom'],
|
|
60
|
+
template: `
|
|
61
|
+
<slot
|
|
62
|
+
v-if="custom"
|
|
63
|
+
:href="typeof to === 'string' ? to : '#'"
|
|
64
|
+
:navigate="() => {}"
|
|
65
|
+
/>
|
|
66
|
+
<a v-else :href="typeof to === 'string' ? to : '#'"><slot /></a>
|
|
67
|
+
`,
|
|
68
|
+
},
|
|
69
|
+
BaseModal: {
|
|
70
|
+
template: '<div><slot /></div>',
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('PluginWorkspaceView internal split', () => {
|
|
76
|
+
it('renders through WorkspaceShellInternal, WorkspaceSidebarInternal and WorkspaceControlsInternal', () => {
|
|
77
|
+
const wrapper = mount(PluginWorkspaceView, {
|
|
78
|
+
props: {
|
|
79
|
+
title: 'MS Uploader',
|
|
80
|
+
subtitle: 'Import and visualize',
|
|
81
|
+
pillNav,
|
|
82
|
+
panels,
|
|
83
|
+
sidebarTitle: 'Tools',
|
|
84
|
+
sidebarSubtitle: 'Current file',
|
|
85
|
+
},
|
|
86
|
+
slots: {
|
|
87
|
+
actions: () => h('button', { 'data-test': 'topbar-action' }, 'Run'),
|
|
88
|
+
'section-import': () => h('button', { 'data-test': 'import-action' }, 'Upload'),
|
|
89
|
+
default: ({ activeView }: WorkspaceSlotProps) => h('main', { 'data-test': 'content' }, activeView),
|
|
90
|
+
},
|
|
91
|
+
global: globalOptions,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// The public adapter renders its three internal pieces...
|
|
95
|
+
expect(wrapper.findComponent(WorkspaceShellInternal).exists()).toBe(true)
|
|
96
|
+
expect(wrapper.findComponent(WorkspaceSidebarInternal).exists()).toBe(true)
|
|
97
|
+
expect(wrapper.findComponent(WorkspaceControlsInternal).exists()).toBe(true)
|
|
98
|
+
|
|
99
|
+
// ...each of which wraps the same underlying SDK components with the same DOM output
|
|
100
|
+
// the pre-split component produced.
|
|
101
|
+
expect(wrapper.findComponent(AppLayout).props()).toMatchObject({
|
|
102
|
+
floating: true,
|
|
103
|
+
responsiveSidebar: true,
|
|
104
|
+
sidebarPosition: 'left',
|
|
105
|
+
})
|
|
106
|
+
expect(wrapper.findComponent(AppTopBar).props()).toMatchObject({
|
|
107
|
+
title: 'MS Uploader',
|
|
108
|
+
subtitle: 'Import and visualize',
|
|
109
|
+
pillNav,
|
|
110
|
+
currentPillId: 'data',
|
|
111
|
+
})
|
|
112
|
+
expect(wrapper.findComponent(AppSidebar).props()).toMatchObject({
|
|
113
|
+
panels,
|
|
114
|
+
activeView: 'data',
|
|
115
|
+
title: 'Tools',
|
|
116
|
+
subtitle: 'Current file',
|
|
117
|
+
variant: 'analysis',
|
|
118
|
+
floating: false,
|
|
119
|
+
dense: true,
|
|
120
|
+
})
|
|
121
|
+
expect(wrapper.get('[data-test="topbar-action"]').text()).toBe('Run')
|
|
122
|
+
expect(wrapper.get('[data-test="import-action"]').text()).toBe('Upload')
|
|
123
|
+
expect(wrapper.get('[data-test="content"]').text()).toBe('data')
|
|
124
|
+
|
|
125
|
+
// WorkspaceControlsInternal receives exactly the scoped-slot payload the default slot sees.
|
|
126
|
+
expect(wrapper.findComponent(WorkspaceControlsInternal).props('controls')).toMatchObject({
|
|
127
|
+
activeView: 'data',
|
|
128
|
+
values: {},
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('keeps topbar navigation and sidebar panels synchronized through the internal split', async () => {
|
|
133
|
+
const wrapper = mount(PluginWorkspaceView, {
|
|
134
|
+
props: {
|
|
135
|
+
title: 'Analysis',
|
|
136
|
+
pillNav,
|
|
137
|
+
panels,
|
|
138
|
+
},
|
|
139
|
+
slots: {
|
|
140
|
+
default: ({ activeView }: WorkspaceSlotProps) => h('main', { 'data-test': 'content' }, activeView),
|
|
141
|
+
},
|
|
142
|
+
global: globalOptions,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
wrapper.findComponent(AppTopBar).vm.$emit('pill-select', { id: 'results', label: 'Results' })
|
|
146
|
+
await nextTick()
|
|
147
|
+
|
|
148
|
+
expect(wrapper.findComponent(AppTopBar).props('currentPillId')).toBe('results')
|
|
149
|
+
expect(wrapper.findComponent(AppSidebar).props('activeView')).toBe('results')
|
|
150
|
+
expect(wrapper.findComponent(WorkspaceSidebarInternal).props('sidebar')).toMatchObject({ activeView: 'results' })
|
|
151
|
+
expect(wrapper.get('[data-test="content"]').text()).toBe('results')
|
|
152
|
+
expect(wrapper.emitted('update:activeView')).toEqual([['results']])
|
|
153
|
+
expect(wrapper.emitted('pill-select')).toEqual([[{ id: 'results', label: 'Results' }]])
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('relays sidebar collapse, toggle, and form events unchanged through WorkspaceSidebarInternal', async () => {
|
|
157
|
+
const wrapper = mount(PluginWorkspaceView, {
|
|
158
|
+
props: {
|
|
159
|
+
title: 'Analysis',
|
|
160
|
+
panels,
|
|
161
|
+
forms: {
|
|
162
|
+
data: { sections: [] },
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
global: globalOptions,
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const sidebar = wrapper.findComponent(AppSidebar)
|
|
169
|
+
sidebar.vm.$emit('update:collapsed', true)
|
|
170
|
+
sidebar.vm.$emit('update:toggle', 'import', false)
|
|
171
|
+
sidebar.vm.$emit('section-action', 'import', 'refresh')
|
|
172
|
+
sidebar.vm.$emit('form-submit', 'data', { value: 1 })
|
|
173
|
+
sidebar.vm.$emit('form-cancel', 'data')
|
|
174
|
+
await nextTick()
|
|
175
|
+
|
|
176
|
+
expect(wrapper.emitted('update:sidebarCollapsed')).toEqual([[true]])
|
|
177
|
+
expect(wrapper.emitted('update:toggle')).toEqual([['import', false]])
|
|
178
|
+
expect(wrapper.emitted('section-action')).toEqual([['import', 'refresh']])
|
|
179
|
+
expect(wrapper.emitted('form-submit')).toEqual([['data', { value: 1 }]])
|
|
180
|
+
expect(wrapper.emitted('form-cancel')).toEqual([['data']])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('can render without a sidebar surface, omitting WorkspaceSidebarInternal entirely', () => {
|
|
184
|
+
const wrapper = mount(PluginWorkspaceView, {
|
|
185
|
+
props: {
|
|
186
|
+
title: 'Simple Plugin',
|
|
187
|
+
showSidebar: false,
|
|
188
|
+
},
|
|
189
|
+
slots: {
|
|
190
|
+
default: () => h('main', { 'data-test': 'content' }, 'Ready'),
|
|
191
|
+
},
|
|
192
|
+
global: globalOptions,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
expect(wrapper.findComponent(WorkspaceSidebarInternal).exists()).toBe(false)
|
|
196
|
+
expect(wrapper.findComponent(AppSidebar).exists()).toBe(false)
|
|
197
|
+
expect(wrapper.findComponent(AppTopBar).props('title')).toBe('Simple Plugin')
|
|
198
|
+
expect(wrapper.get('[data-test="content"]').text()).toBe('Ready')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('lets a consumer-supplied sidebar slot override WorkspaceSidebarInternal', () => {
|
|
202
|
+
const wrapper = mount(PluginWorkspaceView, {
|
|
203
|
+
props: {
|
|
204
|
+
title: 'Custom Sidebar',
|
|
205
|
+
panels,
|
|
206
|
+
},
|
|
207
|
+
slots: {
|
|
208
|
+
sidebar: () => h('aside', { 'data-test': 'custom-sidebar' }, 'Custom'),
|
|
209
|
+
},
|
|
210
|
+
global: globalOptions,
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
expect(wrapper.findComponent(WorkspaceSidebarInternal).exists()).toBe(false)
|
|
214
|
+
expect(wrapper.findComponent(AppSidebar).exists()).toBe(false)
|
|
215
|
+
expect(wrapper.get('[data-test="custom-sidebar"]').text()).toBe('Custom')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('lets a consumer-supplied topbar slot override WorkspaceShellInternal default AppTopBar rendering', () => {
|
|
219
|
+
const wrapper = mount(PluginWorkspaceView, {
|
|
220
|
+
props: { title: 'Custom Top Bar' },
|
|
221
|
+
slots: {
|
|
222
|
+
topbar: () => h('header', { 'data-test': 'custom-topbar' }, 'Custom'),
|
|
223
|
+
},
|
|
224
|
+
global: globalOptions,
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
expect(wrapper.findComponent(AppTopBar).exists()).toBe(false)
|
|
228
|
+
expect(wrapper.get('[data-test="custom-topbar"]').text()).toBe('Custom')
|
|
229
|
+
})
|
|
230
|
+
})
|