diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 00000000..e607102e --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,45 @@ +{ + "name": "@tryabby/vue", + "version": "4.0.0", + "description": "Vue integration for Abby", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "import": "./dist/index.mjs", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest", + "test:ci": "vitest run" + }, + "keywords": [ + "vue", + "abby", + "ab-testing", + "feature-flags" + ], + "author": "Abby Team", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.0" + }, + "dependencies": { + "@tryabby/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^18.0.0", + "tsup": "^7.0.0", + "typescript": "^5.0.0", + "vitest": "^0.34.0", + "vue": "^3.3.0" + } +} diff --git a/packages/vue/src/__tests__/use-abby.spec.ts b/packages/vue/src/__tests__/use-abby.spec.ts new file mode 100644 index 00000000..42f47a5d --- /dev/null +++ b/packages/vue/src/__tests__/use-abby.spec.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useAbby } from "../use-abby"; +import * as core from "@tryabby/core"; + +vi.mock("@tryabby/core"); + +describe("useAbby", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should initialize with variant", () => { + const mockConfig = { tests: { test1: {} } }; + const mockActFn = vi.fn(); + + vi.mocked(core.getVariant).mockReturnValue("control"); + vi.mocked(core.getAct).mockReturnValue(mockActFn); + + const result = useAbby(mockConfig as any, "test1"); + + expect(result.variant.value).toBe("control"); + expect(result.onAct).toBeDefined(); + }); + + it("should call act function with data", () => { + const mockConfig = { tests: { test1: {} } }; + const mockActFn = vi.fn(); + + vi.mocked(core.getVariant).mockReturnValue("treatment"); + vi.mocked(core.getAct).mockReturnValue(mockActFn); + + const result = useAbby(mockConfig as any, "test1"); + const data = { userId: "123" }; + + result.onAct(data); + + expect(mockActFn).toHaveBeenCalledWith("test1", data); + }); + + it("should throw error when config is missing", () => { + expect(() => useAbby(null as any, "test1")).toThrow( + "Abby config is required" + ); + }); + + it("should throw error when test name is missing", () => { + const mockConfig = { tests: {} }; + + expect(() => useAbby(mockConfig as any, "" as any)).toThrow( + "Test name is required" + ); + }); + + it("should handle core errors gracefully", () => { + const mockConfig = { tests: { test1: {} } }; + + vi.mocked(core.getVariant).mockImplementation(() => { + throw new Error("Test not found"); + }); + + expect(() => useAbby(mockConfig as any, "test1")).toThrow( + 'Failed to initialize useAbby for test "test1"' + ); + }); + + it("should warn when act function is not available", () => { + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const mockConfig = { tests: { test1: {} } }; + + vi.mocked(core.getVariant).mockReturnValue("control"); + vi.mocked(core.getAct).mockReturnValue(undefined as any); + + const result = useAbby(mockConfig as any, "test1"); + result.onAct({}); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + "Act function not available in Abby config" + ); + + consoleWarnSpy.mockRestore(); + }); + + it("should return readonly ref", () => { + const mockConfig = { tests: { test1: {} } }; + const mockActFn = vi.fn(); + + vi.mocked(core.getVariant).mockReturnValue("control"); + vi.mocked(core.getAct).mockReturnValue(mockActFn); + + const result = useAbby(mockConfig as any, "test1"); + + expect(() => { + result.variant.value = "treatment" as any; + }).toThrow(); + }); +}); \ No newline at end of file diff --git a/packages/vue/src/__tests__/use-feature-flag.spec.ts b/packages/vue/src/__tests__/use-feature-flag.spec.ts new file mode 100644 index 00000000..3af113a2 --- /dev/null +++ b/packages/vue/src/__tests__/use-feature-flag.spec.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useFeatureFlag } from "../use-feature-flag"; +import * as core from "@tryabby/core"; + +vi.mock("@tryabby/core"); + +describe("useFeatureFlag", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should initialize with enabled state", () => { + const mockConfig = { featureFlags: { flag1: {} } }; + + vi.mocked(core.getFeatureFlag).mockReturnValue(true); + + const result = useFeatureFlag(mockConfig as any, "flag1"); + + expect(result.isEnabled.value).toBe(true); + }); + + it("should initialize with disabled state", () => { + const mockConfig = { featureFlags: { flag1: {} } }; + + vi.mocked(core.getFeatureFlag).mockReturnValue(false); + + const result = useFeatureFlag(mockConfig as any, "flag1"); + + expect(result.isEnabled.value).toBe(false); + }); + + it("should throw error when config is missing", () => { + expect(() => useFeatureFlag(null as any, "flag1")).toThrow( + "Abby config is required" + ); + }); + + it("should throw error when flag name is missing", () => { + const mockConfig = { featureFlags: {} }; + + expect(() => useFeatureFlag(mockConfig as any, "" as any)).toThrow( + "Feature flag name is required" + ); + }); + + it("should handle core errors gracefully", () => { + const mockConfig = { featureFlags: { flag1: {} } }; + + vi.mocked(core.getFeatureFlag).mockImplementation(() => { + throw new Error("Flag not found"); + }); + + expect(() => useFeatureFlag(mockConfig as any, "flag1")).toThrow( + 'Failed to initialize useFeatureFlag for flag "flag1"' + ); + }); + + it("should return readonly ref", () => { + const mockConfig = { featureFlags: { flag1: {} } }; + + vi.mocked(core.getFeatureFlag).mockReturnValue(true); + + const result = useFeatureFlag(mockConfig as any, "flag1"); + + expect(() => { + result.isEnabled.value = false as any; + }).toThrow(); + }); +}); \ No newline at end of file diff --git a/packages/vue/src/__tests__/use-remote-config.spec.ts b/packages/vue/src/__tests__/use-remote-config.spec.ts new file mode 100644 index 00000000..3848a478 --- /dev/null +++ b/packages/vue/src/__tests__/use-remote-config.spec.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest' +import { useRemoteConfig } from '../use-remote-config' + +describe('useRemoteConfig', () => { + it('should work', () => { + expect(useRemoteConfig).toBeDefined() + }) +}) diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 00000000..fd05e3e3 --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,8 @@ +export { useAbby } from "./use-abby"; +export { useFeatureFlag } from "./use-feature-flag"; +export { useRemoteConfig } from "./use-remote-config"; +export type { + UseAbbyReturn, + UseFeatureFlagReturn, + UseRemoteConfigReturn, +} from "./types"; \ No newline at end of file diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts new file mode 100644 index 00000000..2410eec7 --- /dev/null +++ b/packages/vue/src/types.ts @@ -0,0 +1,25 @@ +import type { AbbyConfig } from "@tryabby/core"; +import type { ComputedRef, Ref } from "vue"; + +export interface UseAbbyOptions { + config: T; +} + +export interface UseFeatureFlagOptions { + config: T; +} + +export interface UseRemoteConfigOptions { + config: T; +} + +export interface UseAbbyReturn { + variant: Readonly>; + onAct: (data?: Record) => void; +} + +export interface UseFeatureFlagReturn { + isEnabled: Readonly>; +} + +export type UseRemoteConfigReturn = ComputedRef; diff --git a/packages/vue/src/use-abby.ts b/packages/vue/src/use-abby.ts new file mode 100644 index 00000000..1b7c54bd --- /dev/null +++ b/packages/vue/src/use-abby.ts @@ -0,0 +1,48 @@ +import type { AbbyConfig } from "@tryabby/core"; +import { getVariant, getAct } from "@tryabby/core"; +import { computed, readonly, ref, type Ref } from "vue"; +import type { UseAbbyReturn } from "./types"; + +/** + * Composable for A/B testing with Abby + * @param config - Abby configuration + * @param name - Test name + * @returns Variant and onAct function + * @throws If test name is not found in config + */ +export function useAbby( + config: T, + name: keyof T["tests"] +): UseAbbyReturn { + if (!config) { + throw new Error("Abby config is required"); + } + + if (!name) { + throw new Error("Test name is required"); + } + + const testName = String(name); + + try { + const variant = ref(getVariant(config, testName)) as Ref; + const actFn = getAct(config); + + const onAct = (data?: Record) => { + if (!actFn) { + console.warn("Act function not available in Abby config"); + return; + } + actFn(testName, data); + }; + + return { + variant: readonly(variant), + onAct, + }; + } catch (error) { + throw new Error( + `Failed to initialize useAbby for test "${testName}": ${error instanceof Error ? error.message : String(error)}` + ); + } +} \ No newline at end of file diff --git a/packages/vue/src/use-feature-flag.ts b/packages/vue/src/use-feature-flag.ts new file mode 100644 index 00000000..c2ba4368 --- /dev/null +++ b/packages/vue/src/use-feature-flag.ts @@ -0,0 +1,40 @@ +import type { AbbyConfig } from "@tryabby/core"; +import { getFeatureFlag } from "@tryabby/core"; +import { readonly, ref, type Ref } from "vue"; +import type { UseFeatureFlagReturn } from "./types"; + +/** + * Composable for feature flags with Abby + * @param config - Abby configuration + * @param name - Feature flag name + * @returns isEnabled ref + * @throws If feature flag name is not found in config + */ +export function useFeatureFlag( + config: T, + name: keyof T["featureFlags"] +): UseFeatureFlagReturn { + if (!config) { + throw new Error("Abby config is required"); + } + + if (!name) { + throw new Error("Feature flag name is required"); + } + + const flagName = String(name); + + try { + const isEnabled = ref( + getFeatureFlag(config, flagName) + ) as Ref; + + return { + isEnabled: readonly(isEnabled), + }; + } catch (error) { + throw new Error( + `Failed to initialize useFeatureFlag for flag "${flagName}": ${error instanceof Error ? error.message : String(error)}` + ); + } +} \ No newline at end of file diff --git a/packages/vue/src/use-remote-config.ts b/packages/vue/src/use-remote-config.ts new file mode 100644 index 00000000..bebf68ba --- /dev/null +++ b/packages/vue/src/use-remote-config.ts @@ -0,0 +1,15 @@ +import { computed, type ComputedRef } from 'vue' +import { getRemoteConfig } from '@tryabby/core' +import type { AbbyConfig } from '@tryabby/core' + +export function useRemoteConfig< + T extends AbbyConfig, + K extends keyof NonNullable +>( + config: T, + name: K +): ComputedRef[K]> { + return computed(() => { + return getRemoteConfig(config, name as string) as NonNullable[K] + }) +} diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json new file mode 100644 index 00000000..c2325b3e --- /dev/null +++ b/packages/vue/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../packages/tsconfig/base.json", + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +} \ No newline at end of file diff --git a/packages/vue/tsup.config.ts b/packages/vue/tsup.config.ts new file mode 100644 index 00000000..a9a7f7df --- /dev/null +++ b/packages/vue/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + sourcemap: true, + clean: true, + external: ["vue", "@tryabby/core"], + minify: true, + shims: true, + splitting: false, +}); \ No newline at end of file