From b88f5abd7999e4048170a9f8d1302245ead4cf28 Mon Sep 17 00:00:00 2001 From: Veilreaver Null Date: Wed, 11 Nov 2026 12:33:42 -0800 Subject: [PATCH] fix(vue): address all 8 CodeRabbit review comments (core Abby API, per-factory InjectionKey, selectedVariant, ACT event, LookupValue default & type tests) (#68) Fixes tryabby/abby#68 Closes tryabby/abby#68 /claim #68 --- packages/vue/README.md | 58 +++++++ packages/vue/package.json | 29 ++++ packages/vue/src/index.ts | 246 +++++++++++++++++++++++++++++ packages/vue/tests/types.test.ts | 36 +++++ packages/vue/tests/useAbby.test.ts | 26 +++ tests/test_abby_vue_integration.py | 29 ++++ 6 files changed, 424 insertions(+) create mode 100644 packages/vue/README.md create mode 100644 packages/vue/package.json create mode 100644 packages/vue/src/index.ts create mode 100644 packages/vue/tests/types.test.ts create mode 100644 packages/vue/tests/useAbby.test.ts create mode 100644 tests/test_abby_vue_integration.py diff --git a/packages/vue/README.md b/packages/vue/README.md new file mode 100644 index 00000000..eb1fa634 --- /dev/null +++ b/packages/vue/README.md @@ -0,0 +1,58 @@ +# `@tryabby/vue` + +Vue 3 integration for Abby A/B Testing, Feature Flags, and Remote Config (`#68`). + +## Installation + +```bash +pnpm add @tryabby/vue @tryabby/core +``` + +## Setup & Usage + +1. Initialize Abby with `defineConfig` and `createAbby`: + +```ts +import { createAbby, defineConfig } from "@tryabby/vue"; + +export const abbyConfig = defineConfig({ + projectId: "your-project-id", + currentEnvironment: "production", + environments: ["production", "development"], + tests: { + heroBanner: { variants: ["A", "B"] }, + }, + flags: ["newCheckout"], + remoteConfig: { + welcomeTitle: "string", + }, +}); + +export const { + AbbyProvider, + useAbby, + useFeatureFlag, + useRemoteConfig, +} = createAbby(abbyConfig); +``` + +2. Wrap your application root in `` (optionally passing `initialData` for SSR hydration): + +```vue + +``` + +3. Consume `useAbby`, `useFeatureFlag`, and `useRemoteConfig` inside any descendant Vue component: + +```ts +const { variant, onAct } = useAbby("heroBanner", { + A: "Welcome Variant A", + B: "Welcome Variant B", +}); +const isNewCheckout = useFeatureFlag("newCheckout"); +const welcomeTitle = useRemoteConfig("welcomeTitle"); +``` diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 00000000..5127b636 --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tryabby/vue", + "version": "0.1.0", + "description": "Vue 3 integration for Abby A/B testing, feature flags, and remote config", + "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 src/index.ts --format cjs,esm --dts", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "vue": "^3.3.0" + }, + "dependencies": { + "@tryabby/core": "workspace:*" + } +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 00000000..59a88f2d --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,246 @@ +import { + type ABConfig, + Abby, + type AbbyConfig, + type AbbyDataResponse, + AbbyEventType, + HttpService, + type RemoteConfigValueString, + type RemoteConfigValueStringToType, + type ValidatorType, + defineConfig, +} from "@tryabby/core"; +import { + computed, + defineComponent, + inject, + onMounted, + onUnmounted, + provide, + ref, + watchEffect, + type ComputedRef, + type InjectionKey, + type PropType, + type Ref, +} from "vue"; + +export { type ABConfig, type AbbyConfig, defineConfig }; + +export type ABTestReturnValue = Lookup extends undefined + ? TestVariant + : TestVariant extends keyof Lookup + ? Lookup[TestVariant] + : never; + +export function createAbby< + const FlagName extends string, + const TestName extends string, + const Tests extends Record, + const RemoteConfig extends Record, + const RemoteConfigName extends Extract, + const User extends Record = Record, +>( + abbyConfig: AbbyConfig< + FlagName, + Tests, + string[], + RemoteConfigName, + RemoteConfig, + User + > +) { + const memoryStore = new Map(); + const storageAdapter = { + get: (key: string) => memoryStore.get(`${abbyConfig.projectId}:${key}`) ?? null, + set: (key: string, value: any) => { + memoryStore.set(`${abbyConfig.projectId}:${key}`, String(value)); + }, + }; + + const abby = new Abby< + FlagName, + TestName, + Tests, + RemoteConfig, + RemoteConfigName, + string[], + User + >(abbyConfig, storageAdapter, storageAdapter, storageAdapter); + + type AbbyProjectData = ReturnType; + + // Per-factory injection key so nested providers from separate createAbby instances never collide + const abbyInjectionKey: InjectionKey> = Symbol("AbbyInjectionKey"); + + const useAbbyData = (): Ref => { + const injected = inject(abbyInjectionKey, null); + if (!injected) { + throw new Error( + "useAbbyData must be used within an AbbyProvider. Wrap a parent component in to fix this error." + ); + } + return injected; + }; + + const AbbyProvider = defineComponent({ + name: "AbbyProvider", + props: { + initialData: { + type: Object as PropType, + required: false, + }, + }, + setup(props, { slots }) { + if (props.initialData) { + abby.setResponse(props.initialData); + } + const projectData = ref(abby.getProjectData()) as Ref; + + // Subscribe synchronously during setup before child component setup executes + const unsubscribe = abby.subscribe(() => { + projectData.value = abby.getProjectData(); + }); + + provide(abbyInjectionKey, projectData); + + onMounted(() => { + if (!props.initialData) { + void abby.load(); + } + }); + + onUnmounted(() => { + if (typeof unsubscribe === "function") { + unsubscribe(); + } + }); + + return () => (slots.default ? slots.default() : null); + }, + }); + + const useAbby = < + K extends keyof Tests, + TestVariant extends Tests[K]["variants"][number], + LookupValue = unknown, + const Lookup extends Record | undefined = undefined, + >( + name: K, + lookupObject?: Lookup + ): { + variant: ComputedRef>; + onAct: () => void; + } => { + const projectData = useAbbyData(); + + const selectedVariant = computed(() => { + const snap = projectData.value.tests?.[name as unknown as TestName]?.selectedVariant; + if (snap !== undefined) { + return snap as TestVariant; + } + return abby.getTestVariant(name) as TestVariant; + }); + + const variant = computed>(() => { + const raw = selectedVariant.value; + if (lookupObject === undefined) { + return raw as unknown as ABTestReturnValue; + } + return lookupObject[raw] as unknown as ABTestReturnValue; + }); + + watchEffect(() => { + const currentVariant = selectedVariant.value; + const testId = projectData.value.tests?.[name as unknown as TestName]?.id; + if (!name || !currentVariant || !testId) return; + void HttpService.sendData({ + url: abbyConfig.apiUrl, + type: AbbyEventType.PING, + data: { + projectId: abbyConfig.projectId, + selectedVariant: currentVariant, + testId, + }, + }); + }); + + const onAct = () => { + const currentVariant = selectedVariant.value; + const testId = projectData.value.tests?.[name as unknown as TestName]?.id; + if (!currentVariant || !testId) return; + void HttpService.sendData({ + url: abbyConfig.apiUrl, + type: AbbyEventType.ACT, + data: { + projectId: abbyConfig.projectId, + selectedVariant: currentVariant, + testId, + }, + }); + }; + + return { variant, onAct }; + }; + + const useFeatureFlag = (flagName: K): ComputedRef => { + const projectData = useAbbyData(); + return computed(() => { + const val = projectData.value.flags?.[flagName]; + if (typeof val === "boolean") return val; + return Boolean(abby.getFeatureFlag(flagName)); + }); + }; + + const useRemoteConfig = < + T extends RemoteConfigName, + Config extends RemoteConfig[T], + >( + configKey: T + ): ComputedRef> => { + const projectData = useAbbyData(); + return computed>(() => { + const entry = projectData.value.remoteConfig?.[configKey]; + if (entry && "value" in entry) { + return entry.value as RemoteConfigValueStringToType; + } + return abby.getRemoteConfig(configKey); + }); + }; + + const getFeatureFlagValue = (name: FlagName): boolean => abby.getFeatureFlag(name); + + const getRemoteConfig = < + T extends RemoteConfigName, + Config extends RemoteConfig[T], + >( + remoteConfigName: T + ): RemoteConfigValueStringToType => abby.getRemoteConfig(remoteConfigName); + + const getABTestValue = < + K extends keyof Tests, + TestVariant extends Tests[K]["variants"][number], + LookupValue = unknown, + const Lookup extends Record | undefined = undefined, + >( + testName: K, + lookupObject?: Lookup + ): ABTestReturnValue => { + const v = abby.getTestVariant(testName) as TestVariant; + if (lookupObject === undefined) { + return v as unknown as ABTestReturnValue; + } + return lookupObject[v] as unknown as ABTestReturnValue; + }; + + return { + AbbyProvider, + useAbby, + useFeatureFlag, + useRemoteConfig, + getFeatureFlagValue, + getRemoteConfig, + getABTestValue, + __abby__: abby, + }; +} diff --git a/packages/vue/tests/types.test.ts b/packages/vue/tests/types.test.ts new file mode 100644 index 00000000..c815cfb6 --- /dev/null +++ b/packages/vue/tests/types.test.ts @@ -0,0 +1,36 @@ +import { describe, expectTypeOf, it } from "vitest"; +import type { ComputedRef } from "vue"; +import { createAbby, defineConfig } from "../src/index"; + +describe("@tryabby/vue type inference", () => { + const config = defineConfig({ + projectId: "proj_vue_types_68", + currentEnvironment: "production", + environments: ["production"], + tests: { + checkoutFlow: { variants: ["control", "streamlined"] }, + }, + flags: ["newSidebar"], + remoteConfig: { + retryCount: "number", + greeting: "string", + }, + }); + + const { getABTestValue, getFeatureFlagValue, getRemoteConfig } = createAbby(config); + + it("infers variant union and lookup return types without explicit type arguments", () => { + const rawVariant = getABTestValue("checkoutFlow"); + expectTypeOf(rawVariant).toEqualTypeOf<"control" | "streamlined">(); + + const mapped = getABTestValue("checkoutFlow", { + control: "Old Checkout", + streamlined: "New Checkout", + }); + expectTypeOf(mapped).toEqualTypeOf(); + + expectTypeOf(getFeatureFlagValue("newSidebar")).toEqualTypeOf(); + expectTypeOf(getRemoteConfig("retryCount")).toEqualTypeOf(); + expectTypeOf(getRemoteConfig("greeting")).toEqualTypeOf(); + }); +}); diff --git a/packages/vue/tests/useAbby.test.ts b/packages/vue/tests/useAbby.test.ts new file mode 100644 index 00000000..4c4fde51 --- /dev/null +++ b/packages/vue/tests/useAbby.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { createAbby, defineConfig } from "../src/index"; + +describe("@tryabby/vue runtime integration", () => { + const config = defineConfig({ + projectId: "proj_vue_68", + currentEnvironment: "production", + environments: ["production", "development"], + tests: { + heroBanner: { variants: ["A", "B"] }, + }, + flags: ["darkMode"], + remoteConfig: { + maxItems: "number", + welcomeBanner: "string", + }, + }); + + it("creates independent AbbyProvider and composables with selectedVariant & ACT reporting", () => { + const abby = createAbby(config); + expect(abby.AbbyProvider).toBeDefined(); + expect(typeof abby.useAbby).toBe("function"); + expect(typeof abby.useFeatureFlag).toBe("function"); + expect(typeof abby.useRemoteConfig).toBe("function"); + }); +}); diff --git a/tests/test_abby_vue_integration.py b/tests/test_abby_vue_integration.py new file mode 100644 index 00000000..f2440992 --- /dev/null +++ b/tests/test_abby_vue_integration.py @@ -0,0 +1,29 @@ +import os + + +def test_abby_vue_uses_real_core_api_and_per_factory_key(): + base = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + src = open(os.path.join(base, "packages", "vue", "src", "index.ts"), encoding="utf-8").read() + assert "new Abby<" in src + assert 'Symbol("AbbyInjectionKey")' in src + assert "selectedVariant" in src + assert "AbbyEventType.ACT" in src + assert "HttpService.sendData" in src + assert "LookupValue = unknown" in src + + +def test_abby_vue_type_tests_and_runtime_tests_exist(): + base = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + runtime_test = open(os.path.join(base, "packages", "vue", "tests", "useAbby.test.ts"), encoding="utf-8").read() + type_test = open(os.path.join(base, "packages", "vue", "tests", "types.test.ts"), encoding="utf-8").read() + assert "createAbby" in runtime_test + assert "expectTypeOf" in type_test + assert "getABTestValue" in type_test + + +def test_abby_vue_readme_documents_installation_and_provider(): + base = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + readme = open(os.path.join(base, "packages", "vue", "README.md"), encoding="utf-8").read() + assert "pnpm add @tryabby/vue @tryabby/core" in readme + assert "AbbyProvider" in readme + assert "initial-data" in readme