Repository navigation
feat(vue): add Vue 3 integration package (@tryabby/vue) (#68) #226
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| dist | ||
| node_modules |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| { | ||
| "name": "@tryabby/vue", | ||
| "version": "7.0.1", | ||
| "description": "Official 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", | ||
| "files": [ | ||
| "dist" | ||
| ], | ||
| "exports": { | ||
| ".": { | ||
| "types": "./dist/index.d.ts", | ||
| "import": "./dist/index.mjs", | ||
| "require": "./dist/index.js" | ||
| } | ||
| }, | ||
| "scripts": { | ||
| "build": "tsup src/index.ts --dts --format cjs,esm", | ||
| "dev": "pnpm run build --watch", | ||
| "test": "vitest run" | ||
| }, | ||
| "homepage": "https://docs.tryabby.com", | ||
| "keywords": [ | ||
| "abby", | ||
| "vue", | ||
| "vue3", | ||
| "ab-testing", | ||
| "feature-flags", | ||
| "remote-config" | ||
| ], | ||
| "author": "", | ||
| "license": "ISC", | ||
| "peerDependencies": { | ||
| "vue": "^3.0.0" | ||
| }, | ||
| "dependencies": { | ||
| "@tryabby/core": "workspace:*", | ||
| "js-cookie": "^3.0.1" | ||
| }, | ||
| "devDependencies": { | ||
| "@tryabby/devtools": "workspace:*", | ||
| "@types/js-cookie": "^3.0.3", | ||
| "tsconfig": "workspace:*", | ||
| "tsup": "^6.5.0", | ||
| "typescript": "5.5.4", | ||
| "vite": "5.4.0", | ||
| "vitest": "2.0.5", | ||
| "vue": "^3.4.0" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| 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)); | ||
| return retrievedValue || null; | ||
| } | ||
|
|
||
| set( | ||
| projectId: string, | ||
| testName: string, | ||
| value: string, | ||
| options?: StorageServiceOptions | ||
| ): void { | ||
| Cookie.set(getABStorageKey(projectId, testName), value, { | ||
| expires: options?.expiresInDays | ||
| ? options.expiresInDays | ||
| : DEFAULT_COOKIE_AGE, | ||
|
Comment on lines
+25
to
+27
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. Forward the configured test-cookie expiration. When
|
||
| }); | ||
| } | ||
|
|
||
| remove(projectId: string, testName: string): void { | ||
| Cookie.remove(getABStorageKey(projectId, testName)); | ||
| } | ||
| } | ||
|
|
||
| class FFStorageService implements IStorageService { | ||
| get(projectId: string, flagName: string): string | null { | ||
| return Cookie.get(getFFStorageKey(projectId, flagName)) || null; | ||
| } | ||
|
|
||
| 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, configName: string): string | null { | ||
| return Cookie.get(getRCStorageKey(projectId, configName)) || null; | ||
| } | ||
|
|
||
| set(projectId: string, configName: string, value: string): void { | ||
| Cookie.set(getRCStorageKey(projectId, configName), value, { | ||
| expires: DEFAULT_COOKIE_AGE, | ||
| }); | ||
| } | ||
|
|
||
| remove(projectId: string, configName: string): void { | ||
| Cookie.remove(getRCStorageKey(projectId, configName)); | ||
| } | ||
| } | ||
|
|
||
| export const TestStorageService = new ABStorageService(); | ||
| export const FlagStorageService = new FFStorageService(); | ||
| export const RemoteConfigStorageService = new RCStorageService(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { createApp } from "vue"; | ||
| import { | ||
| createAbby, | ||
| useAbby, | ||
| useFeatureFlag, | ||
| useRemoteConfig, | ||
| ABBY_INJECTION_KEY, | ||
| } from "../index"; | ||
| import { HttpService, AbbyEventType } from "@tryabby/core"; | ||
|
|
||
| describe("@tryabby/vue integration", () => { | ||
| let sendDataSpy: any; | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| sendDataSpy = vi.spyOn(HttpService, "sendData").mockResolvedValue({} as any); | ||
| }); | ||
|
|
||
| const sampleConfig = { | ||
| projectId: "proj_test_123", | ||
| apiUrl: "https://api.tryabby.com", | ||
| flags: ["dark_mode", "beta_features"], | ||
| tests: { | ||
| button_color: { | ||
| variants: ["blue", "green"], | ||
| }, | ||
| hero_text: { | ||
| variants: ["original", "variant_b"], | ||
| }, | ||
| }, | ||
| remoteConfig: { | ||
| discount_percentage: "Number", | ||
| welcome_message: "String", | ||
| }, | ||
| } as const; | ||
|
|
||
| it("exports required functions and keys", () => { | ||
| expect(typeof createAbby).toBe("function"); | ||
| expect(typeof useAbby).toBe("function"); | ||
| expect(typeof useFeatureFlag).toBe("function"); | ||
| expect(typeof useRemoteConfig).toBe("function"); | ||
| expect(ABBY_INJECTION_KEY).toBeDefined(); | ||
| }); | ||
|
|
||
| it("initializes createAbby and provides scoped composables", () => { | ||
| const client = createAbby(sampleConfig); | ||
|
|
||
| expect(client).toBeDefined(); | ||
| expect(client.abby).toBeDefined(); | ||
| expect(typeof client.useAbby).toBe("function"); | ||
| expect(typeof client.useFeatureFlag).toBe("function"); | ||
| expect(typeof client.useRemoteConfig).toBe("function"); | ||
| expect(typeof client.getABTestValue).toBe("function"); | ||
| expect(typeof client.getFeatureFlagValue).toBe("function"); | ||
| expect(typeof client.getRemoteConfig).toBe("function"); | ||
| expect(typeof client.getVariants).toBe("function"); | ||
| expect(typeof client.plugin.install).toBe("function"); | ||
| }); | ||
|
|
||
| it("resolves test variant and sends ping on useAbby call", () => { | ||
| const client = createAbby(sampleConfig); | ||
| const { variant, onAct } = client.useAbby("button_color"); | ||
|
|
||
| expect(["blue", "green"]).toContain(variant.value); | ||
| expect(sendDataSpy).toHaveBeenCalledWith( | ||
| expect.objectContaining({ | ||
| type: AbbyEventType.PING, | ||
| data: expect.objectContaining({ | ||
| projectId: "proj_test_123", | ||
| testName: "button_color", | ||
| selectedVariant: variant.value, | ||
| }), | ||
| }) | ||
| ); | ||
|
|
||
| // Test onAct | ||
| onAct(); | ||
| expect(sendDataSpy).toHaveBeenCalledWith( | ||
| expect.objectContaining({ | ||
| type: AbbyEventType.ACT, | ||
| data: expect.objectContaining({ | ||
| projectId: "proj_test_123", | ||
| testName: "button_color", | ||
| selectedVariant: variant.value, | ||
| }), | ||
| }) | ||
| ); | ||
| }); | ||
|
|
||
| it("supports lookupObject mapping with useAbby", () => { | ||
| const client = createAbby(sampleConfig); | ||
| const { variant } = client.useAbby("button_color", { | ||
| blue: "#0000FF", | ||
| green: "#00FF00", | ||
| }); | ||
|
|
||
| expect(["#0000FF", "#00FF00"]).toContain(variant.value); | ||
| }); | ||
|
|
||
| it("returns feature flag value with useFeatureFlag and getFeatureFlagValue", () => { | ||
| const client = createAbby(sampleConfig); | ||
| const flag = client.useFeatureFlag("dark_mode"); | ||
|
|
||
| expect(typeof flag.value).toBe("boolean"); | ||
| expect(typeof client.getFeatureFlagValue("dark_mode")).toBe("boolean"); | ||
| }); | ||
|
|
||
| it("returns remote config with useRemoteConfig and getRemoteConfig", () => { | ||
| const client = createAbby(sampleConfig); | ||
| const msg = client.useRemoteConfig("welcome_message"); | ||
|
|
||
| // Initially undefined or string before remote payload | ||
| expect(msg.value === undefined || typeof msg.value === "string").toBe(true); | ||
| const direct = client.getRemoteConfig("welcome_message"); | ||
| expect(direct === undefined || typeof direct === "string").toBe(true); | ||
| }); | ||
|
|
||
| it("allows installing plugin into a Vue App and using top-level composables", () => { | ||
| const client = createAbby(sampleConfig); | ||
| const app = createApp({}); | ||
| app.use(client.plugin); | ||
|
|
||
| let resolvedVariant: any; | ||
| let resolvedFlag: any; | ||
|
|
||
| app.runWithContext(() => { | ||
| const { variant } = useAbby("hero_text"); | ||
| const flag = useFeatureFlag("beta_features"); | ||
| resolvedVariant = variant.value; | ||
| resolvedFlag = flag.value; | ||
| }); | ||
|
|
||
| expect(["original", "variant_b"]).toContain(resolvedVariant); | ||
| expect(typeof resolvedFlag).toBe("boolean"); | ||
| }); | ||
|
|
||
| it("returns available variants list with getVariants", () => { | ||
| const client = createAbby(sampleConfig); | ||
| const variants = client.getVariants("button_color"); | ||
| expect(variants).toEqual(["blue", "green"]); | ||
| }); | ||
| }); |
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.
Build
@tryabby/corebefore running the Vue tests.On a fresh checkout,
vitest runimports@tryabby/corethrough itsdistentrypoint, butdistis ignored and this test command does not build it. The Vue test command fails during module resolution. Add a build prerequisite for the workspace dependency so the package test command works without a separate manual build. (raw.githubusercontent.com)