diff --git a/.changeset/vue-integration.md b/.changeset/vue-integration.md new file mode 100644 index 00000000..8eb1e2db --- /dev/null +++ b/.changeset/vue-integration.md @@ -0,0 +1,10 @@ +--- +"@tryabby/core": patch +"@tryabby/vue": minor +--- + +Add Vue 3 composables for A/B tests, feature flags and remote configuration, +with typed lookups, server rendering, lifecycle cleanup and devtools support. + +Declare zod as a core dependency so published TypeScript declarations resolve +in applications that install an integration without core's development tools. diff --git a/packages/core/package.json b/packages/core/package.json index c475cf16..ac42cd3f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -47,7 +47,9 @@ "tsup": "^6.5.0", "typescript": "5.5.4", "vite": "5.4.0", - "vitest": "2.0.5", + "vitest": "2.0.5" + }, + "dependencies": { "zod": "^3.19.1" } } diff --git a/packages/vue/.gitignore b/packages/vue/.gitignore new file mode 100644 index 00000000..849ddff3 --- /dev/null +++ b/packages/vue/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/vue/README.md b/packages/vue/README.md new file mode 100644 index 00000000..4cdd7289 --- /dev/null +++ b/packages/vue/README.md @@ -0,0 +1,231 @@ +# @tryabby/vue + +Vue 3 composables for Abby A/B tests, feature flags and remote configuration. +Uses `@tryabby/core` for data loading, targeting, defaults and variant selection. + +## Configure + +```ts +// abby.ts (a browser-only application) +import { createAbby } from "@tryabby/vue"; + +export const { + AbbyProvider, + useAbby, + useFeatureFlag, + useRemoteConfig, +} = createAbby({ + projectId: "your-project-id", + environments: ["development", "production"], + currentEnvironment: "development", + tests: { checkout: { variants: ["classic", "compact"] } }, + flags: ["banner"], + remoteConfig: { title: "String", limit: "Number" }, +}); +``` + +Wrap the components that consume these composables in the provider from the +**same factory call**: + +```vue + + + + +``` + +```vue + + + + +``` + +The composables return read-only computed refs. Vue unwraps them in templates; +use `.value` in JavaScript. Call composables synchronously in component setup. +Their names are fixed for that component instance. Remount the consumer if its +configured key changes. + +`variant.value` is `undefined` during SSR and before the initial client data has +loaded. It then narrows to the configured variants. An optional complete lookup +maps variants while preserving value types: + +```ts +const { variant } = useAbby("checkout", { classic: "Continue", compact: "Buy now" }); +// ComputedRef<"Continue" | "Buy now" | undefined> +``` + +Each mounted consumer reports an exposure when its selected variant changes. +`onAct()` reports a conversion for its current variant. Neither sends events +during SSR, before a variant exists, or after that consumer unmounts. Core's +existing localhost tracking suppression still applies. + +## Initial data and SSR + +`` accepts the core `AbbyDataResponse` shape +(`tests`, `flags`, `remoteConfig`) and skips its automatic fetch. Without initial +data, fetching begins on mount. The provider supports the same `apiUrl`, custom +`fetch`, environment and window bootstrap data as core. Network failures use +core's defaults. Core subscriptions update the refs and are removed on unmount; +an old provider's pending response cannot initialize its replacement. + +**Create a new factory for each server request.** A factory owns mutable core +state, targeting properties and overrides. Do not export a server singleton or +share one between requests. Reusing an immutable configuration object to create +separate factories is supported. Use one provider per factory. + +Pass the same initial data to server rendering and client hydration. Flags and +remote config render from that data; the variant remains absent until the client +mounts. Include an appropriate placeholder where the test will appear. Cookie +overrides and user targeting must also agree between server and client if they +affect server-rendered flags/config. + +### Nuxt 4 + +Create the factory inside a universal Nuxt plugin: Nuxt runs it for each server +request and again for the client application. `useAsyncData` transfers only the +project data in the hydration payload; the factory and its mutable core state +stay in the current application. Do not give this plugin a `.client` suffix if +flags or remote configuration should render on the server. + +```ts +// app/plugins/abby.ts +import type { AbbyDataResponse } from "@tryabby/core"; +import { createAbby } from "@tryabby/vue"; + +export default defineNuxtPlugin(async () => { + const abby = createAbby({ + projectId: "your-project-id", + environments: ["production"], + currentEnvironment: "production", + tests: { checkout: { variants: ["classic", "compact"] } }, + flags: ["banner"], + remoteConfig: { title: "String", limit: "Number" }, + }); + const { data, error } = await useAsyncData("abby-initial", () => + $fetch("/api/abby") + ); + if (error.value || !data.value) { + throw createError({ statusCode: 503, statusMessage: "Abby data unavailable" }); + } + return { provide: { abby, abbyInitialData: data.value } }; +}); +``` + +`/api/abby` is an endpoint supplied by your application, not by this package. +It must return the core `AbbyDataResponse` shape. Only return data suitable for +the browser; keep any service credentials in the server endpoint. Choose the +error policy appropriate for your application rather than passing missing data +to the provider unintentionally. + +```vue + + + + +``` + +Call the Abby composables in a descendant page or component's setup, where the +provider is available. They must not be called in the plugin or in `app.vue` +before its provider is rendered. Nuxt infers the injected factory's types: + +```vue + + + + +``` + +The variant still waits until client mount, as described above. Cookie overrides +and user targeting need the same server/client handling as other SSR consumers. +This is a plugin recipe, not a Nuxt module or global Abby auto-imports. + +## Other helpers + +The factory also provides `useFeatureFlags()` and `useRemoteConfigVariables()` +as computed lists, plus `getFeatureFlagValue`, `getRemoteConfig`, +`getABTestValue`, `getVariants`, `getABResetFunction` and +`updateUserProperties`. These non-composable helpers retain configured key and +value types. `__abby__` exposes the underlying core instance for explicit +reloads and existing core tooling. `defineConfig` is re-exported from core. + +Test cookies use the same project-prefixed names as the other integrations and +honor `cookies.disableByDefault` and `cookies.expiresInDays`. Saved consent is +read before initializing or refreshing variant cookies. No browser storage is +accessed on the server. + +With `cookies.disableByDefault: true`, removing saved consent disables cookie +writes when a provider from the same factory remounts. A new grant through +`__abby__.enableCookies()` saves consent and enables persistence again. Removing +consent does not erase existing variant cookies, and external cookie changes +are not watched while the provider stays mounted. + +### Devtools + +```ts +import devtools from "@tryabby/devtools"; + +const AbbyDevtools = abby.withDevtools(devtools, { position: "bottom-right" }); +// Render in the application's template. +``` + +Install `@tryabby/devtools` separately if using it. Options are inferred from +the supplied factory, so this integration does not require loading the devtools +bundle otherwise. The component creates devtools on mount in development and +calls their cleanup function on unmount. It renders nothing during SSR. +`dangerouslyForceShow: true` explicitly enables it outside development. + +## Local verification and example + +From the repository root, with Node 22 and pnpm 9.9.0: + +```sh +pnpm --filter @tryabby/vue... install --frozen-lockfile +pnpm --filter @tryabby/core build +pnpm --filter @tryabby/vue test +pnpm --filter @tryabby/vue build +pnpm --filter @tryabby/vue example +``` + +`test` runs runtime tests followed by `tsc --noEmit`, including positive +inference assertions and rejected invalid names, lookups and writes. Tests +cover SSR, hydration, targeting, cookie consent, late consumers, cleanup, +stale requests and devtools lifecycle. + +Open `http://127.0.0.1:4178`. The example consumes the built package and serves +synthetic project data from its local Vite middleware. **Change server data** +loads alternate flags/config/weights. **Record conversion**, then **Read +events**, shows the server's received exposure/conversion payloads. This makes +no calls to an Abby account and uses no customer data. diff --git a/packages/vue/example/index.html b/packages/vue/example/index.html new file mode 100644 index 00000000..84341673 --- /dev/null +++ b/packages/vue/example/index.html @@ -0,0 +1,37 @@ + + + + + + Abby · Vue integration + + + + +

