Repository navigation
fix: resolve #68 - [tryabby/abby] Vue Integration #195
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| { | ||
| "name": "@tryabby/vue", | ||
| "version": "0.0.1", | ||
| "description": "Vue integration for Abby A/B testing, feature flags, and remote config.", | ||
| "keywords": [ | ||
| "abby", | ||
| "vue", | ||
| "nuxt", | ||
| "ab-testing", | ||
| "feature-flags", | ||
| "remote-config" | ||
| ], | ||
| "author": "Abby Team", | ||
| "license": "MIT", | ||
| "repository": { | ||
| "type": "git", | ||
| "url": "https://github.com/tryabby/abby.git", | ||
| "directory": "packages/vue" | ||
| }, | ||
| "main": "./dist/index.js", | ||
| "module": "./dist/index.mjs", | ||
| "types": "./dist/index.d.ts", | ||
| "exports": { | ||
| ".": { | ||
| "import": "./dist/index.mjs", | ||
| "require": "./dist/index.js", | ||
| "types": "./dist/index.d.ts" | ||
| } | ||
| }, | ||
| "files": [ | ||
| "dist" | ||
| ], | ||
| "scripts": { | ||
| "build": "tsup", | ||
| "dev": "tsup --watch", | ||
| "test": "vitest", | ||
| "typecheck": "tsc --noEmit" | ||
| }, | ||
| "dependencies": { | ||
| "@tryabby/core": "workspace:^" | ||
| }, | ||
| "peerDependencies": { | ||
| "vue": "^3.0.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@tryabby/core": "workspace:^", | ||
| "@vue/test-utils": "^2.4.6", | ||
| "jsdom": "^24.0.0", | ||
| "tsup": "^8.0.2", | ||
| "typescript": "^5.4.5", | ||
| "vite": "^5.2.11", | ||
| "vitest": "^1.6.0", | ||
| "vue": "^3.4.27" | ||
| }, | ||
| "publishConfig": { | ||
| "access": "public" | ||
| } | ||
| } | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,268 @@ | ||
| import { mount } from '@vue/test-utils'; | ||
| import { defineComponent, provide, ref, nextTick } from 'vue'; | ||
| import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| import { AbbyClient } from '@tryabby/core'; | ||
| import { ABBY_CLIENT_KEY } from '../constants'; | ||
| import { useAbby, useFeatureFlag, useRemoteConfig } from '../composables'; | ||
| import type { AbbyClientContext } from '../types'; | ||
|
|
||
| // Mock the AbbyClient from @tryabby/core | ||
| const mockAbbyClientInstance = { | ||
| init: vi.fn(() => Promise.resolve()), | ||
| getVariant: vi.fn(() => ({ name: 'control', payload: {} })), | ||
| getFeatureFlag: vi.fn(() => true), | ||
| getRemoteConfig: vi.fn(() => 'remoteValue'), | ||
| onAct: vi.fn(), | ||
| }; | ||
|
|
||
| vi.mock('@tryabby/core', () => { | ||
| return { | ||
| AbbyClient: vi.fn(() => mockAbbyClientInstance), | ||
| }; | ||
| }); | ||
|
|
||
| describe('Abby Composables', () => { | ||
| const createTestComponent = (composableHook: Function, hookArgs?: any[]) => | ||
| defineComponent({ | ||
| setup() { | ||
| // Provide a mock AbbyClient context for testing composables in isolation | ||
| const isInitialized = ref(true); | ||
| const client = mockAbbyClientInstance as unknown as AbbyClient; | ||
| const context: AbbyClientContext = { client, isInitialized }; | ||
| provide(ABBY_CLIENT_KEY, context); | ||
|
|
||
| if (hookArgs) { | ||
| return { result: composableHook(...hookArgs) }; | ||
| } | ||
| return { result: composableHook() }; | ||
| }, | ||
| template: '<div />', | ||
| }); | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| // Reset mock return values for each test | ||
| mockAbbyClientInstance.getVariant.mockReturnValue({ name: 'control', payload: {} }); | ||
| mockAbbyClientInstance.getFeatureFlag.mockReturnValue(true); | ||
| mockAbbyClientInstance.getRemoteConfig.mockReturnValue('remoteValue'); | ||
| }); | ||
|
|
||
| describe('useAbby', () => { | ||
| it('should return the variant and onAct method', async () => { | ||
| const TestComponent = createTestComponent(useAbby); | ||
| const wrapper = mount(TestComponent); | ||
|
|
||
| const { variant, onAct, isInitialized } = wrapper.vm.result; | ||
|
|
||
| expect(variant.value).toEqual({ name: 'control', payload: {} }); | ||
| expect(typeof onAct).toBe('function'); | ||
| expect(isInitialized.value).toBe(true); | ||
| expect(mockAbbyClientInstance.getVariant).toHaveBeenCalledTimes(1); | ||
|
|
||
| onAct('test_action', { key: 'value' }); | ||
| expect(mockAbbyClientInstance.onAct).toHaveBeenCalledWith('test_action', { key: 'value' }); | ||
| }); | ||
|
|
||
| it('should update variant reactively when client state changes (simulated)', async () => { | ||
| const TestComponent = defineComponent({ | ||
| setup() { | ||
| const isInitialized = ref(true); | ||
| const client = mockAbbyClientInstance as unknown as AbbyClient; | ||
| const context: AbbyClientContext = { client, isInitialized }; | ||
| provide(ABBY_CLIENT_KEY, context); | ||
|
|
||
| const { variant } = useAbby(); | ||
| return { variant }; | ||
| }, | ||
| template: `<div>{{ variant?.name }}</div>`, | ||
| }); | ||
|
|
||
| const wrapper = mount(TestComponent); | ||
| expect(wrapper.text()).toBe('control'); | ||
|
|
||
| mockAbbyClientInstance.getVariant.mockReturnValue({ name: 'variantA', payload: {} }); | ||
| // Trigger reactivity by forcing a re-render or state change that would re-evaluate watchEffect | ||
| // In a real app, this would happen if AbbyClient itself emitted an event or its internal state changed. | ||
| // For testing, we can manually trigger a component update or re-mount. | ||
| // A more robust solution would involve the mock client having an observable state. | ||
| // For now, we simulate by re-mounting or updating a prop if the component was designed to react to it. | ||
| // Since `watchEffect` runs on dependency changes, we need to ensure `getVariant` is re-evaluated. | ||
| // The current `watchEffect` in `useAbby` depends on `isInitialized.value` and `client`. | ||
| // To simulate a variant change, we'd ideally have the mock client notify. | ||
| // For this test, we'll assume `getVariant` is called when `isInitialized` changes or on initial setup. | ||
| // Let's simulate a re-evaluation by changing a reactive dependency. | ||
| // A simpler way to test reactivity is to directly change the mock's return value and then | ||
| // trigger a re-render if the composable's dependencies were to change. | ||
| // For `getVariant`, it's called inside `watchEffect` which runs on `isInitialized` change. | ||
| // Let's simulate a change in `isInitialized` to trigger the `watchEffect`. | ||
| const isInitializedRef = (wrapper.vm as any).result?.isInitialized || ref(true); // Access the ref from the component instance | ||
| isInitializedRef.value = false; | ||
| await nextTick(); | ||
| isInitializedRef.value = true; | ||
| await nextTick(); | ||
|
Comment on lines
+98
to
+102
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Toggle the injected initialization ref in the reactivity test. This component returns only 🤖 Prompt for AI Agents |
||
|
|
||
| expect(wrapper.text()).toBe('variantA'); | ||
| expect(mockAbbyClientInstance.getVariant).toHaveBeenCalledTimes(2); // Initial + after isInitialized change | ||
| }); | ||
|
|
||
| it('should return null variant if not initialized', async () => { | ||
| const TestComponent = defineComponent({ | ||
| setup() { | ||
| const isInitialized = ref(false); // Simulate not initialized | ||
| const client = mockAbbyClientInstance as unknown as AbbyClient; | ||
| const context: AbbyClientContext = { client, isInitialized }; | ||
| provide(ABBY_CLIENT_KEY, context); | ||
|
|
||
| const { variant, isInitialized: hookIsInitialized } = useAbby(); | ||
| return { variant, hookIsInitialized }; | ||
| }, | ||
| template: `<div>{{ variant?.name }}</div>`, | ||
| }); | ||
|
|
||
| const wrapper = mount(TestComponent); | ||
| expect(wrapper.text()).toBe(''); | ||
| expect(wrapper.vm.variant.value).toBeNull(); | ||
| expect(wrapper.vm.hookIsInitialized.value).toBe(false); | ||
| expect(mockAbbyClientInstance.getVariant).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('should warn if onAct is called when client is not available', async () => { | ||
| const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); | ||
| const TestComponent = defineComponent({ | ||
| setup() { | ||
| // Provide a context with null client to simulate unavailability | ||
| const isInitialized = ref(false); | ||
| const context: AbbyClientContext = { client: null, isInitialized }; | ||
| provide(ABBY_CLIENT_KEY, context); | ||
| const { onAct } = useAbby(); | ||
| return { onAct }; | ||
| }, | ||
| template: '<div />', | ||
| }); | ||
| const wrapper = mount(TestComponent); | ||
| wrapper.vm.onAct('test'); | ||
| expect(consoleWarnSpy).toHaveBeenCalledWith('AbbyClient not available. Cannot call onAct.'); | ||
| expect(mockAbbyClientInstance.onAct).not.toHaveBeenCalled(); | ||
| consoleWarnSpy.mockRestore(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('useFeatureFlag', () => { | ||
| it('should return the reactive feature flag value', async () => { | ||
| const TestComponent = createTestComponent(useFeatureFlag, ['myFlag']); | ||
| const wrapper = mount(TestComponent); | ||
|
|
||
| const flagValue = wrapper.vm.result; | ||
| expect(flagValue.value).toBe(true); | ||
| expect(mockAbbyClientInstance.getFeatureFlag).toHaveBeenCalledWith('myFlag'); | ||
|
|
||
| mockAbbyClientInstance.getFeatureFlag.mockReturnValue(false); | ||
| // Trigger reactivity by changing a dependency or forcing re-render | ||
| const isInitializedRef = (wrapper.vm as any).result?.isInitialized || ref(true); | ||
| isInitializedRef.value = false; | ||
| await nextTick(); | ||
| isInitializedRef.value = true; | ||
| await nextTick(); | ||
|
|
||
| expect(flagValue.value).toBe(false); | ||
| expect(mockAbbyClientInstance.getFeatureFlag).toHaveBeenCalledWith('myFlag'); | ||
| expect(mockAbbyClientInstance.getFeatureFlag).toHaveBeenCalledTimes(2); | ||
| }); | ||
|
|
||
| it('should return null if not initialized', async () => { | ||
| const TestComponent = defineComponent({ | ||
| setup() { | ||
| const isInitialized = ref(false); | ||
| const client = mockAbbyClientInstance as unknown as AbbyClient; | ||
| const context: AbbyClientContext = { client, isInitialized }; | ||
| provide(ABBY_CLIENT_KEY, context); | ||
| return { flag: useFeatureFlag('myFlag') }; | ||
| }, | ||
| template: `<div>{{ flag }}</div>`, | ||
| }); | ||
| const wrapper = mount(TestComponent); | ||
| expect(wrapper.text()).toBe(''); | ||
| expect(wrapper.vm.flag.value).toBeNull(); | ||
| expect(mockAbbyClientInstance.getFeatureFlag).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('useRemoteConfig', () => { | ||
| it('should return the reactive remote config value', async () => { | ||
| const TestComponent = createTestComponent(useRemoteConfig, ['myConfig']); | ||
| const wrapper = mount(TestComponent); | ||
|
|
||
| const configValue = wrapper.vm.result; | ||
| expect(configValue.value).toBe('remoteValue'); | ||
| expect(mockAbbyClientInstance.getRemoteConfig).toHaveBeenCalledWith('myConfig'); | ||
|
|
||
| mockAbbyClientInstance.getRemoteConfig.mockReturnValue(123); | ||
| // Trigger reactivity | ||
| const isInitializedRef = (wrapper.vm as any).result?.isInitialized || ref(true); | ||
| isInitializedRef.value = false; | ||
| await nextTick(); | ||
| isInitializedRef.value = true; | ||
| await nextTick(); | ||
|
|
||
| expect(configValue.value).toBe(123); | ||
| expect(mockAbbyClientInstance.getRemoteConfig).toHaveBeenCalledWith('myConfig'); | ||
| expect(mockAbbyClientInstance.getRemoteConfig).toHaveBeenCalledTimes(2); | ||
| }); | ||
|
|
||
| it('should return null if not initialized', async () => { | ||
| const TestComponent = defineComponent({ | ||
| setup() { | ||
| const isInitialized = ref(false); | ||
| const client = mockAbbyClientInstance as unknown as AbbyClient; | ||
| const context: AbbyClientContext = { client, isInitialized }; | ||
| provide(ABBY_CLIENT_KEY, context); | ||
| return { config: useRemoteConfig('myConfig') }; | ||
| }, | ||
| template: `<div>{{ config }}</div>`, | ||
| }); | ||
| const wrapper = mount(TestComponent); | ||
| expect(wrapper.text()).toBe(''); | ||
| expect(wrapper.vm.config.value).toBeNull(); | ||
| expect(mockAbbyClientInstance.getRemoteConfig).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Error Handling', () => { | ||
| it('useAbby should throw an error if not used within AbbyProvider', () => { | ||
| const TestComponent = defineComponent({ | ||
| setup() { | ||
| return { result: useAbby() }; | ||
| }, | ||
| template: '<div />', | ||
| }); | ||
| expect(() => mount(TestComponent)).toThrow( | ||
| 'AbbyClient is not initialized. Please ensure you are using useAbby, useFeatureFlag, or useRemoteConfig within an <AbbyProvider>.' | ||
| ); | ||
| }); | ||
|
|
||
| it('useFeatureFlag should throw an error if not used within AbbyProvider', () => { | ||
| const TestComponent = defineComponent({ | ||
| setup() { | ||
| return { result: useFeatureFlag('test') }; | ||
| }, | ||
| template: '<div />', | ||
| }); | ||
| expect(() => mount(TestComponent)).toThrow( | ||
| 'AbbyClient is not initialized. Please ensure you are using useAbby, useFeatureFlag, or useRemoteConfig within an <AbbyProvider>.' | ||
| ); | ||
| }); | ||
|
|
||
| it('useRemoteConfig should throw an error if not used within AbbyProvider', () => { | ||
| const TestComponent = defineComponent({ | ||
| setup() { | ||
| return { result: useRemoteConfig('test') }; | ||
| }, | ||
| template: '<div />', | ||
| }); | ||
| expect(() => mount(TestComponent)).toThrow( | ||
| 'AbbyClient is not initialized. Please ensure you are using useAbby, useFeatureFlag, or useRemoteConfig within an <AbbyProvider>.' | ||
| ); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Provide context from an ancestor component.
createTestComponentcallsprovide()and then calls the composable in the same component’ssetup(). Vue injection resolves values from ancestors, not from the current component. Mount a child that calls the composable under a parent provider, or use mount-levelglobal.provide. The other inline test components repeat this setup pattern. (vuejs.org)🤖 Prompt for AI Agents