From b0517fee4b3b80b3dbe970daf759b0be2fcf2c32 Mon Sep 17 00:00:00 2001 From: Bogdan Militaru Date: Sat, 26 Sep 2026 16:57:43 +0300 Subject: [PATCH 1/3] feat(vue): add @tryabby/vue integration - Add @tryabby/vue package with Vue 3 composables for A/B tests, feature flags, and remote config - Provide useAbby, useFeatureFlag, useRemoteConfig composables backed by @tryabby/core - Support both component and Vue plugin installation - Include runtime tests and compile-time type tests - TypeScript support with full type inference Closes #68 --- packages/vue/.gitignore | 2 + packages/vue/README.md | 30 ++ packages/vue/package.json | 46 +++ packages/vue/src/StorageService.ts | 74 +++++ packages/vue/src/createAbby.ts | 322 ++++++++++++++++++++ packages/vue/src/index.ts | 2 + packages/vue/tests/mocks/handlers.ts | 42 +++ packages/vue/tests/mocks/server.ts | 4 + packages/vue/tests/setup.ts | 15 + packages/vue/tests/types.test.ts | 211 +++++++++++++ packages/vue/tests/useAbby.test.ts | 434 +++++++++++++++++++++++++++ packages/vue/tsconfig.json | 17 ++ packages/vue/tsup.config.ts | 10 + packages/vue/vite.config.ts | 25 ++ 14 files changed, 1234 insertions(+) create mode 100644 packages/vue/.gitignore create mode 100644 packages/vue/README.md create mode 100644 packages/vue/package.json create mode 100644 packages/vue/src/StorageService.ts create mode 100644 packages/vue/src/createAbby.ts create mode 100644 packages/vue/src/index.ts create mode 100644 packages/vue/tests/mocks/handlers.ts create mode 100644 packages/vue/tests/mocks/server.ts create mode 100644 packages/vue/tests/setup.ts create mode 100644 packages/vue/tests/types.test.ts create mode 100644 packages/vue/tests/useAbby.test.ts create mode 100644 packages/vue/tsconfig.json create mode 100644 packages/vue/tsup.config.ts create mode 100644 packages/vue/vite.config.ts diff --git a/packages/vue/.gitignore b/packages/vue/.gitignore new file mode 100644 index 00000000..de4d1f00 --- /dev/null +++ b/packages/vue/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/packages/vue/README.md b/packages/vue/README.md new file mode 100644 index 00000000..3b503f4c --- /dev/null +++ b/packages/vue/README.md @@ -0,0 +1,30 @@ +# @tryabby/vue + +Vue 3 SDK for Abby. Consumes `@tryabby/core`. + +## Acceptance (GitHub issue #68) + +- Package lives in `packages/vue` +- Package name is `@tryabby/vue` +- Consumes `@tryabby/core` +- TypeScript, same type quality as the React SDK +- `useAbby` → current variant + `onAct` +- `useFeatureFlag` → flag value +- `useRemoteConfig` → remote config value +- Tests for types and code + +## Usage + +```ts +import { createAbby } from "@tryabby/vue"; + +export const { useAbby, useFeatureFlag, useRemoteConfig, AbbyProvider, install } = + createAbby({ + projectId: "…", + environments: ["production"], + currentEnvironment: "production", + tests: { homepage: { variants: ["A", "B"] } }, + flags: ["newCheckout"], + remoteConfig: { heroCopy: "String" }, + }); +``` diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 00000000..2f5a6cf0 --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,46 @@ +{ + "name": "@tryabby/vue", + "version": "1.0.0", + "description": "Vue 3 SDK for Abby feature flags, remote config and A/B tests", + "main": "dist/index.js", + "files": ["dist"], + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup src/index.ts", + "dev": "pnpm run build --watch", + "test": "vitest" + }, + "homepage": "https://docs.tryabby.com", + "keywords": ["vue", "feature-flags", "ab-testing", "abby"], + "author": "", + "license": "ISC", + "peerDependencies": { + "vue": "^3.3.0" + }, + "dependencies": { + "@tryabby/core": "workspace:*", + "js-cookie": "^3.0.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "^5.16.5", + "@types/js-cookie": "^3.0.3", + "@vue/test-utils": "^2.4.6", + "jsdom": "^20.0.3", + "msw": "^0.49.1", + "node-fetch": "^3.3.0", + "tsconfig": "workspace:*", + "tsup": "^6.5.0", + "typescript": "5.5.4", + "vite": "5.4.0", + "vitest": "2.0.5", + "vue": "^3.4.38" + } +} diff --git a/packages/vue/src/StorageService.ts b/packages/vue/src/StorageService.ts new file mode 100644 index 00000000..39cc16cd --- /dev/null +++ b/packages/vue/src/StorageService.ts @@ -0,0 +1,74 @@ +import { + type IStorageService, + type StorageServiceOptions, + getABStorageKey, + getFFStorageKey, + getRCStorageKey, +} from "@tryabby/core"; +import Cookie from "js-cookie"; + +const DEFAULT_COOKIE_AGE = 365; + +class ABStorageService implements IStorageService { + get(projectId: string, testName: string): string | null { + const retrievedValue = Cookie.get(getABStorageKey(projectId, testName)); + if (!retrievedValue) return null; + return retrievedValue; + } + + set( + projectId: string, + testName: string, + value: string, + options?: StorageServiceOptions + ): void { + Cookie.set(getABStorageKey(projectId, testName), value, { + expires: options?.expiresInDays + ? options.expiresInDays + : DEFAULT_COOKIE_AGE, + }); + } + + remove(projectId: string, testName: string): void { + Cookie.remove(getABStorageKey(projectId, testName)); + } +} + +class FFStorageService implements IStorageService { + get(projectId: string, flagName: string): string | null { + const retrievedValue = Cookie.get(getFFStorageKey(projectId, flagName)); + if (!retrievedValue) return null; + return retrievedValue; + } + + set(projectId: string, flagName: string, value: string): void { + Cookie.set(getFFStorageKey(projectId, flagName), value, { + expires: DEFAULT_COOKIE_AGE, + }); + } + + remove(projectId: string, flagName: string): void { + Cookie.remove(getFFStorageKey(projectId, flagName)); + } +} + +class RCStorageService implements IStorageService { + get(projectId: string, key: string): string | null { + const retrievedValue = Cookie.get(getRCStorageKey(projectId, key)); + return retrievedValue ?? null; + } + + set(projectId: string, key: string, value: string): void { + Cookie.set(getRCStorageKey(projectId, key), value, { + expires: DEFAULT_COOKIE_AGE, + }); + } + + remove(projectId: string, key: string): void { + Cookie.remove(getRCStorageKey(projectId, key)); + } +} + +export const TestStorageService = new ABStorageService(); +export const FlagStorageService = new FFStorageService(); +export const RemoteConfigStorageService = new RCStorageService(); diff --git a/packages/vue/src/createAbby.ts b/packages/vue/src/createAbby.ts new file mode 100644 index 00000000..1a2f0312 --- /dev/null +++ b/packages/vue/src/createAbby.ts @@ -0,0 +1,322 @@ +import { + type ABConfig, + Abby, + type AbbyConfig, + type AbbyDataResponse, + AbbyEventType, + HttpService, + type RemoteConfigValueString, + type RemoteConfigValueStringToType, + type ValidatorType, +} from "@tryabby/core"; +import type { Infer } from "@tryabby/core/validation"; +import { + type InjectionKey, + computed, + inject, + onMounted, + onUnmounted, + provide, + ref, + type App, + type ComputedRef, + type Ref, +} from "vue"; +import { + FlagStorageService, + RemoteConfigStorageService, + TestStorageService, +} from "./StorageService"; + +export type ABTestReturnValue = + Lookup extends undefined + ? TestVariant + : Lookup[TestVariant & keyof Lookup]; + +type AbbyProjectData = ReturnType["getProjectData"]>; + +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 abby = new Abby< + FlagName, + TestName, + Tests, + RemoteConfig, + RemoteConfigName, + string[], + User + >( + abbyConfig, + { + get: (key: string) => { + if (typeof window === "undefined") return null; + return TestStorageService.get(abbyConfig.projectId, key); + }, + set: (key: string, value: any) => { + if (typeof window === "undefined" || config.cookies?.disableByDefault) + return; + TestStorageService.set(abbyConfig.projectId, key, value); + }, + }, + { + get: (key: string) => { + if (typeof window === "undefined") return null; + return FlagStorageService.get(abbyConfig.projectId, key); + }, + set: (key: string, value: any) => { + if (typeof window === "undefined") return; + FlagStorageService.set(abbyConfig.projectId, key, value); + }, + }, + { + get: (key: string) => { + if (typeof window === "undefined") return null; + return RemoteConfigStorageService.get(abbyConfig.projectId, key); + }, + set: (key: string, value: any) => { + if (typeof window === "undefined") return; + RemoteConfigStorageService.set(abbyConfig.projectId, key, value); + }, + } + ); + + const config = abbyConfig; + const AbbyDataKey: InjectionKey> = + Symbol("abby-data"); + + const useAbbyData = () => { + const data = inject(AbbyDataKey); + if (!data) { + throw new Error( + "useAbbyData must be used within an AbbyProvider. Wrap a parent component in to fix this error." + ); + } + return data; + }; + + function useAbby(name: K): { + variant: ComputedRef; + onAct: () => void; + }; + function useAbby< + K extends keyof Tests, + const Lookup extends Record, + >( + name: K, + lookupObject: Lookup + ): { + variant: ComputedRef; + onAct: () => void; + }; + function useAbby( + name: K, + lookupObject?: Record + ) { + const data = useAbbyData(); + type TestVariant = Tests[K]["variants"][number]; + const selectedVariant = ref(""); + + const syncVariant = () => { + const fromData = + data.value?.tests[name as unknown as TestName]?.selectedVariant; + const fromAbby = + abby.getProjectData().tests[name as unknown as TestName] + ?.selectedVariant; + const next = fromData ?? fromAbby ?? ""; + selectedVariant.value = next; + }; + + onMounted(() => { + syncVariant(); + if (!name || !selectedVariant.value) return; + HttpService.sendData({ + url: config.apiUrl, + type: AbbyEventType.PING, + data: { + projectId: config.projectId, + selectedVariant: selectedVariant.value, + testName: name as string, + }, + }); + }); + + const onAct = () => { + if (!selectedVariant.value) return; + HttpService.sendData({ + url: config.apiUrl, + type: AbbyEventType.ACT, + data: { + projectId: config.projectId, + selectedVariant: selectedVariant.value, + testName: name as string, + }, + }); + }; + + const variant = computed(() => { + syncVariant(); + const current = selectedVariant.value; + if (lookupObject) { + return lookupObject[current as TestVariant]; + } + return current as TestVariant; + }); + + return { variant, onAct }; + } + + const useFeatureFlag = (name: FlagName): ComputedRef => { + const data = useAbbyData(); + return computed(() => { + const fromData = data.value?.flags[name]?.value; + if (typeof fromData === "boolean") return fromData; + return Boolean(abby.getFeatureFlag(name)); + }); + }; + + const useRemoteConfig = < + T extends RemoteConfigName, + Config extends RemoteConfig[T], + >( + remoteConfigName: T + ): ComputedRef> => { + const data = useAbbyData(); + return computed(() => { + const fromData = data.value?.remoteConfig[remoteConfigName]?.value; + if (fromData !== undefined) { + return fromData as RemoteConfigValueStringToType; + } + return abby.getRemoteConfig(remoteConfigName); + }); + }; + + const AbbyProvider = { + name: "AbbyProvider", + props: { + initialData: { + type: Object as () => AbbyDataResponse | undefined, + required: false, + }, + }, + setup( + props: { initialData?: AbbyDataResponse }, + { slots }: { slots: { default?: () => unknown } } + ) { + const data = ref( + props.initialData + ? abby.init(props.initialData) + : abby.getProjectData() + ); + provide(AbbyDataKey, data); + + onMounted(() => { + if (props.initialData) return; + abby.loadProjectData().then((loaded) => { + if (!loaded) return; + data.value = loaded; + }); + }); + + const unsubscribe = abby.subscribe((next) => { + data.value = next; + }); + onUnmounted(unsubscribe); + + return () => slots.default?.(); + }, + }; + + const install = (app: App, options?: { initialData?: AbbyDataResponse }) => { + const data = ref( + options?.initialData + ? abby.init(options.initialData) + : abby.getProjectData() + ); + app.provide(AbbyDataKey, data); + if (!options?.initialData && typeof window !== "undefined") { + abby.loadProjectData().then((loaded) => { + if (!loaded) return; + data.value = loaded; + }); + } + abby.subscribe((next) => { + data.value = next; + }); + }; + + const getFeatureFlagValue = (name: FlagName) => { + return abby.getFeatureFlag(name); + }; + + const getRemoteConfig = < + T extends RemoteConfigName, + Config extends RemoteConfig[T], + >( + remoteConfigName: T + ): RemoteConfigValueStringToType => { + return abby.getRemoteConfig(remoteConfigName); + }; + + const getABTestValue = < + TestName extends keyof Tests, + const Lookup extends Record | undefined = undefined, + >( + testName: TestName, + lookupObject?: Lookup + ): ABTestReturnValue => { + type TestVariant = Tests[TestName]["variants"][number]; + const variant = abby.getTestVariant(testName); + if (lookupObject === undefined) { + return variant as any; + } + return lookupObject[variant as TestVariant] as any; + }; + + const getABResetFunction = (name: T) => { + return () => { + TestStorageService.remove(config.projectId, name as string); + }; + }; + + const getVariants = (name: T) => { + return abby.getVariants(name); + }; + + const updateUserProperties = ( + user: Partial<{ + -readonly [K in keyof User]: Infer; + }> + ) => { + abby.updateUserProperties(user); + }; + + return { + useAbby, + useFeatureFlag, + useRemoteConfig, + AbbyProvider, + install, + getFeatureFlagValue, + getRemoteConfig, + getABTestValue, + __abby__: abby, + getABResetFunction, + getVariants, + updateUserProperties, + }; +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 00000000..c0d28d60 --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,2 @@ +export { createAbby, type ABTestReturnValue } from "./createAbby"; +export { type ABConfig, type AbbyConfig, defineConfig } from "@tryabby/core"; diff --git a/packages/vue/tests/mocks/handlers.ts b/packages/vue/tests/mocks/handlers.ts new file mode 100644 index 00000000..f2028cb7 --- /dev/null +++ b/packages/vue/tests/mocks/handlers.ts @@ -0,0 +1,42 @@ +import { ABBY_BASE_URL, type AbbyDataResponse } from "@tryabby/core"; +import { rest } from "msw"; + +const returnData: AbbyDataResponse = { + tests: [ + { + name: "test", + weights: [1, 1, 1, 1], + }, + { + name: "test2", + weights: [1, 0], + }, + ], + flags: [ + { + name: "flag1", + value: true, + }, + { + name: "flag2", + value: false, + }, + ], + remoteConfig: [ + { + name: "remoteConfig1", + value: "FooBar", + }, + ], +}; +export const handlers = [ + rest.get( + `${ABBY_BASE_URL}api/dashboard/:projectId/data`, + (_req, res, ctx) => { + return res(ctx.json(returnData)); + } + ), + rest.get(`${ABBY_BASE_URL}api/v2/data/:projectId`, (_req, res, ctx) => { + return res(ctx.json(returnData)); + }), +]; diff --git a/packages/vue/tests/mocks/server.ts b/packages/vue/tests/mocks/server.ts new file mode 100644 index 00000000..7b37f2ac --- /dev/null +++ b/packages/vue/tests/mocks/server.ts @@ -0,0 +1,4 @@ +import { setupServer } from "msw/node"; +import { handlers } from "./handlers"; + +export const server = setupServer(...handlers); diff --git a/packages/vue/tests/setup.ts b/packages/vue/tests/setup.ts new file mode 100644 index 00000000..5a691963 --- /dev/null +++ b/packages/vue/tests/setup.ts @@ -0,0 +1,15 @@ +/// @ts-ignore it doesn't have types +import matchers from "@testing-library/jest-dom/matchers"; +import fetch from "node-fetch"; +import { afterEach, expect } from "vitest"; +import { server } from "./mocks/server"; + +/// @ts-ignore +global.fetch = fetch; + +beforeAll(() => server.listen()); +afterAll(() => server.close()); +expect.extend(matchers); +afterEach(() => { + server.resetHandlers(); +}); diff --git a/packages/vue/tests/types.test.ts b/packages/vue/tests/types.test.ts new file mode 100644 index 00000000..8b55301e --- /dev/null +++ b/packages/vue/tests/types.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { defineComponent, h } from "vue"; +import { mount, flushPromises } from "@vue/test-utils"; +import { createAbby } from "../src"; + +describe("useAbby types", () => { + it("returns the correct types", async () => { + const test2Variants = [ + "SimonsText", + "MatthiasText", + "TomsText", + "TimsText", + ] as const; + + const { AbbyProvider, useAbby } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + tests: { + test: { variants: ["ONLY_ONE_VARIANT"] }, + test2: { + variants: test2Variants, + }, + }, + }); + + const TestComponent = defineComponent({ + setup() { + const { variant: test1Variant } = useAbby("test"); + const { variant: test2Variant } = useAbby("test2"); + + expectTypeOf(test1Variant.value).toEqualTypeOf<"ONLY_ONE_VARIANT">(); + expectTypeOf(test2Variant.value).toEqualTypeOf< + "SimonsText" | "MatthiasText" | "TomsText" | "TimsText" + >(); + + return { test1Variant, test2Variant }; + }, + render() { + return h("div", `${this.test1Variant} ${this.test2Variant}`); + }, + }); + + mount( + defineComponent({ + components: { AbbyProvider, TestComponent }, + template: ``, + }) + ); + + await flushPromises(); + }); + + it("has the correct types with lookup object", async () => { + const { AbbyProvider, useAbby } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + tests: { + test: { + variants: ["A", "B", "C"], + }, + }, + }); + + const TestComponent = defineComponent({ + setup() { + const { variant } = useAbby("test", { + A: "Hello", + B: "Bonjour", + C: "Hola", + }); + + expectTypeOf(variant.value).toEqualTypeOf< + "Hello" | "Bonjour" | "Hola" + >(); + + return { variant }; + }, + render() { + return h("div", String(this.variant)); + }, + }); + + mount( + defineComponent({ + components: { AbbyProvider, TestComponent }, + template: ``, + }) + ); + + await flushPromises(); + }); +}); + +describe("useFeatureFlag types", () => { + it("returns the correct types", async () => { + const { AbbyProvider, useFeatureFlag } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + flags: ["test"], + }); + + const TestComponent = defineComponent({ + setup() { + const testFlag = useFeatureFlag("test"); + expectTypeOf(testFlag.value).toEqualTypeOf(); + return { testFlag }; + }, + render() { + return h("div", String(this.testFlag)); + }, + }); + + mount( + defineComponent({ + components: { AbbyProvider, TestComponent }, + template: ``, + }) + ); + + await flushPromises(); + }); +}); + +describe("useRemoteConfig types", () => { + it("uses correct typings", async () => { + const { AbbyProvider, useRemoteConfig } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + remoteConfig: { + stringRc: "String", + numberRc: "Number", + jsonRc: "JSON", + }, + }); + + // Type tests only - no render needed for type checking + const TestComponent = defineComponent({ + setup() { + const stringRc = useRemoteConfig("stringRc"); + const numberRc = useRemoteConfig("numberRc"); + const jsonRc = useRemoteConfig("jsonRc"); + + expectTypeOf(stringRc.value).toEqualTypeOf(); + expectTypeOf(numberRc.value).toEqualTypeOf(); + expectTypeOf(jsonRc.value).toEqualTypeOf>(); + + return {}; + }, + render() { + return h("div", "type test"); + }, + }); + + mount( + defineComponent({ + components: { AbbyProvider, TestComponent }, + template: ``, + }) + ); + + // This test validates types at compile time, not runtime values + await flushPromises(); + }); +}); + +describe("getVariants types", () => { + it("has the correct types", () => { + const { getVariants } = createAbby({ + environments: [], + projectId: "123", + currentEnvironment: "test", + tests: { + test: { + variants: ["ONLY_ONE_VARIANT"], + }, + }, + flags: ["test"], + }); + + expectTypeOf(getVariants("test")).toEqualTypeOf< + readonly ["ONLY_ONE_VARIANT"] + >(); + }); +}); + +describe("getABTestValue types", () => { + it("produces proper types with a lookup object", () => { + const { getABTestValue } = createAbby({ + environments: [], + projectId: "123", + currentEnvironment: "a", + tests: { + test: { + variants: ["A", "B", "C"], + }, + }, + }); + + const activeVariant = getABTestValue("test", { + A: "Hello", + B: "Bonjour", + C: "Hola", + }); + + expectTypeOf(activeVariant).toEqualTypeOf<"Hello" | "Bonjour" | "Hola">(); + }); +}); diff --git a/packages/vue/tests/useAbby.test.ts b/packages/vue/tests/useAbby.test.ts new file mode 100644 index 00000000..824d1954 --- /dev/null +++ b/packages/vue/tests/useAbby.test.ts @@ -0,0 +1,434 @@ +import { HttpService, AbbyEventType } from "@tryabby/core"; +import { describe, it, expect, vi, beforeEach, afterAll } from "vitest"; +import { ref, nextTick, defineComponent, h } from "vue"; +import { mount, flushPromises } from "@vue/test-utils"; +import { createAbby } from "../src"; +import { TestStorageService } from "../src/StorageService"; + +const OLD_ENV = process.env; + +beforeEach(() => { + document.cookie = ""; + vi.resetModules(); + process.env = { ...OLD_ENV }; +}); + +afterAll(() => { + process.env = OLD_ENV; +}); + +describe("useAbby", () => { + it("returns the correct variant", async () => { + const spy = vi.spyOn(TestStorageService, "set"); + + const { AbbyProvider, useAbby } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + tests: { + test: { variants: ["OldFooter", "NewFooter"] }, + test2: { + variants: ["SimonsText", "MatthiasText", "TomsText", "TimsText"], + }, + }, + }); + + const TestComponent = defineComponent({ + setup() { + const { variant } = useAbby("test"); + return { variant }; + }, + render() { + return h("div", this.variant); + }, + }); + + const wrapper = mount( + defineComponent({ + components: { AbbyProvider, TestComponent }, + template: ``, + }) + ); + + await flushPromises(); + await nextTick(); + + expect(spy).toHaveBeenCalled(); + expect(wrapper.text()).toBeDefined(); + }); + + it("should use the persistedValue", async () => { + const persistedValue = "SimonsText"; + const variants = ["SimonsText", "MatthiasText", "TomsText", "TimsText"]; + + const getSpy = vi.spyOn(TestStorageService, "get"); + + getSpy.mockReturnValue(persistedValue); + + const { AbbyProvider, useAbby } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + tests: { + test: { variants }, + }, + }); + + const capturedVariant = ref(null); + + const TestComponent = defineComponent({ + setup() { + const { variant } = useAbby("test"); + capturedVariant.value = variant.value; + return { variant }; + }, + render() { + return h("div", this.variant); + }, + }); + + mount( + defineComponent({ + components: { AbbyProvider, TestComponent }, + template: ``, + }) + ); + + await flushPromises(); + await nextTick(); + + // value set in storage should be used + expect(capturedVariant.value).toEqual(persistedValue); + }); + + it("looks up the selected variant in the lookup object", async () => { + const persistedValue = "SimonsText"; + const variants = [ + "SimonsText", + "MatthiasText", + "TomsText", + "TimsText", + ] as const; + + const getSpy = vi.spyOn(TestStorageService, "get"); + getSpy.mockReturnValue(persistedValue); + + const { AbbyProvider, useAbby } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + tests: { + test: { variants }, + }, + }); + + const capturedVariant = ref(null); + + const TestComponent = defineComponent({ + setup() { + const { variant } = useAbby("test", { + SimonsText: "a", + MatthiasText: "b", + TomsText: "c", + TimsText: "d", + }); + capturedVariant.value = variant.value; + return { variant }; + }, + render() { + return h("div", String(this.variant)); + }, + }); + + mount( + defineComponent({ + components: { AbbyProvider, TestComponent }, + template: ``, + }) + ); + + await flushPromises(); + await nextTick(); + + expect(capturedVariant.value).toEqual("a"); + }); + + it("should ping the current info on mount", async () => { + const spy = vi.spyOn(HttpService, "sendData"); + const { AbbyProvider, useAbby } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + tests: { + test: { variants: ["A", "B", "C"] }, + }, + }); + + const TestComponent = defineComponent({ + setup() { + const { variant, onAct } = useAbby("test"); + return { variant, onAct }; + }, + render() { + return h("div", this.variant); + }, + }); + + mount( + defineComponent({ + components: { AbbyProvider, TestComponent }, + template: ``, + }) + ); + + await flushPromises(); + await nextTick(); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ type: AbbyEventType.PING }) + ); + }); + + it("should notify the server with onAct", async () => { + const spy = vi.spyOn(HttpService, "sendData"); + const { AbbyProvider, useAbby } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + tests: { + test: { variants: ["A", "B", "C"] }, + }, + }); + + let onActFn: (() => void) | null = null; + + const TestComponent = defineComponent({ + setup() { + const { variant, onAct } = useAbby("test"); + onActFn = onAct; + return { variant }; + }, + render() { + return h("div", this.variant); + }, + }); + + mount( + defineComponent({ + components: { AbbyProvider, TestComponent }, + template: ``, + }) + ); + + await flushPromises(); + await nextTick(); + + // Reset call count after mount PING + spy.mockClear(); + + onActFn!(); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ type: AbbyEventType.ACT }) + ); + }); +}); + +describe("useFeatureFlag", () => { + it("should return the correct feature flag value", async () => { + const { AbbyProvider, useFeatureFlag } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + flags: ["flag1", "flag2"], + }); + + const capturedFlag = ref(null); + + const TestComponent = defineComponent({ + setup() { + const flag1 = useFeatureFlag("flag1"); + capturedFlag.value = flag1.value; + return { flag1 }; + }, + render() { + return h("div", String(this.flag1)); + }, + }); + + mount( + defineComponent({ + components: { AbbyProvider, TestComponent }, + template: ``, + }) + ); + + await flushPromises(); + await nextTick(); + + // Initially false (default) until server returns + expect(typeof capturedFlag.value).toBe("boolean"); + }); + + it("should respect the default value for feature flags", async () => { + const { AbbyProvider, useFeatureFlag } = createAbby({ + environments: [], + projectId: "123", + flags: ["flag1", "flag2"], + currentEnvironment: "a", + }); + + const capturedFlag = ref(null); + + const TestComponent = defineComponent({ + setup() { + const flag1 = useFeatureFlag("flag1"); + capturedFlag.value = flag1.value; + return { flag1 }; + }, + render() { + return h("div", String(this.flag1)); + }, + }); + + mount( + defineComponent({ + components: { AbbyProvider, TestComponent }, + template: ``, + }) + ); + + await flushPromises(); + await nextTick(); + + expect(capturedFlag.value).toEqual(false); + }); +}); + +describe("useRemoteConfig", () => { + it("returns the correct remote config value", async () => { + const { AbbyProvider, useRemoteConfig } = createAbby({ + environments: [], + projectId: "123", + remoteConfig: { + remoteConfig1: "String", + }, + currentEnvironment: "a", + }); + + const capturedValue = ref(null); + + const TestComponent = defineComponent({ + setup() { + const rc = useRemoteConfig("remoteConfig1"); + capturedValue.value = rc.value; + return { rc }; + }, + render() { + return h("div", String(this.rc)); + }, + }); + + mount( + defineComponent({ + components: { AbbyProvider, TestComponent }, + template: ``, + }) + ); + + await flushPromises(); + await nextTick(); + + // Initially empty string default, or server value + expect(typeof capturedValue.value).toBe("string"); + }); +}); + +describe("getFeatureFlagValue", () => { + it("gets the stored feature flag value using a function properly", async () => { + const { getFeatureFlagValue } = createAbby({ + environments: [], + projectId: "123", + flags: ["flag1", "flag2"], + currentEnvironment: "a", + }); + + // Default is false before server fetch + expect(typeof getFeatureFlagValue("flag1")).toBe("boolean"); + expect(typeof getFeatureFlagValue("flag2")).toBe("boolean"); + }); +}); + +describe("getVariants", () => { + it("returns the correct possible variant values", () => { + const { getVariants } = createAbby({ + environments: [], + projectId: "123", + currentEnvironment: "a", + tests: { + test: { + variants: ["A", "B", "C"], + }, + }, + }); + expect(getVariants("test")).toEqual(["A", "B", "C"]); + }); +}); + +describe("getABTestValue", () => { + it("uses the lookup object when retrieving a variant", () => { + const { getABTestValue } = createAbby({ + environments: [], + projectId: "123", + currentEnvironment: "a", + tests: { + test: { + variants: ["A", "B", "C"], + }, + }, + }); + + const activeVariant = getABTestValue("test"); + const lookupObject = { + A: 1, + B: 2, + C: 3, + }; + + expect(getABTestValue("test", lookupObject)).toEqual( + lookupObject[activeVariant as keyof typeof lookupObject] + ); + }); +}); + +describe("Vue plugin install", () => { + it("works as a Vue plugin", async () => { + const { install, useFeatureFlag } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + flags: ["flag1"], + }); + + const TestComponent = defineComponent({ + setup() { + const flag1 = useFeatureFlag("flag1"); + return { flag1 }; + }, + render() { + return h("div", String(this.flag1)); + }, + }); + + const wrapper = mount(TestComponent, { + global: { + plugins: [[install]], + }, + }); + + await flushPromises(); + await nextTick(); + + expect(wrapper.text()).toBeDefined(); + }); +}); diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json new file mode 100644 index 00000000..a2814094 --- /dev/null +++ b/packages/vue/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "tsconfig/base.json", + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "types": ["vitest/globals"], + "jsx": "preserve", + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ES2018", + "baseUrl": ".", + "paths": { + "@tryabby/core": ["../core/src/index.ts"], + "@tryabby/core/validation": ["../core/src/validation/index.ts"] + } + }, + "include": ["src", "tests"] +} diff --git a/packages/vue/tsup.config.ts b/packages/vue/tsup.config.ts new file mode 100644 index 00000000..685d4095 --- /dev/null +++ b/packages/vue/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + dts: true, + clean: true, + format: ["cjs", "esm"], + sourcemap: true, + treeshake: true, + external: ["vue"], +}); diff --git a/packages/vue/vite.config.ts b/packages/vue/vite.config.ts new file mode 100644 index 00000000..21059690 --- /dev/null +++ b/packages/vue/vite.config.ts @@ -0,0 +1,25 @@ +/// + +import { resolve } from "node:path"; +import { defineConfig } from "vite"; + +export default defineConfig({ + resolve: { + alias: { + "@tryabby/core/validation": resolve( + __dirname, + "../core/src/validation/index.ts" + ), + "@tryabby/core": resolve(__dirname, "../core/src/index.ts"), + }, + }, + test: { + globals: true, + environment: "jsdom", + setupFiles: "./tests/setup.ts", + typecheck: { + enabled: true, + include: ["tests/types.test.ts"], + }, + }, +}); From bc002d8ce1ae7f635e9f9a48b9bad062aaf441c2 Mon Sep 17 00:00:00 2001 From: Bogdan Militaru Date: Sat, 26 Sep 2026 17:07:18 +0300 Subject: [PATCH 2/3] test(vue): improve PING test assertion per CodeRabbit review - Add stricter assertion to verify selectedVariant is one of configured variants - Use unique projectId/testName to avoid mock pollution from earlier tests - Clear cookies before test to ensure clean state --- packages/vue/tests/useAbby.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/vue/tests/useAbby.test.ts b/packages/vue/tests/useAbby.test.ts index 824d1954..37d54623 100644 --- a/packages/vue/tests/useAbby.test.ts +++ b/packages/vue/tests/useAbby.test.ts @@ -154,19 +154,21 @@ describe("useAbby", () => { }); it("should ping the current info on mount", async () => { + vi.restoreAllMocks(); + document.cookie = ""; const spy = vi.spyOn(HttpService, "sendData"); const { AbbyProvider, useAbby } = createAbby({ environments: [""], currentEnvironment: "", - projectId: "123", + projectId: "ping-test-123", tests: { - test: { variants: ["A", "B", "C"] }, + pingTest: { variants: ["A", "B", "C"] }, }, }); const TestComponent = defineComponent({ setup() { - const { variant, onAct } = useAbby("test"); + const { variant, onAct } = useAbby("pingTest"); return { variant, onAct }; }, render() { @@ -185,7 +187,12 @@ describe("useAbby", () => { await nextTick(); expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ type: AbbyEventType.PING }) + expect.objectContaining({ + type: AbbyEventType.PING, + data: expect.objectContaining({ + selectedVariant: expect.stringMatching(/^(A|B|C)$/), + }), + }) ); }); From 213e811e2c05d636aaac1a48dec920b95863ccb1 Mon Sep 17 00:00:00 2001 From: Bogdan Militaru Date: Sat, 26 Sep 2026 17:09:07 +0300 Subject: [PATCH 3/3] refactor(vue): keep variant computed pure per CodeRabbit review - Add immediate watcher on data to sync selectedVariant - Derive variant value directly from data/abby without side effects - Prevents recursive dirty marking in Vue computed evaluation --- packages/vue/src/createAbby.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/vue/src/createAbby.ts b/packages/vue/src/createAbby.ts index 1a2f0312..a0ba9c34 100644 --- a/packages/vue/src/createAbby.ts +++ b/packages/vue/src/createAbby.ts @@ -18,6 +18,7 @@ import { onUnmounted, provide, ref, + watch, type App, type ComputedRef, type Ref, @@ -142,7 +143,6 @@ export function createAbby< }; onMounted(() => { - syncVariant(); if (!name || !selectedVariant.value) return; HttpService.sendData({ url: config.apiUrl, @@ -155,6 +155,8 @@ export function createAbby< }); }); + watch(data, syncVariant, { immediate: true }); + const onAct = () => { if (!selectedVariant.value) return; HttpService.sendData({ @@ -169,8 +171,11 @@ export function createAbby< }; const variant = computed(() => { - syncVariant(); - const current = selectedVariant.value; + const current = + data.value?.tests[name as unknown as TestName]?.selectedVariant ?? + abby.getProjectData().tests[name as unknown as TestName] + ?.selectedVariant ?? + ""; if (lookupObject) { return lookupObject[current as TestVariant]; }