Abby for Vue

+

A local example of feature flags, remote configuration and A/B conversion events.

+ All data is synthetic. Requests stay on this local server. +
+ + + + diff --git a/packages/vue/example/main.ts b/packages/vue/example/main.ts new file mode 100644 index 00000000..61994ca1 --- /dev/null +++ b/packages/vue/example/main.ts @@ -0,0 +1,91 @@ +import { createApp, defineComponent, h, ref } from "vue"; +import { createAbby } from "../dist/index.mjs"; + +const abby = createAbby({ + projectId: "vue-example", + apiUrl: `${window.location.origin}/`, + environments: ["local"], + currentEnvironment: "local", + flags: ["banner"], + tests: { checkout: { variants: ["classic", "compact"] } }, + remoteConfig: { title: "String" }, + cookies: { disableByDefault: true }, +}); + +const Example = defineComponent({ + setup() { + const banner = abby.useFeatureFlag("banner"); + const title = abby.useRemoteConfig("title"); + const { variant, onAct } = abby.useAbby("checkout"); + const busy = ref(false); + const message = ref(""); + const events = ref("No events read yet."); + async function changeData() { + busy.value = true; + try { + const response = await fetch("/fixture/toggle", { method: "POST" }); + if (!response.ok) throw new Error("Could not update the fixture"); + await abby.__abby__.loadProjectData(); + message.value = "Loaded the updated fixture."; + } catch (error) { + message.value = String(error); + } finally { + busy.value = false; + } + } + async function readEvents() { + try { + const response = await fetch("/fixture/events"); + if (!response.ok) throw new Error("Could not read events"); + events.value = JSON.stringify(await response.json(), null, 2); + } catch (error) { + message.value = String(error); + } + } + const row = (label: string, value: unknown) => + h("tr", [ + h("th", { scope: "row" }, h("code", label)), + h("td", String(value)), + ]); + return () => [ + h("table", [ + h("tbody", [ + row('useFeatureFlag("banner")', banner.value), + row('useRemoteConfig("title")', title.value), + row('useAbby("checkout").variant', variant.value ?? "Loading…"), + ]), + ]), + h("div", { class: "actions" }, [ + h( + "button", + { type: "button", disabled: busy.value, onClick: changeData }, + busy.value ? "Loading fixture…" : "Change server data" + ), + h( + "button", + { + type: "button", + disabled: variant.value === undefined, + onClick: () => { + onAct(); + message.value = + "Conversion requested. Read events to verify receipt."; + }, + }, + "Record conversion" + ), + h("button", { type: "button", onClick: readEvents }, "Read events"), + ]), + h("p", { id: "message", role: "status" }, message.value), + h("h2", "Server event log"), + h( + "p", + "Type 0 is an exposure. Type 1 is a conversion. Read events after recording a conversion to see the server receipt." + ), + h("pre", { "data-testid": "events" }, events.value), + ]; + }, +}); +createApp({ render: () => h(abby.AbbyProvider, {}, () => h(Example)) }).mount( + "#app" +); diff --git a/packages/vue/example/vite.config.ts b/packages/vue/example/vite.config.ts new file mode 100644 index 00000000..cf0ee79a --- /dev/null +++ b/packages/vue/example/vite.config.ts @@ -0,0 +1,71 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +export default defineConfig({ + define: { + __VUE_OPTIONS_API__: true, + __VUE_PROD_DEVTOOLS__: false, + __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false, + }, + root: fileURLToPath(new URL(".", import.meta.url)), + server: { host: "127.0.0.1", port: 4178, strictPort: true }, + plugins: [ + { + name: "local-abby-fixture", + configureServer(server) { + let alternate = false; + const events: unknown[] = []; + server.middlewares.use((request, response, next) => { + const pathname = new URL(request.url ?? "/", "http://127.0.0.1") + .pathname; + const reply = (data: unknown) => { + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify(data)); + }; + if ( + pathname === "/api/v2/data/vue-example" && + request.method === "GET" + ) { + reply({ + tests: [ + { name: "checkout", weights: alternate ? [1, 0] : [0, 1] }, + ], + flags: [{ name: "banner", value: !alternate }], + remoteConfig: [ + { + name: "title", + value: alternate ? "Welcome back" : "Welcome", + }, + ], + }); + } else if ( + pathname === "/fixture/toggle" && + request.method === "POST" + ) { + alternate = !alternate; + reply({ alternate }); + } else if ( + pathname === "/fixture/events" && + request.method === "GET" + ) { + reply(events); + } else if (pathname === "/api/data" && request.method === "POST") { + let body = ""; + request.on("data", (chunk) => { + body += chunk; + }); + request.on("end", () => { + try { + events.push(JSON.parse(body)); + reply({ recorded: true }); + } catch { + response.statusCode = 400; + reply({ error: "Invalid event JSON" }); + } + }); + } else next(); + }); + }, + }, + ], +}); diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 00000000..7c54ca85 --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,42 @@ +{ + "name": "@tryabby/vue", + "version": "0.0.0", + "description": "Vue 3 composables for Abby", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "files": ["dist"], + "sideEffects": false, + "license": "AGPL-3.0-only", + "scripts": { + "build": "tsup src/index.ts", + "dev": "pnpm build --watch", + "example": "vite --config example/vite.config.ts", + "test": "vitest run && pnpm typecheck", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@tryabby/core": "workspace:*", + "js-cookie": "^3.0.1" + }, + "peerDependencies": { + "vue": "^3.3.0" + }, + "devDependencies": { + "@types/js-cookie": "^3.0.3", + "jsdom": "^20.0.3", + "tsconfig": "workspace:*", + "tsup": "^6.5.0", + "typescript": "5.5.4", + "vite": "5.4.0", + "vitest": "2.0.5", + "vue": "3.5.12" + } +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 00000000..0979cbac --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,303 @@ +import { + ABBY_WINDOW_KEY, + type ABConfig, + Abby, + type AbbyConfig, + type AbbyDataResponse, + AbbyEventType, + HttpService, + type RemoteConfigValueString, + type RemoteConfigValueStringToType, + type ValidatorType, +} from "@tryabby/core"; +import { + type DefineComponent, + type InjectionKey, + type PropType, + computed, + defineComponent, + inject, + onBeforeUnmount, + onMounted, + provide, + shallowRef, + watch, +} from "vue"; +import { + createStorage, + getABStorageKey, + getFFStorageKey, + getRCStorageKey, +} from "./storage"; + +export { type ABConfig, type AbbyConfig, defineConfig } from "@tryabby/core"; + +export type ABTestReturnValue = Lookup extends undefined + ? Variant + : Variant extends keyof Lookup + ? Lookup[Variant] + : 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< + string, + ValidatorType + >, +>( + abbyConfig: AbbyConfig< + FlagName, + Tests, + string[], + RemoteConfigName, + RemoteConfig, + User + > +) { + const requiresCookieConsent = abbyConfig.cookies?.disableByDefault === true; + // Core assigns weights and cookie preferences in place. A shared config + // must not carry those mutations into another server request. + const config = { + ...abbyConfig, + cookies: abbyConfig.cookies ? { ...abbyConfig.cookies } : undefined, + tests: abbyConfig.tests + ? (Object.fromEntries( + Object.entries(abbyConfig.tests as Record).map( + ([name, test]) => [name, { ...test, variants: [...test.variants] }] + ) + ) as unknown as Tests) + : undefined, + }; + const testStorage = createStorage(config.projectId, getABStorageKey); + const abby = new Abby< + FlagName, + TestName, + Tests, + RemoteConfig, + RemoteConfigName, + string[], + User + >( + config, + testStorage, + createStorage(config.projectId, getFFStorageKey), + createStorage(config.projectId, getRCStorageKey) + ); + type ProjectData = ReturnType; + const createContext = () => ({ + data: shallowRef(), + mounted: shallowRef(false), + }); + const contextKey: InjectionKey> = + Symbol("Abby"); + + function useContext() { + const context = inject(contextKey, null); + if (!context) { + throw new Error( + "Abby composables must be used inside the AbbyProvider returned by the same createAbby call." + ); + } + return context; + } + + const AbbyProvider = defineComponent({ + name: "AbbyProvider", + props: { + initialData: Object as PropType, + }, + setup(props, { slots }) { + const context = createContext(); + // Read saved consent before init notifies subscribers and selects tests. + if (typeof document !== "undefined") { + const consent = testStorage.get("$_abcc_$"); + if (consent !== null) { + abby.setLocalOverrides( + `${getABStorageKey(config.projectId, "$_abcc_$")}=${encodeURIComponent(consent)}` + ); + } else if (requiresCookieConsent) { + // A reused core instance may still hold the previous consent grant. + abby.disableCookies(); + } + abby.setLocalOverrides(document.cookie); + } + if (props.initialData) context.data.value = abby.init(props.initialData); + provide(contextKey, context); + let unsubscribe: (() => void) | undefined; + onMounted(() => { + context.mounted.value = true; + unsubscribe = abby.subscribe(() => { + context.data.value = abby.getProjectData(); + }); + if (props.initialData) { + // Child mount hooks run first and may update targeting before we subscribe. + context.data.value = abby.getProjectData(); + return; + } + // Select the first variant only after the server's weights arrive. + // Fetch without init so a retired provider cannot overwrite a new one. + const load = + typeof window !== "undefined" && + ABBY_WINDOW_KEY in window && + window[ABBY_WINDOW_KEY] != null + ? Promise.resolve(window[ABBY_WINDOW_KEY] as AbbyDataResponse) + : HttpService.getProjectData({ + projectId: config.projectId, + environment: config.currentEnvironment, + url: config.apiUrl, + fetch: config.fetch, + __experimentalCdnUrl: config.__experimentalCdnUrl + ? `${config.__experimentalCdnUrl}/${config.projectId}/${config.currentEnvironment}` + : undefined, + }); + void load.then((data) => { + if (context.mounted.value) { + context.data.value = data ? abby.init(data) : abby.getProjectData(); + } + }); + }); + onBeforeUnmount(() => { + context.mounted.value = false; + unsubscribe?.(); + }); + return () => slots.default?.(); + }, + }) as DefineComponent<{ initialData?: AbbyDataResponse }>; + + function useAbby< + K extends keyof Tests, + Variant extends Tests[K]["variants"][number], + const Lookup extends Record | undefined = undefined, + >(name: K, lookup?: Lookup) { + const context = useContext(); + const mounted = shallowRef(false); + onMounted(() => { + mounted.value = true; + }); + onBeforeUnmount(() => { + mounted.value = false; + }); + const selected = computed(() => + context.mounted.value && mounted.value + ? context.data.value?.tests[name as unknown as TestName] + ?.selectedVariant + : undefined + ); + function send(type: AbbyEventType) { + if (!selected.value) return; + HttpService.sendData({ + url: config.apiUrl, + type, + data: { + projectId: config.projectId, + testName: String(name), + selectedVariant: selected.value, + }, + }); + } + watch(selected, (variant) => { + if (variant) send(AbbyEventType.PING); + }); + return { + // undefined is explicit during SSR and the first client render. + variant: computed | undefined>(() => { + const variant = selected.value as Variant | undefined; + if (variant === undefined) return undefined; + return (lookup ? lookup[variant] : variant) as ABTestReturnValue< + Lookup, + Variant + >; + }), + onAct: () => send(AbbyEventType.ACT), + }; + } + + function useFeatureFlag(name: FlagName) { + const context = useContext(); + return computed(() => { + context.data.value; + return abby.getFeatureFlag(name); + }); + } + + function useRemoteConfig(name: K) { + const context = useContext(); + return computed>(() => { + context.data.value; + return abby.getRemoteConfig(name); + }); + } + + function useFeatureFlags() { + const context = useContext(); + return computed(() => { + context.data.value; + return abby.getFeatureFlags(); + }); + } + + function useRemoteConfigVariables() { + const context = useContext(); + return computed(() => { + context.data.value; + return abby.getRemoteConfigVariables(); + }); + } + + function getABTestValue< + K extends keyof Tests, + Variant extends Tests[K]["variants"][number], + const Lookup extends Record | undefined = undefined, + >(name: K, lookup?: Lookup): ABTestReturnValue { + const variant = abby.getTestVariant(name) as Variant; + return (lookup ? lookup[variant] : variant) as ABTestReturnValue< + Lookup, + Variant + >; + } + + function withDevtools }>( + factory: { create(props: Props): () => void }, + props: Omit & { dangerouslyForceShow?: boolean } + ): DefineComponent { + return defineComponent({ + name: "AbbyDevtools", + setup() { + let destroy: (() => void) | undefined; + onMounted(() => { + if ( + !props.dangerouslyForceShow && + process.env.NODE_ENV !== "development" + ) + return; + const { dangerouslyForceShow: _force, ...options } = props; + destroy = factory.create({ ...options, abby } as unknown as Props); + }); + onBeforeUnmount(() => destroy?.()); + return () => null; + }, + }) as DefineComponent; + } + + return { + AbbyProvider, + useAbby, + useFeatureFlag, + useRemoteConfig, + useFeatureFlags, + useRemoteConfigVariables, + getABTestValue, + withDevtools, + getFeatureFlagValue: abby.getFeatureFlag.bind(abby), + getRemoteConfig: abby.getRemoteConfig.bind(abby), + getVariants: abby.getVariants.bind(abby), + getABResetFunction: (name: keyof Tests) => () => + testStorage.remove(String(name)), + updateUserProperties: abby.updateUserProperties.bind(abby), + __abby__: abby, + }; +} diff --git a/packages/vue/src/storage.ts b/packages/vue/src/storage.ts new file mode 100644 index 00000000..7dc0e85e --- /dev/null +++ b/packages/vue/src/storage.ts @@ -0,0 +1,32 @@ +import { + type StorageServiceOptions, + getABStorageKey, + getFFStorageKey, + getRCStorageKey, +} from "@tryabby/core"; +import Cookies from "js-cookie"; + +export function createStorage( + projectId: string, + storageKey: typeof getABStorageKey +) { + return { + get(key: string) { + if (typeof document === "undefined") return null; + return Cookies.get(storageKey(projectId, key)) ?? null; + }, + set(key: string, value: string, options?: StorageServiceOptions) { + if (typeof document === "undefined") return; + Cookies.set(storageKey(projectId, key), value, { + expires: options?.expiresInDays ?? 365, + }); + }, + remove(key: string) { + if (typeof document !== "undefined") { + Cookies.remove(storageKey(projectId, key)); + } + }, + }; +} + +export { getABStorageKey, getFFStorageKey, getRCStorageKey }; diff --git a/packages/vue/tests/composables.test.ts b/packages/vue/tests/composables.test.ts new file mode 100644 index 00000000..ec87e858 --- /dev/null +++ b/packages/vue/tests/composables.test.ts @@ -0,0 +1,588 @@ +import { + type AbbyDataResponse, + AbbyEventType, + HttpService, +} from "@tryabby/core"; +import Cookies from "js-cookie"; +import { + type App, + createApp, + createSSRApp, + defineComponent, + h, + nextTick, + onMounted, + shallowRef, +} from "vue"; +import { renderToString } from "vue/server-renderer"; +import { createAbby } from "../src"; + +const initialData: AbbyDataResponse = { + tests: [{ name: "checkout", weights: [0, 1] }], + flags: [{ name: "banner", value: true }], + remoteConfig: [ + { name: "title", value: "Welcome" }, + { name: "limit", value: 3 }, + ], +}; +function configure( + cookies: { disableByDefault?: boolean } = { disableByDefault: true } +) { + return createAbby({ + projectId: "vue-tests", + apiUrl: "http://127.0.0.1:9876/", + currentEnvironment: "test", + environments: ["test"], + tests: { checkout: { variants: ["old", "new"] } }, + flags: ["banner", "missing"], + remoteConfig: { title: "String", limit: "Number", missing: "String" }, + cookies, + settings: { + flags: { fallbackValues: { missing: true } }, + remoteConfig: { defaultValues: { String: "Default" } }, + }, + user: { customer: { type: "boolean" } }, + }); +} +type Integration = ReturnType; +const apps: App[] = []; +function mount(abby: Integration, setup: () => T, data?: AbbyDataResponse) { + let result!: T; + const child = defineComponent({ + setup() { + result = setup(); + return () => h("span", "consumer"); + }, + }); + const app = createApp({ + render: () => h(abby.AbbyProvider, { initialData: data }, () => h(child)), + }); + apps.push(app); + const element = document.createElement("div"); + app.mount(element); + return { result, app, element }; +} + +beforeEach(() => { + vi.spyOn(HttpService, "sendData").mockImplementation(() => undefined); + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("Unexpected network request")) + ); +}); +afterEach(() => { + for (const app of apps.splice(0)) app.unmount(); + for (const cookie of document.cookie.split(";")) { + document.cookie = `${cookie.split("=")[0].trim()}=; Max-Age=0; path=/`; + } + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +it("returns a weighted variant, lookup value, flags and typed config", async () => { + const abby = configure(); + const { result } = mount( + abby, + () => ({ + test: abby.useAbby("checkout"), + lookup: abby.useAbby("checkout", { old: 1, new: 2 }), + flag: abby.useFeatureFlag("banner"), + title: abby.useRemoteConfig("title"), + limit: abby.useRemoteConfig("limit"), + }), + initialData + ); + await nextTick(); + expect(result.test.variant.value).toBe("new"); + expect(result.lookup.variant.value).toBe(2); + expect(result.flag.value).toBe(true); + expect(result.title.value).toBe("Welcome"); + expect(result.limit.value).toBe(3); + expect(fetch).not.toHaveBeenCalled(); +}); + +it("hydrates server data without a variant or text mismatch", async () => { + function createPage() { + const abby = configure(); + const child = defineComponent({ + setup() { + const flag = abby.useFeatureFlag("banner"); + const title = abby.useRemoteConfig("title"); + const { variant } = abby.useAbby("checkout"); + return () => + h("p", `${flag.value}|${title.value}|${variant.value ?? "pending"}`); + }, + }); + return createSSRApp({ + render: () => h(abby.AbbyProvider, { initialData }, () => h(child)), + }); + } + const element = document.createElement("div"); + element.innerHTML = await renderToString(createPage()); + expect(element.textContent).toBe("true|Welcome|pending"); + const app = createPage(); + const warning = vi.fn(); + app.config.warnHandler = warning; + apps.push(app); + app.mount(element); + await nextTick(); + expect(element.textContent).toBe("true|Welcome|new"); + expect(warning).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); +}); + +it("tracks each rendered variant and its conversion", async () => { + const abby = configure(); + const { result } = mount(abby, () => abby.useAbby("checkout"), initialData); + await nextTick(); + expect(HttpService.sendData).toHaveBeenCalledOnce(); + expect(HttpService.sendData).toHaveBeenCalledWith({ + url: "http://127.0.0.1:9876/", + type: AbbyEventType.PING, + data: { + projectId: "vue-tests", + testName: "checkout", + selectedVariant: "new", + }, + }); + result.onAct(); + expect(HttpService.sendData).toHaveBeenLastCalledWith( + expect.objectContaining({ type: AbbyEventType.ACT }) + ); +}); + +it("updates every composable after core subscription events", async () => { + vi.stubEnv("NODE_ENV", "development"); + const abby = configure(); + const { result } = mount( + abby, + () => ({ + test: abby.useAbby("checkout"), + flag: abby.useFeatureFlag("banner"), + title: abby.useRemoteConfig("title"), + flags: abby.useFeatureFlags(), + variables: abby.useRemoteConfigVariables(), + }), + initialData + ); + await nextTick(); + abby.__abby__.updateLocalVariant("checkout", "old"); + abby.__abby__.updateFlag("banner", false); + abby.__abby__.updateRemoteConfig("title", "Changed"); + await nextTick(); + expect(result.test.variant.value).toBe("old"); + expect(result.flag.value).toBe(false); + expect(result.title.value).toBe("Changed"); + expect(result.flags.value).toContainEqual({ name: "banner", value: false }); + expect(result.variables.value).toContainEqual({ + name: "title", + value: "Changed", + }); + expect(HttpService.sendData).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: AbbyEventType.PING, + data: expect.objectContaining({ selectedVariant: "old" }), + }) + ); +}); + +it("uses core fallbacks when the API omits a configured key", () => { + const abby = configure(); + const { result } = mount( + abby, + () => ({ + flag: abby.useFeatureFlag("missing"), + remote: abby.useRemoteConfig("missing"), + }), + initialData + ); + expect(result.flag.value).toBe(true); + expect(result.remote.value).toBe("Default"); +}); + +it("fetches once on mount, waits for weights, and updates the initial view", async () => { + let resolve!: (value: Response) => void; + vi.mocked(fetch).mockImplementation( + () => + new Promise((done) => { + resolve = done; + }) + ); + const abby = configure(); + const { result } = mount(abby, () => ({ + test: abby.useAbby("checkout"), + flag: abby.useFeatureFlag("banner"), + })); + expect(result.test.variant.value).toBeUndefined(); + result.test.onAct(); + expect(HttpService.sendData).not.toHaveBeenCalled(); + expect(result.flag.value).toBe(false); + expect(fetch).toHaveBeenCalledOnce(); + expect(fetch).toHaveBeenCalledWith( + "http://127.0.0.1:9876/api/v2/data/vue-tests?environment=test" + ); + resolve(new Response(JSON.stringify(initialData))); + await vi.waitFor(() => expect(result.test.variant.value).toBe("new")); + expect(result.flag.value).toBe(true); +}); + +it("keeps defaults when the project request fails", async () => { + vi.mocked(fetch).mockResolvedValue( + new Response("Unavailable", { status: 503 }) + ); + const abby = configure(); + const { result } = mount(abby, () => ({ + test: abby.useAbby("checkout"), + title: abby.useRemoteConfig("title"), + })); + await vi.waitFor(() => expect(result.test.variant.value).toBe("old")); + expect(result.title.value).toBe("Default"); +}); + +it("unsubscribes and stops tracking when the provider unmounts", async () => { + const abby = configure(); + const subscribe = abby.__abby__.subscribe.bind(abby.__abby__); + const removed = vi.fn(); + vi.spyOn(abby.__abby__, "subscribe").mockImplementation((listener) => { + const unsubscribe = subscribe(listener); + return () => { + removed(); + unsubscribe(); + }; + }); + const { result, app } = mount( + abby, + () => abby.useAbby("checkout"), + initialData + ); + await nextTick(); + app.unmount(); + apps.splice(apps.indexOf(app), 1); + vi.mocked(HttpService.sendData).mockClear(); + abby.__abby__.updateFlag("banner", false); + result.onAct(); + await nextTick(); + expect(removed).toHaveBeenCalledOnce(); + expect(HttpService.sendData).not.toHaveBeenCalled(); +}); + +it("ignores an outstanding load after unmount", async () => { + let resolve!: (value: Response) => void; + vi.mocked(fetch).mockImplementation( + () => + new Promise((done) => { + resolve = done; + }) + ); + const abby = configure(); + const { result, app } = mount(abby, () => abby.useAbby("checkout")); + app.unmount(); + apps.splice(apps.indexOf(app), 1); + resolve(new Response(JSON.stringify(initialData))); + await new Promise((done) => setTimeout(done, 0)); + expect(result.variant.value).toBeUndefined(); + expect(HttpService.sendData).not.toHaveBeenCalled(); +}); + +it("does not let a retired provider's request overwrite its replacement", async () => { + let resolve!: (value: Response) => void; + vi.mocked(fetch).mockImplementation( + () => + new Promise((done) => { + resolve = done; + }) + ); + const abby = configure(); + const first = mount(abby, () => abby.useRemoteConfig("title")); + first.app.unmount(); + apps.splice(apps.indexOf(first.app), 1); + const second = mount(abby, () => abby.useRemoteConfig("title"), initialData); + expect(second.result.value).toBe("Welcome"); + resolve( + new Response( + JSON.stringify({ + ...initialData, + remoteConfig: [{ name: "title", value: "Stale" }], + }) + ) + ); + await new Promise((done) => setTimeout(done, 0)); + expect(second.result.value).toBe("Welcome"); + expect(abby.getRemoteConfig("title")).toBe("Welcome"); +}); + +it("tracks late consumers and stops conversions after only the consumer unmounts", async () => { + const abby = configure(); + const visible = shallowRef(false); + let onAct!: () => void; + const child = defineComponent({ + setup() { + onAct = abby.useAbby("checkout").onAct; + return () => h("p", "Checkout"); + }, + }); + const app = createApp({ + render: () => + h(abby.AbbyProvider, { initialData }, () => + visible.value ? h(child) : null + ), + }); + apps.push(app); + app.mount(document.createElement("div")); + await nextTick(); + expect(HttpService.sendData).not.toHaveBeenCalled(); + visible.value = true; + await nextTick(); + expect(HttpService.sendData).toHaveBeenCalledOnce(); + expect(HttpService.sendData).toHaveBeenLastCalledWith( + expect.objectContaining({ type: AbbyEventType.PING }) + ); + onAct(); + expect(HttpService.sendData).toHaveBeenCalledTimes(2); + visible.value = false; + await nextTick(); + onAct(); + expect(HttpService.sendData).toHaveBeenCalledTimes(2); +}); + +it.each(["useAbby", "useFeatureFlag", "useRemoteConfig"] as const)( + "explains a missing provider for %s", + (method) => { + const abby = configure(); + const error = vi.fn(); + const app = createApp({ + setup: () => { + if (method === "useAbby") abby.useAbby("checkout"); + if (method === "useFeatureFlag") abby.useFeatureFlag("banner"); + if (method === "useRemoteConfig") abby.useRemoteConfig("title"); + return () => null; + }, + render: () => null, + }); + app.config.errorHandler = error; + apps.push(app); + app.mount(document.createElement("div")); + expect(error.mock.calls[0][0].message).toContain("inside the AbbyProvider"); + } +); + +it("does not accept the provider from a different factory", () => { + const first = configure(); + const second = configure(); + const error = vi.fn(); + const child = defineComponent({ + setup() { + second.useFeatureFlag("banner"); + }, + render: () => null, + }); + const app = createApp({ + render: () => h(first.AbbyProvider, { initialData }, () => h(child)), + }); + app.config.errorHandler = error; + apps.push(app); + app.mount(document.createElement("div")); + expect(error.mock.calls[0][0].message).toContain("same createAbby"); +}); + +it("persists the variant in the same cookie format as other integrations", async () => { + const abby = createAbby({ + projectId: "cookies", + currentEnvironment: "test", + environments: ["test"], + tests: { checkout: { variants: ["old", "new"] } }, + }); + // The provider helper is deliberately narrow; this instance only differs in config. + const app = createApp({ + render: () => h(abby.AbbyProvider, { initialData }, () => h("div")), + }); + apps.push(app); + app.mount(document.createElement("div")); + expect(document.cookie).toContain("__abby__ab__cookies_checkout=new"); + expect(abby.getABTestValue("checkout")).toBe("new"); + abby.getABResetFunction("checkout")(); + expect(document.cookie).not.toContain("__abby__ab__cookies_checkout="); +}); + +it.each([false, true])( + "reads saved refusal before cookie writes (existing variant: %s)", + async (existing) => { + if (existing) document.cookie = "__abby__ab__consent_checkout=old; path=/"; + document.cookie = "__abby__ab__consent_$_abcc_$=false; path=/"; + const writeCookie = vi.spyOn(Cookies, "set"); + let resolve!: (value: Response) => void; + vi.mocked(fetch).mockImplementation( + () => + new Promise((done) => { + resolve = done; + }) + ); + const abby = createAbby({ + projectId: "consent", + currentEnvironment: "test", + environments: ["test"], + tests: { checkout: { variants: ["old", "new"] } }, + }); + let value!: ReturnType; + const child = defineComponent({ + setup() { + value = abby.useAbby("checkout"); + return () => null; + }, + }); + const app = createApp({ + render: () => h(abby.AbbyProvider, {}, () => h(child)), + }); + apps.push(app); + app.mount(document.createElement("div")); + expect(writeCookie).not.toHaveBeenCalled(); + resolve(new Response(JSON.stringify(initialData))); + await vi.waitFor(() => + expect(value.variant.value).toBe(existing ? "old" : "new") + ); + expect(writeCookie).not.toHaveBeenCalled(); + if (!existing) + expect(document.cookie).not.toContain("__abby__ab__consent_checkout="); + } +); + +it.each([false, true])( + "restores opt-in after consent is removed on remount (retained variant: %s)", + async (retainVariant) => { + const consentKey = "__abby__ab__vue-tests_$_abcc_$"; + const variantKey = "__abby__ab__vue-tests_checkout"; + Cookies.set(consentKey, "true"); + const abby = configure(); + const first = mount(abby, () => abby.useAbby("checkout"), initialData); + await nextTick(); + expect(Cookies.get(variantKey)).toBe("new"); + first.app.unmount(); + + Cookies.remove(consentKey); + if (!retainVariant) Cookies.remove(variantKey); + const writeCookie = vi.spyOn(Cookies, "set"); + const second = mount(abby, () => abby.useAbby("checkout"), initialData); + await nextTick(); + expect(second.result.variant.value).toBe("new"); + expect(writeCookie).not.toHaveBeenCalled(); + expect(abby.__abby__.getConfig().cookies?.disableByDefault).toBe(true); + expect(Cookies.get(consentKey)).toBeUndefined(); + expect(Cookies.get(variantKey)).toBe(retainVariant ? "new" : undefined); + + // An explicit new grant must work in this provider and survive remounting. + abby.__abby__.enableCookies(); + expect(Cookies.get(consentKey)).toBe("true"); + expect(Cookies.get(variantKey)).toBe("new"); + second.app.unmount(); + Cookies.remove(variantKey); + mount(abby, () => abby.useAbby("checkout"), initialData); + await nextTick(); + expect(Cookies.get(variantKey)).toBe("new"); + expect(abby.__abby__.getConfig().cookies?.disableByDefault).toBe(false); + } +); + +it("honors a saved refusal when remounting a previously consenting factory", async () => { + const consentKey = "__abby__ab__vue-tests_$_abcc_$"; + Cookies.set(consentKey, "true"); + const abby = configure(); + const first = mount(abby, () => abby.useAbby("checkout"), initialData); + first.app.unmount(); + Cookies.set(consentKey, "false"); + const writeCookie = vi.spyOn(Cookies, "set"); + mount(abby, () => abby.useAbby("checkout"), initialData); + await nextTick(); + expect(writeCookie).not.toHaveBeenCalled(); + expect(abby.__abby__.getConfig().cookies?.disableByDefault).toBe(true); +}); + +it.each([{}, { disableByDefault: false }])( + "preserves default-allow and explicit disabling on remount (%j)", + async (cookies) => { + const variantKey = "__abby__ab__vue-tests_checkout"; + const abby = configure(cookies); + const first = mount(abby, () => abby.useAbby("checkout"), initialData); + expect(Cookies.get(variantKey)).toBe("new"); + first.app.unmount(); + Cookies.remove(variantKey); + const second = mount(abby, () => abby.useAbby("checkout"), initialData); + expect(Cookies.get(variantKey)).toBe("new"); + + abby.__abby__.disableCookies(); + second.app.unmount(); + Cookies.remove(variantKey); + const writeCookie = vi.spyOn(Cookies, "set"); + mount(abby, () => abby.useAbby("checkout"), initialData); + await nextTick(); + expect(writeCookie).not.toHaveBeenCalled(); + expect(Cookies.get(variantKey)).toBeUndefined(); + expect(abby.__abby__.getConfig().cookies?.disableByDefault).toBe(true); + } +); + +it("respects disabled cookies and applies typed user targeting updates", async () => { + const abby = configure(); + const data: AbbyDataResponse = { + ...initialData, + flags: [ + { + name: "banner", + value: false, + ruleSet: [ + { + propertyName: "customer", + propertyType: "boolean", + operator: "eq", + value: true, + thenValue: true, + }, + ], + }, + ], + }; + const { result } = mount(abby, () => abby.useFeatureFlag("banner"), data); + expect(result.value).toBe(false); + abby.updateUserProperties({ customer: true }); + await nextTick(); + expect(result.value).toBe(true); + expect(document.cookie).not.toContain("__abby__ab__vue-tests"); +}); + +it("observes targeting updates from a child's mount hook", async () => { + const abby = configure(); + const data: AbbyDataResponse = { + ...initialData, + flags: [ + { + name: "banner", + value: false, + ruleSet: [ + { + propertyName: "customer", + propertyType: "boolean", + operator: "eq", + value: true, + thenValue: true, + }, + ], + }, + ], + }; + const child = defineComponent({ + setup() { + const flag = abby.useFeatureFlag("banner"); + onMounted(() => abby.updateUserProperties({ customer: true })); + return () => h("p", String(flag.value)); + }, + }); + const app = createApp({ + render: () => h(abby.AbbyProvider, { initialData: data }, () => h(child)), + }); + apps.push(app); + const element = document.createElement("div"); + app.mount(element); + await nextTick(); + expect(abby.getFeatureFlagValue("banner")).toBe(true); + expect(element.textContent).toBe("true"); +}); diff --git a/packages/vue/tests/devtools.test.ts b/packages/vue/tests/devtools.test.ts new file mode 100644 index 00000000..f6466cde --- /dev/null +++ b/packages/vue/tests/devtools.test.ts @@ -0,0 +1,58 @@ +import { createApp, createSSRApp, h } from "vue"; +import { renderToString } from "vue/server-renderer"; +import { createAbby } from "../src"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +it.each([ + { environment: "development", force: false, expected: 1 }, + { environment: "production", force: false, expected: 0 }, + { environment: "production", force: true, expected: 1 }, +])( + "mounts and disposes devtools in $environment (force: $force)", + ({ environment, force, expected }) => { + vi.stubEnv("NODE_ENV", environment); + const abby = createAbby({ + projectId: "tools", + environments: [], + currentEnvironment: "test", + }); + const destroy = vi.fn(); + const factory = { + create: vi.fn( + (_props: { abby: typeof abby.__abby__; defaultShow?: boolean }) => + destroy + ), + }; + const Devtools = abby.withDevtools(factory, { + defaultShow: true, + dangerouslyForceShow: force, + }); + const app = createApp({ render: () => h(Devtools) }); + app.mount(document.createElement("div")); + expect(factory.create).toHaveBeenCalledTimes(expected); + if (expected) + expect(factory.create).toHaveBeenCalledWith({ + abby: abby.__abby__, + defaultShow: true, + }); + app.unmount(); + expect(destroy).toHaveBeenCalledTimes(expected); + } +); + +it("never constructs devtools during SSR, even when forced", async () => { + const abby = createAbby({ + projectId: "tools", + environments: [], + currentEnvironment: "test", + }); + const factory = { + create: vi.fn((_props: { abby: typeof abby.__abby__ }) => vi.fn()), + }; + const Devtools = abby.withDevtools(factory, { dangerouslyForceShow: true }); + await renderToString(createSSRApp({ render: () => h(Devtools) })); + expect(factory.create).not.toHaveBeenCalled(); +}); diff --git a/packages/vue/tests/inference.types.ts b/packages/vue/tests/inference.types.ts new file mode 100644 index 00000000..bb6ca0dd --- /dev/null +++ b/packages/vue/tests/inference.types.ts @@ -0,0 +1,68 @@ +import { expectTypeOf } from "vitest"; +import type { ComputedRef } from "vue"; +import { createAbby } from "../src"; + +// Compiled by tsc, not executed as component setup. +function checkTypes() { + const abby = createAbby({ + projectId: "types", + environments: ["test"], + currentEnvironment: "test", + flags: ["banner", "checkout"], + tests: { checkout: { variants: ["old", "new"] } }, + remoteConfig: { title: "String", limit: "Number", options: "JSON" }, + user: { age: { type: "number" }, name: { type: "string", optional: true } }, + }); + expectTypeOf(abby.useFeatureFlag("banner")).toEqualTypeOf< + ComputedRef + >(); + expectTypeOf(abby.useRemoteConfig("title")).toEqualTypeOf< + ComputedRef + >(); + expectTypeOf(abby.useRemoteConfig("limit")).toEqualTypeOf< + ComputedRef + >(); + expectTypeOf(abby.useRemoteConfig("options")).toEqualTypeOf< + ComputedRef> + >(); + expectTypeOf(abby.useAbby("checkout").variant).toEqualTypeOf< + ComputedRef<"old" | "new" | undefined> + >(); + expectTypeOf( + abby.useAbby("checkout", { old: 1, new: 2 }).variant + ).toEqualTypeOf>(); + expectTypeOf( + abby.getABTestValue("checkout", { old: "A", new: "B" }) + ).toEqualTypeOf<"A" | "B">(); + expectTypeOf(abby.getRemoteConfig("limit")).toEqualTypeOf(); + expectTypeOf(abby.getVariants("checkout")).toEqualTypeOf< + readonly ["old", "new"] + >(); + abby.updateUserProperties({ age: 30, name: undefined }); + const devtools = { + create: + (_props: { + abby: typeof abby.__abby__; + position?: "top-left" | "bottom-right"; + }) => + () => {}, + }; + abby.withDevtools(devtools, { position: "top-left" }); + // @ts-expect-error devtools options are inferred from the supplied factory + abby.withDevtools(devtools, { position: "unknown" }); + // @ts-expect-error flag names come from configuration + abby.useFeatureFlag("unknown"); + // @ts-expect-error remote config names come from configuration + abby.useRemoteConfig("unknown"); + // @ts-expect-error test names come from configuration + abby.useAbby("unknown"); + // @ts-expect-error all variants must have lookup entries + abby.useAbby("checkout", { old: 1 }); + // @ts-expect-error returned refs are read-only + abby.useFeatureFlag("banner").value = true; + // @ts-expect-error targeting keeps validator types + abby.updateUserProperties({ age: "30" }); + // @ts-expect-error helpers retain configured keys + abby.getRemoteConfig("unknown"); +} +void checkTypes; diff --git a/packages/vue/tests/ssr.test.ts b/packages/vue/tests/ssr.test.ts new file mode 100644 index 00000000..4ce3a62e --- /dev/null +++ b/packages/vue/tests/ssr.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment node +import { type AbbyDataResponse, HttpService } from "@tryabby/core"; +import { createSSRApp, defineComponent, h } from "vue"; +import { renderToString } from "vue/server-renderer"; +import { createAbby } from "../src"; + +const config = { + projectId: "ssr", + currentEnvironment: "test", + environments: ["test"], + tests: { checkout: { variants: ["old", "new"] } }, + flags: ["banner"], + remoteConfig: { title: "String" as const }, +}; +afterEach(() => { + vi.restoreAllMocks(); +}); + +it("renders initial flags and config without browser APIs, subscriptions or tracking", async () => { + const abby = createAbby(config); + const subscribe = vi.spyOn(abby.__abby__, "subscribe"); + const load = vi.spyOn(abby.__abby__, "loadProjectData"); + const send = vi.spyOn(HttpService, "sendData"); + const child = defineComponent({ + setup() { + const flag = abby.useFeatureFlag("banner"); + const title = abby.useRemoteConfig("title"); + const test = abby.useAbby("checkout"); + test.onAct(); + return () => + h( + "div", + `${flag.value}|${title.value}|${test.variant.value ?? "pending"}` + ); + }, + }); + const initialData: AbbyDataResponse = { + flags: [{ name: "banner", value: true }], + tests: [{ name: "checkout", weights: [0, 1] }], + remoteConfig: [{ name: "title", value: "Server title" }], + }; + const html = await renderToString( + createSSRApp({ + render: () => h(abby.AbbyProvider, { initialData }, () => h(child)), + }) + ); + expect(html).toContain("true|Server title|pending"); + expect(subscribe).not.toHaveBeenCalled(); + expect(load).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); +}); + +it("isolates simultaneous requests even when they reuse a configuration object", async () => { + async function render(title: string) { + const abby = createAbby(config); + const child = defineComponent({ + async setup() { + const value = abby.useRemoteConfig("title"); + await Promise.resolve(); + return () => h("p", value.value); + }, + }); + const initialData: AbbyDataResponse = { + tests: [{ name: "checkout", weights: title === "A" ? [0, 1] : [1, 0] }], + flags: [], + remoteConfig: [{ name: "title", value: title }], + }; + return renderToString( + createSSRApp({ + render: () => h(abby.AbbyProvider, { initialData }, () => h(child)), + }) + ); + } + const [first, second] = await Promise.all([render("A"), render("B")]); + expect(first).toContain("

A

"); + expect(second).toContain("

B

"); + expect(config.tests.checkout).not.toHaveProperty("weights"); + expect(config.tests.checkout).not.toHaveProperty("selectedVariant"); +}); diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json new file mode 100644 index 00000000..fceb723f --- /dev/null +++ b/packages/vue/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "tsconfig/base.json", + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM"], + "types": ["vitest/globals"] + }, + "include": ["src", "tests", "vite.config.ts", "tsup.config.ts"] +} diff --git a/packages/vue/tsup.config.ts b/packages/vue/tsup.config.ts new file mode 100644 index 00000000..bf89044f --- /dev/null +++ b/packages/vue/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + dts: true, + clean: true, + format: ["cjs", "esm"], + sourcemap: true, + treeshake: true, +}); diff --git a/packages/vue/vite.config.ts b/packages/vue/vite.config.ts new file mode 100644 index 00000000..86ed19a1 --- /dev/null +++ b/packages/vue/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "jsdom", + exclude: ["**/node_modules/**", "**/dist/**", "**/*.types.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69fa896e..8ad36e63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -666,6 +666,10 @@ importers: version: 2.0.5(@types/node@20.3.1)(jsdom@20.0.3)(less@4.2.0)(sass@1.77.8)(terser@5.31.1) packages/core: + dependencies: + zod: + specifier: ^3.19.1 + version: 3.21.4 devDependencies: '@vitejs/plugin-react': specifier: 4.2.1 @@ -691,9 +695,6 @@ importers: vitest: specifier: 2.0.5 version: 2.0.5(@types/node@22.1.0)(jsdom@20.0.3)(less@4.2.0)(sass@1.77.8)(terser@5.31.1) - zod: - specifier: ^3.19.1 - version: 3.21.4 packages/devtools: dependencies: @@ -1108,6 +1109,40 @@ importers: packages/tsconfig: {} + packages/vue: + dependencies: + '@tryabby/core': + specifier: workspace:* + version: link:../core + js-cookie: + specifier: ^3.0.1 + version: 3.0.5 + devDependencies: + '@types/js-cookie': + specifier: ^3.0.3 + version: 3.0.3 + jsdom: + specifier: ^20.0.3 + version: 20.0.3 + tsconfig: + specifier: workspace:* + version: link:../tsconfig + tsup: + specifier: ^6.5.0 + version: 6.7.0(postcss@8.4.41)(ts-node@10.9.1(@types/node@22.1.0)(typescript@5.5.4))(typescript@5.5.4) + typescript: + specifier: 5.5.4 + version: 5.5.4 + vite: + specifier: 5.4.0 + version: 5.4.0(@types/node@22.1.0)(less@4.2.0)(sass@1.77.8)(terser@5.31.1) + vitest: + specifier: 2.0.5 + version: 2.0.5(@types/node@22.1.0)(jsdom@20.0.3)(less@4.2.0)(sass@1.77.8)(terser@5.31.1) + vue: + specifier: 3.5.12 + version: 3.5.12(typescript@5.5.4) + packages: '@adobe/css-tools@4.2.0': @@ -14389,6 +14424,88 @@ packages: zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: + integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw== + + '@vue/compiler-core@3.5.12': + resolution: + integrity: sha512-ISyBTRMmMYagUxhcpyEH0hpXRd/KqDU4ymofPgl2XAkY9ZhQ+h0ovEZJIiPop13UmR/54oA2cgMDjgroRelaEw== + + '@vue/compiler-dom@3.5.12': + resolution: + integrity: sha512-9G6PbJ03uwxLHKQ3P42cMTi85lDRvGLB2rSGOiQqtXELat6uI4n8cNz9yjfVHRPIu+MsK6TE418Giruvgptckg== + + '@vue/compiler-sfc@3.5.12': + resolution: + integrity: sha512-2k973OGo2JuAa5+ZlekuQJtitI5CgLMOwgl94BzMCsKZCX/xiqzJYzapl4opFogKHqwJk34vfsaKpfEhd1k5nw== + + '@vue/compiler-ssr@3.5.12': + resolution: + integrity: sha512-eLwc7v6bfGBSM7wZOGPmRavSWzNFF6+PdRhE+VFJhNCgHiF8AM7ccoqcv5kBXA2eWUfigD7byekvf/JsOfKvPA== + + '@vue/reactivity@3.5.12': + resolution: + integrity: sha512-UzaN3Da7xnJXdz4Okb/BGbAaomRHc3RdoWqTzlvd9+WBR5m3J39J1fGcHes7U3za0ruYn/iYy/a1euhMEHvTAg== + + '@vue/runtime-core@3.5.12': + resolution: + integrity: sha512-hrMUYV6tpocr3TL3Ad8DqxOdpDe4zuQY4HPY3X/VRh+L2myQO8MFXPAMarIOSGNu0bFAjh1yBkMPXZBqCk62Uw== + + '@vue/runtime-dom@3.5.12': + resolution: + integrity: sha512-q8VFxR9A2MRfBr6/55Q3umyoN7ya836FzRXajPB6/Vvuv0zOPL+qltd9rIMzG/DbRLAIlREmnLsplEF/kotXKA== + + '@vue/server-renderer@3.5.12': + resolution: + integrity: sha512-I3QoeDDeEPZm8yR28JtY+rk880Oqmj43hreIBVTicisFTx/Dl7JpG72g/X7YF8hnQD3IFhkky5i2bPonwrTVPg== + peerDependencies: + vue: 3.5.12 + + '@vue/shared@3.5.12': + resolution: + integrity: sha512-L2RPSAwUFbgZH20etwrXyVyCBu9OxRSi8T/38QsvnkJyvq2LufW2lDCOzm7t/U9C1mkhJGWYfCuFBCmIuNivrg== + + csstype@3.2.3: + resolution: + integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + + magic-string@0.30.21: + resolution: + integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + + nanoid@3.3.19: + resolution: + integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug== + engines: + node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 + hasBin: true + + picocolors@1.1.1: + resolution: + integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + + postcss@8.5.28: + resolution: + integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A== + engines: + node: ^10 || ^12 || >=14 + + source-map-js@1.2.1: + resolution: + integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + engines: + node: '>=0.10.0' + + vue@3.5.12: + resolution: + integrity: sha512-CLVZtXtn2ItBIi/zHZ0Sg1Xkb7+PU32bJJ8Bmy7ts3jxXTcbfsEfBivFYYWz1Hur+lalqGAh65Coin0r+HRUfg== + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + snapshots: '@adobe/css-tools@4.2.0': {} @@ -31399,3 +31516,87 @@ snapshots: react: 18.2.0 zwitch@2.0.4: {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@vue/compiler-core@3.5.12': + dependencies: + '@babel/parser': 7.25.3 + '@vue/shared': 3.5.12 + entities: 4.5.0 + estree-walker: 2.0.2 + source-map-js: 1.2.0 + + '@vue/compiler-dom@3.5.12': + dependencies: + '@vue/compiler-core': 3.5.12 + '@vue/shared': 3.5.12 + + '@vue/compiler-sfc@3.5.12': + dependencies: + '@babel/parser': 7.25.3 + '@vue/compiler-core': 3.5.12 + '@vue/compiler-dom': 3.5.12 + '@vue/compiler-ssr': 3.5.12 + '@vue/shared': 3.5.12 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.28 + source-map-js: 1.2.0 + + '@vue/compiler-ssr@3.5.12': + dependencies: + '@vue/compiler-dom': 3.5.12 + '@vue/shared': 3.5.12 + + '@vue/reactivity@3.5.12': + dependencies: + '@vue/shared': 3.5.12 + + '@vue/runtime-core@3.5.12': + dependencies: + '@vue/reactivity': 3.5.12 + '@vue/shared': 3.5.12 + + '@vue/runtime-dom@3.5.12': + dependencies: + '@vue/reactivity': 3.5.12 + '@vue/runtime-core': 3.5.12 + '@vue/shared': 3.5.12 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.12(vue@3.5.12(typescript@5.5.4))': + dependencies: + '@vue/compiler-ssr': 3.5.12 + '@vue/shared': 3.5.12 + vue: 3.5.12(typescript@5.5.4) + + '@vue/shared@3.5.12': {} + + csstype@3.2.3: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + nanoid@3.3.19: {} + + picocolors@1.1.1: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + source-map-js@1.2.1: {} + + vue@3.5.12(typescript@5.5.4): + dependencies: + '@vue/compiler-dom': 3.5.12 + '@vue/compiler-sfc': 3.5.12 + '@vue/runtime-dom': 3.5.12 + '@vue/server-renderer': 3.5.12(vue@3.5.12(typescript@5.5.4)) + '@vue/shared': 3.5.12 + optionalDependencies: + typescript: 5.5.4