Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions packages/vue/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"name": "@tryabby/vue",
"version": "1.0.0",
"description": "Vue 3 integration for Abby A/B testing and feature flags",
"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 --format cjs,esm --dts --sourcemap --clean",
"dev": "pnpm run build --watch",
"test": "vitest"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
"homepage": "https://docs.tryabby.com",
"keywords": [],
"author": "",
"license": "ISC",
"peerDependencies": {
"vue": "^3.0.0"
},
"devDependencies": {
"jsdom": "^20.0.3",
"tsup": "^6.5.0",
"typescript": "5.5.4",
"vite": "5.4.0",
"vitest": "2.0.5",
"vue": "^3.3.0"
},
"dependencies": {
"@tryabby/core": "workspace:*"
}
}
161 changes: 161 additions & 0 deletions packages/vue/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, it, expect, expectTypeOf, vi, beforeEach } from "vitest";
import { AbbyEventType, HttpService } from "@tryabby/core";
import { createAbby } from "../index";

vi.mock("@tryabby/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tryabby/core")>();
return {
...actual,
};
});

describe("createAbby", () => {
it("returns the expected API surface", () => {
const { AbbyPlugin, useAbby, useFeatureFlag, useRemoteConfig, __abby__ } =
createAbby({
projectId: "test-project",
environments: [],
currentEnvironment: "",
tests: {
buttonColor: { variants: ["blue", "red"] },
},
flags: ["darkMode"],
remoteConfig: {},
settings: {
flags: {
devOverrides: {},
},
},
});

expect(typeof AbbyPlugin).toBe("function");
expect(typeof useAbby).toBe("function");
expect(typeof useFeatureFlag).toBe("function");
expect(typeof useRemoteConfig).toBe("function");
expect(__abby__).toBeDefined();
});

describe("useFeatureFlag", () => {
it("returns a ref with the feature flag value", () => {
const { useFeatureFlag } = createAbby({
projectId: "test-project",
environments: [],
currentEnvironment: "",
tests: {},
flags: ["darkMode"],
remoteConfig: {},
settings: {
flags: {
defaultValue: false,
devOverrides: {},
},
},
});

const flag = useFeatureFlag("darkMode");
expect(flag).toHaveProperty("value");
expect(typeof flag.value).toBe("boolean");
});
});

describe("useAbby", () => {
it("returns variant ref and onAct function", () => {
const { useAbby } = createAbby({
projectId: "test-project",
environments: [],
currentEnvironment: "",
tests: {
buttonColor: { variants: ["blue", "red"] },
},
flags: [],
remoteConfig: {},
});

const { variant, onAct } = useAbby("buttonColor");
expect(variant).toHaveProperty("value");
expect(["blue", "red"]).toContain(variant.value);
expect(typeof onAct).toBe("function");
});

it("onAct sends the correct ACT payload", () => {
const sendDataSpy = vi
.spyOn(HttpService, "sendData")
.mockReturnValue(undefined as never);

const { useAbby } = createAbby({
projectId: "test-project",
environments: [],
currentEnvironment: "",
tests: {
buttonColor: { variants: ["blue", "red"] },
},
flags: [],
remoteConfig: {},
});

const { variant, onAct } = useAbby("buttonColor");
onAct();

expect(sendDataSpy).toHaveBeenCalledTimes(1);
const payload = sendDataSpy.mock.calls[0]![0];
expect(payload.type).toBe(AbbyEventType.ACT);
expect(payload.data).toMatchObject({
projectId: "test-project",
testName: "buttonColor",
selectedVariant: variant.value,
});

sendDataSpy.mockRestore();
});
});

describe("useRemoteConfig", () => {
it("returns a ref with the remote config value (runtime + type)", () => {
const { useRemoteConfig } = createAbby({
projectId: "test-project",
environments: [],
currentEnvironment: "",
tests: {},
flags: [],
remoteConfig: { theme: "String" },
settings: {
remoteConfig: {
defaultValues: {
String: "default-theme",
},
},
},
});

const config = useRemoteConfig("theme");
expect(config).toHaveProperty("value");
expect(config.value).toBe("default-theme");
// A "String" remote config resolves to a string-typed ref value.
expectTypeOf(config.value).toBeString();
});
});

describe("AbbyPlugin", () => {
it("calls app.provide with the injection key", () => {
const { AbbyPlugin } = createAbby({
projectId: "test-project",
environments: [],
currentEnvironment: "",
tests: {},
flags: [],
remoteConfig: {},
});

const providedValues: Array<[symbol, unknown]> = [];
const fakeApp = {
provide: (key: symbol, value: unknown) => {
providedValues.push([key, value]);
},
};

AbbyPlugin(fakeApp);
expect(providedValues.length).toBe(1);
expect(providedValues[0]?.[1]).toHaveProperty("abby");
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
93 changes: 93 additions & 0 deletions packages/vue/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {
Abby,
AbbyEventType,
type ABConfig,
type AbbyConfig,
type RemoteConfigValueString,
type RemoteConfigValueStringToType,
HttpService,
} from "@tryabby/core";
import { ref, readonly, type InjectionKey, type Ref } from "vue";

export type { AbbyConfig, ABConfig };

const ABBY_INJECTION_KEY = Symbol("abby") as InjectionKey<{
abby: InstanceType<typeof Abby>;
}>;

export function createAbby<
const FlagName extends string,
const TestName extends string,
const Tests extends Record<TestName, ABConfig>,
const RemoteConfig extends Record<RemoteConfigName, RemoteConfigValueString>,
const RemoteConfigName extends Extract<keyof RemoteConfig, string>,
>(config: AbbyConfig<FlagName, Tests, string[], RemoteConfigName, RemoteConfig>) {
const abby = new Abby<FlagName, TestName, Tests, RemoteConfig, RemoteConfigName>(config, {
get: (key: string) => {
if (typeof window === "undefined") return null;
try {
return window.localStorage.getItem(key);
} catch {
// localStorage can throw SecurityError in private/restricted contexts
// or when storage access is blocked; fall back to no stored value.
return null;
}
},
set: (key: string, value: string) => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, value);
} catch {
// Ignore SecurityError (private mode) and QuotaExceededError (storage full).
}
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

function AbbyPlugin(app: { provide: (key: symbol, value: unknown) => void }) {
app.provide(ABBY_INJECTION_KEY, { abby });
}

function useAbby<T extends TestName>(
testName: T
): {
variant: Readonly<Ref<Tests[T]["variants"][number]>>;
onAct: () => void;
} {
const selectedVariant = abby.getTestVariant(testName);
const variant = ref(selectedVariant) as Ref<Tests[T]["variants"][number]>;

const onAct = () => {
HttpService.sendData({
url: config.apiUrl,
type: AbbyEventType.ACT,
data: {
projectId: config.projectId,
selectedVariant: variant.value as string,
testName: testName as string,
},
});
};

return { variant: readonly(variant) as Readonly<Ref<Tests[T]["variants"][number]>>, onAct };
}

function useFeatureFlag(flagName: FlagName): Readonly<Ref<boolean>> {
return readonly(ref(abby.getFeatureFlag(flagName)));
}

function useRemoteConfig<T extends RemoteConfigName>(
configName: T
): Readonly<Ref<RemoteConfigValueStringToType<RemoteConfig[T]>>> {
return readonly(
ref(abby.getRemoteConfig(configName)) as Ref<RemoteConfigValueStringToType<RemoteConfig[T]>>
) as Readonly<Ref<RemoteConfigValueStringToType<RemoteConfig[T]>>>;
}

return {
AbbyPlugin,
useAbby,
useFeatureFlag,
useRemoteConfig,
__abby__: abby,
};
}
16 changes: 16 additions & 0 deletions packages/vue/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["ES2020", "DOM"]
},
"include": ["src"]
}
8 changes: 8 additions & 0 deletions packages/vue/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
environment: "jsdom",
globals: true,
},
});
Loading