diff --git a/.changeset/vue-integration.md b/.changeset/vue-integration.md new file mode 100644 index 00000000..feaa655c --- /dev/null +++ b/.changeset/vue-integration.md @@ -0,0 +1,5 @@ +--- +"@tryabby/vue": minor +--- + +feat(vue): add Vue integration diff --git a/packages/vue/.gitignore b/packages/vue/.gitignore new file mode 100644 index 00000000..1521c8b7 --- /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..bdc32e23 --- /dev/null +++ b/packages/vue/README.md @@ -0,0 +1,136 @@ +# @tryabby/vue + +Vue 3 integration for [Abby](https://docs.tryabby.com). + +## Installation + +```bash +pnpm add @tryabby/vue +``` + +## Usage + +```ts +// abby.ts +import { createAbby } from "@tryabby/vue"; + +export const { + AbbyProvider, + useAbby, + useFeatureFlag, + useRemoteConfig, + __abby__, +} = createAbby({ + projectId: "YOUR_PROJECT_ID", + currentEnvironment: "production", + environments: ["development", "production"], + tests: { + "signup-button": { + variants: ["control", "variant-a"], + }, + }, + flags: ["new-dashboard"], + remoteConfig: { + headline: "String", + }, +}); +``` + +Wrap your application in the `AbbyProvider` component: + +```vue + + + +``` + +The provider can also be passed data that was fetched on the server, which +avoids a request on the client and makes the values available during SSR: + +```vue + + + +``` + +## Composables + +### `useAbby` + +Returns the selected variant of an A/B test as a computed ref, plus an +`onAct` function that reports an action for the test. + +```vue + + + +``` + +Variant values are rendered as an empty string on the first render to avoid +SSR hydration mismatches. Once mounted, `variant.value` contains the +selected variant. + +### `useFeatureFlag` + +Returns the value of a feature flag as a computed ref. + +```vue + + + +``` + +### `useRemoteConfig` + +Returns the value of a remote config variable as a computed ref. The type of +the value is inferred from the remote config definition (`String`, `Number` +or `JSON`). + +```vue + + + +``` + +## Other exports + +`createAbby` also returns the following helpers: + +- `useFeatureFlags()` - a computed ref containing all flags with their values +- `useRemoteConfigVariables()` - a computed ref containing all remote config values +- `getFeatureFlagValue(name)` - read a flag outside of a component +- `getRemoteConfig(name)` - read a remote config value outside of a component +- `getABTestValue(name, lookup?)` - read a test variant outside of a component +- `getVariants(name)` - all variants of a test +- `getABResetFunction(name)` - reset the persisted variant of a test +- `updateUserProperties(user)` - update the user properties used for rules +- `__abby__` - the underlying `Abby` instance diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 00000000..cc13e95b --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,46 @@ +{ + "name": "@tryabby/vue", + "version": "0.0.0", + "description": "", + "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/", + "dev": "pnpm run build --watch", + "test": "vitest" + }, + "homepage": "https://docs.tryabby.com", + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@types/js-cookie": "^3.0.3", + "@vue/server-renderer": "3.4.24", + "@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.24" + }, + "peerDependencies": { + "vue": "^3.3.0" + }, + "dependencies": { + "@tryabby/core": "workspace:*", + "js-cookie": "^3.0.1" + } +} diff --git a/packages/vue/src/StorageService.ts b/packages/vue/src/StorageService.ts new file mode 100644 index 00000000..7633105e --- /dev/null +++ b/packages/vue/src/StorageService.ts @@ -0,0 +1,76 @@ +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/context.ts b/packages/vue/src/context.ts new file mode 100644 index 00000000..fa179290 --- /dev/null +++ b/packages/vue/src/context.ts @@ -0,0 +1,403 @@ +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 ComputedRef, + type InjectionKey, + type PropType, + type ShallowRef, + computed, + defineComponent, + inject, + onMounted, + onUnmounted, + provide, + ref, + shallowRef, + watch, +} from "vue"; +import { + FlagStorageService, + RemoteConfigStorageService, + TestStorageService, +} from "./StorageService"; + +export type ABTestReturnValue = Lookup extends undefined + ? TestVariant + : TestVariant extends keyof Lookup + ? Lookup[TestVariant] + : never; + +export function createAbby< + const FlagName extends string, + const TestName extends string, + const Tests extends Record, + const RemoteConfig extends Record, + const RemoteConfigName extends Extract, + const User extends Record = Record< + string, + ValidatorType + >, +>( + abbyConfig: AbbyConfig< + FlagName, + Tests, + string[], + RemoteConfigName, + RemoteConfig, + User + > +) { + // we need to return the config as a const so that the types are narrowed + const config = abbyConfig; + + 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); + }, + } + ); + + type AbbyProjectData = ReturnType; + + type AbbyContext = { + data: ShallowRef; + abby: typeof abby; + }; + + const AbbyContextKey: InjectionKey = Symbol("abby"); + + const useAbbyData = (): AbbyContext => { + const context = inject(AbbyContextKey, null); + + if (!context) { + throw new Error( + "useAbbyData must be used within an AbbyProvider. Wrap a parent component in to fix this error." + ); + } + + return context; + }; + + /** + * Composable to access the value of an A/B test. + * @param name the name of the test + * @param lookupObject an optional object to map the selected variant to a value + * @returns the selected variant (as a computed ref) and an `onAct` function + */ + const useAbby = < + K extends keyof Tests, + TestVariant extends Tests[K]["variants"][number], + LookupValue, + const Lookup extends + | Record + | undefined = undefined, + >( + name: K, + lookupObject?: Lookup + ): { + variant: ComputedRef>; + onAct: () => void; + } => { + const { data } = useAbbyData(); + + // always return an empty string on the first render to avoid SSR mismatches + // because the server doesn't know which variant to render + const isMounted = ref(false); + onMounted(() => { + isMounted.value = true; + }); + + const selectedVariant = computed(() => { + if (!isMounted.value) return ""; + return ( + data.value.tests[name as unknown as TestName]?.selectedVariant ?? "" + ); + }); + + watch( + selectedVariant, + (variant) => { + if (!variant) return; + + HttpService.sendData({ + url: config.apiUrl, + type: AbbyEventType.PING, + data: { + projectId: config.projectId, + selectedVariant: variant, + testName: name as string, + }, + }); + }, + { immediate: true } + ); + + const onAct = () => { + const variant = selectedVariant.value; + if (!variant) return; + + HttpService.sendData({ + url: config.apiUrl, + type: AbbyEventType.ACT, + data: { + projectId: config.projectId, + selectedVariant: variant, + testName: name as string, + }, + }); + }; + + const variant = computed>(() => { + const currentVariant = selectedVariant.value; + + return ( + lookupObject + ? lookupObject[currentVariant as TestVariant] + : currentVariant + ) as ABTestReturnValue; + }); + + return { + /** + * A computed ref containing the selected variant. + */ + variant, + /** + * This function can be called to indicate that something that + * uses the selected variant has been rendered. + * It will automatically send the selected variant to the server. + */ + onAct, + }; + }; + + /** + * Composable to access the value of a feature flag. + * @param name the name of the feature flag + * @returns a computed ref containing the value of the flag + */ + const useFeatureFlag = (name: FlagName): ComputedRef => { + const { data } = useAbbyData(); + return computed(() => data.value.flags[name]?.value ?? false); + }; + + /** + * Returns a computed ref of all flags with their name and value + */ + const useFeatureFlags = (): ComputedRef< + Array<{ name: FlagName; value: boolean }> + > => { + const { data } = useAbbyData(); + return computed(() => + (Object.keys(data.value.flags) as Array).map((flagName) => ({ + name: flagName, + value: data.value.flags[flagName].value, + })) + ); + }; + + /** + * Returns a computed ref of all remote config variables with their name and value + */ + const useRemoteConfigVariables = (): ComputedRef< + Array<{ + name: RemoteConfigName; + value: RemoteConfigValueStringToType; + }> + > => { + const { data } = useAbbyData(); + return computed(() => + (Object.keys(data.value.remoteConfig) as Array).map( + (configName) => ({ + name: configName, + value: data.value.remoteConfig[configName] + .value as RemoteConfigValueStringToType< + RemoteConfig[RemoteConfigName] + >, + }) + ) + ); + }; + + /** + * Composable to access the value of a remote config variable. + * @param remoteConfigName the name of the remote config variable + * @returns a computed ref containing the value of the remote config variable + */ + const useRemoteConfig = < + T extends RemoteConfigName, + Config extends RemoteConfig[T], + >( + remoteConfigName: T + ): ComputedRef> => { + const { data } = useAbbyData(); + return computed( + () => + data.value.remoteConfig[remoteConfigName] + .value as RemoteConfigValueStringToType + ); + }; + + const AbbyProvider = defineComponent({ + name: "AbbyProvider", + props: { + initialData: { + type: Object as PropType, + default: undefined, + }, + }, + setup(props, { slots }) { + const data = shallowRef( + props.initialData ? abby.init(props.initialData) : abby.getProjectData() + ); + + provide(AbbyContextKey, { data, abby }); + + let unsubscribe: (() => void) | undefined; + + onMounted(() => { + data.value = abby.getProjectData(); + unsubscribe = abby.subscribe((newData) => { + data.value = newData; + }); + + if (props.initialData) return; + + abby.loadProjectData().then((newData) => { + if (!newData) return; + data.value = newData; + }); + }); + + onUnmounted(() => { + unsubscribe?.(); + }); + + return () => slots.default?.() ?? null; + }, + }); + + 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, + TestVariant extends Tests[TestName]["variants"][number], + LookupValue, + const Lookup extends + | Record + | undefined = undefined, + >( + testName: TestName, + lookupObject?: Lookup + ): ABTestReturnValue => { + const variant = abby.getTestVariant(testName); + // Typescript loses its typing here, so we cast as any in favor of having + // better type inference for the user + if (lookupObject === undefined) { + return variant as any; + } + + return lookupObject[variant as TestVariant] as any; + }; + + /** + * Simple helper function to create a function that resets an AB test + * @param name the name of the test + * @returns A function that can be called to reset the test + */ + const getABResetFunction = (name: T) => { + return () => { + TestStorageService.remove(config.projectId, name as string); + }; + }; + + /** + * Simple helper function to get a list of all variants for a given test + * @param name the name of the test + * @returns an array of all variants + */ + const getVariants = (name: T) => { + return abby.getVariants(name); + }; + + const updateUserProperties = ( + user: Partial<{ + -readonly [K in keyof User]: Infer; + }> + ) => { + abby.updateUserProperties(user); + }; + + return { + useAbby, + AbbyProvider, + useFeatureFlag, + getFeatureFlagValue, + useRemoteConfig, + getRemoteConfig, + getABTestValue, + __abby__: abby, + getABResetFunction, + getVariants, + useFeatureFlags, + useRemoteConfigVariables, + updateUserProperties, + }; +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 00000000..9e38bc3a --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,5 @@ +export { + createAbby, + type ABTestReturnValue, +} from "./context"; +export { type ABConfig, type AbbyConfig, defineConfig } from "@tryabby/core"; diff --git a/packages/vue/tests/helpers.ts b/packages/vue/tests/helpers.ts new file mode 100644 index 00000000..90d0f766 --- /dev/null +++ b/packages/vue/tests/helpers.ts @@ -0,0 +1,39 @@ +import type { AbbyDataResponse } from "@tryabby/core"; +import { mount } from "@vue/test-utils"; +import { type Component, defineComponent } from "vue"; + +/** + * Mounts the given provider and calls the composable inside of its slot + * so that `provide` / `inject` and lifecycle hooks work as expected. + */ +export function renderComposable( + composable: () => T, + Provider: Component, + options: { initialData?: AbbyDataResponse } = {} +) { + let result!: T; + + const TestComponent = defineComponent({ + setup() { + result = composable(); + return () => null; + }, + }); + + const wrapper = mount(Provider, { + props: options.initialData ? { initialData: options.initialData } : {}, + slots: { + default: TestComponent, + }, + }); + + return { result, wrapper }; +} + +export function clearCookies() { + for (const cookie of document.cookie.split(";")) { + const name = cookie.split("=")[0]?.trim(); + if (!name) continue; + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; + } +} diff --git a/packages/vue/tests/mocks/handlers.ts b/packages/vue/tests/mocks/handlers.ts new file mode 100644 index 00000000..b1fde5e2 --- /dev/null +++ b/packages/vue/tests/mocks/handlers.ts @@ -0,0 +1,43 @@ +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..9bb8a870 --- /dev/null +++ b/packages/vue/tests/mocks/server.ts @@ -0,0 +1,5 @@ +import { setupServer } from "msw/node"; +import { handlers } from "./handlers"; + +// This configures a request mocking server with the given request 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..cb279bb9 --- /dev/null +++ b/packages/vue/tests/setup.ts @@ -0,0 +1,18 @@ +import fetch from "node-fetch"; +import { afterAll, afterEach, beforeAll } from "vitest"; +import { server } from "./mocks/server"; + +/// @ts-ignore node-fetch is not typed as a drop-in replacement +global.fetch = fetch; + +// Establish API mocking before all tests. +beforeAll(() => server.listen()); + +// Clean up after the tests are finished. +afterAll(() => server.close()); + +// Reset any request handlers that we may add during the tests, +// so they don't affect other tests. +afterEach(() => { + server.resetHandlers(); +}); diff --git a/packages/vue/tests/ssr.test.ts b/packages/vue/tests/ssr.test.ts new file mode 100644 index 00000000..5ea63659 --- /dev/null +++ b/packages/vue/tests/ssr.test.ts @@ -0,0 +1,128 @@ +import { renderToString } from "@vue/server-renderer"; +import { describe, expect, it } from "vitest"; +import { createSSRApp, defineComponent, h } from "vue"; +import { createAbby } from "../src"; + +describe("useAbby", () => { + it("doesn't render a variant on the server", async () => { + const { AbbyProvider, useAbby } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + tests: { + showFooter: { + variants: ["current", "new"], + }, + }, + }); + + const ComponentWithTest = defineComponent({ + setup() { + const { variant } = useAbby("showFooter"); + return () => + h("div", [ + // @ts-ignore the variant is an empty string before the component is mounted + variant.value === "" ? "SSR!" : "", + variant.value === "current" ? "Secret" : "", + variant.value === "new" ? "Very Secret" : "", + ]); + }, + }); + + const app = createSSRApp({ + render: () => + h(AbbyProvider, null, { + default: () => h(ComponentWithTest), + }), + }); + + const serverSideDOM = await renderToString(app); + + expect(serverSideDOM).toContain("SSR!"); + expect(serverSideDOM).not.toContain("Secret"); + expect(serverSideDOM).not.toContain("Very Secret"); + }); +}); + +describe("useFeatureFlag", () => { + it("renders the correct feature flag on the server", async () => { + const { AbbyProvider, useFeatureFlag } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + flags: ["test", "test2"], + }); + + const ComponentWithFF = defineComponent({ + setup() { + const test = useFeatureFlag("test"); + const test2 = useFeatureFlag("test2"); + return () => + h("div", [ + test.value ? "Secret" : "", + test2.value ? "SUPER SECRET" : "", + ]); + }, + }); + + const app = createSSRApp({ + render: () => + h( + AbbyProvider, + { + initialData: { + flags: [ + { value: true, name: "test" }, + { value: false, name: "test2" }, + ], + tests: [], + remoteConfig: [], + }, + }, + { default: () => h(ComponentWithFF) } + ), + }); + + const serverSideDOM = await renderToString(app); + + expect(serverSideDOM).toContain("Secret"); + expect(serverSideDOM).not.toContain("SUPER SECRET"); + }); +}); + +describe("useRemoteConfig", () => { + it("renders the correct remote config value on the server", async () => { + const { AbbyProvider, useRemoteConfig } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + remoteConfig: { remoteConfig1: "String" }, + }); + + const ComponentWithRC = defineComponent({ + setup() { + const remoteConfig1 = useRemoteConfig("remoteConfig1"); + return () => h("div", [remoteConfig1.value]); + }, + }); + + const app = createSSRApp({ + render: () => + h( + AbbyProvider, + { + initialData: { + flags: [], + tests: [], + remoteConfig: [{ name: "remoteConfig1", value: "FooBar" }], + }, + }, + { default: () => h(ComponentWithRC) } + ), + }); + + const serverSideDOM = await renderToString(app); + + expect(serverSideDOM).toContain("FooBar"); + }); +}); diff --git a/packages/vue/tests/types.test.ts b/packages/vue/tests/types.test.ts new file mode 100644 index 00000000..9e6afa0f --- /dev/null +++ b/packages/vue/tests/types.test.ts @@ -0,0 +1,127 @@ +import { describe, expectTypeOf, it } from "vitest"; +import { createAbby } from "../src"; +import { renderComposable } from "./helpers"; + +describe("useAbby", () => { + it("returns the correct types", () => { + 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, + }, + }, + }); + + expectTypeOf(useAbby).parameter(0).toEqualTypeOf<"test" | "test2">(); + + const { result: test1Result } = renderComposable( + () => useAbby("test"), + AbbyProvider + ); + + expectTypeOf(test1Result.variant.value).toEqualTypeOf<"ONLY_ONE_VARIANT">(); + + const { result: test2Result } = renderComposable( + () => useAbby("test2"), + AbbyProvider + ); + + expectTypeOf(test2Result.variant.value).toEqualTypeOf< + (typeof test2Variants)[number] + >(); + }); + + it("produces proper types with a lookup object", () => { + const { AbbyProvider, useAbby } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + tests: { + test: { + variants: ["A", "B", "C"], + }, + }, + }); + + const { result } = renderComposable( + () => + useAbby("test", { + A: "Hello", + B: "Bonjour", + C: "Hola", + }), + AbbyProvider + ); + + expectTypeOf(result.variant.value).toEqualTypeOf< + "Hello" | "Bonjour" | "Hola" + >(); + }); +}); + +describe("useFeatureFlag", () => { + it("returns the correct types", () => { + const { AbbyProvider, useFeatureFlag } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + flags: ["test"], + }); + + expectTypeOf(useFeatureFlag).parameter(0).toEqualTypeOf<"test">(); + + const { result } = renderComposable( + () => useFeatureFlag("test"), + AbbyProvider + ); + + expectTypeOf(result.value).toEqualTypeOf(); + }); +}); + +describe("useRemoteConfig", () => { + it("returns the correct types", () => { + const { AbbyProvider, useRemoteConfig } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + remoteConfig: { + stringRc: "String", + numberRc: "Number", + jsonRc: "JSON", + }, + }); + + expectTypeOf(useRemoteConfig) + .parameter(0) + .toEqualTypeOf<"stringRc" | "numberRc" | "jsonRc">(); + + const { result: stringRcResult } = renderComposable( + () => useRemoteConfig("stringRc"), + AbbyProvider + ); + const { result: numberRcResult } = renderComposable( + () => useRemoteConfig("numberRc"), + AbbyProvider + ); + const { result: jsonRcResult } = renderComposable( + () => useRemoteConfig("jsonRc"), + AbbyProvider + ); + + expectTypeOf(stringRcResult.value).toEqualTypeOf(); + expectTypeOf(numberRcResult.value).toEqualTypeOf(); + expectTypeOf(jsonRcResult.value).toEqualTypeOf>(); + }); +}); diff --git a/packages/vue/tests/useAbby.test.ts b/packages/vue/tests/useAbby.test.ts new file mode 100644 index 00000000..b35f82dc --- /dev/null +++ b/packages/vue/tests/useAbby.test.ts @@ -0,0 +1,387 @@ +import { AbbyEventType, HttpService } from "@tryabby/core"; +import { flushPromises } from "@vue/test-utils"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { nextTick } from "vue"; +import { createAbby } from "../src"; +import { TestStorageService } from "../src/StorageService"; +import { clearCookies, renderComposable } from "./helpers"; + +const OLD_ENV = process.env; + +async function settle() { + await flushPromises(); + await nextTick(); + await flushPromises(); +} + +beforeEach(() => { + clearCookies(); + vi.restoreAllMocks(); + process.env = { ...OLD_ENV }; +}); + +afterAll(() => { + process.env = OLD_ENV; +}); + +describe("useAbby", () => { + it("returns a valid variant and an onAct function", async () => { + const setSpy = vi.spyOn(TestStorageService, "set"); + + const { AbbyProvider, useAbby } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + tests: { + test: { variants: ["OldFooter", "NewFooter"] }, + }, + }); + + const { result } = renderComposable(() => useAbby("test"), AbbyProvider); + await settle(); + + expect(setSpy).toHaveBeenCalled(); + expect(["OldFooter", "NewFooter"]).toContain(result.variant.value); + expect(typeof result.onAct).toBe("function"); + }); + + it("uses the persisted value", async () => { + const persistedValue = "SimonsText"; + const getSpy = vi.spyOn(TestStorageService, "get"); + const setSpy = vi.spyOn(TestStorageService, "set"); + + getSpy.mockReturnValue(persistedValue); + + const { AbbyProvider, useAbby } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + tests: { + test: { variants: ["SimonsText", "MatthiasText"] }, + }, + }); + + const { result } = renderComposable(() => useAbby("test"), AbbyProvider); + await settle(); + + // the stored value should not be overwritten + expect(setSpy).not.toHaveBeenCalled(); + expect(getSpy).toHaveBeenCalled(); + expect(result.variant.value).toBe(persistedValue); + }); + + it("looks up the selected variant in the lookup object", async () => { + const persistedValue = "SimonsText"; + const variants = ["SimonsText", "MatthiasText"] as const; + + vi.spyOn(TestStorageService, "get").mockReturnValue(persistedValue); + + const { AbbyProvider, useAbby } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + tests: { + test: { variants }, + }, + }); + + const { result } = renderComposable( + () => + useAbby("test", { + SimonsText: "a", + MatthiasText: "b", + }), + AbbyProvider + ); + await settle(); + + expect(result.variant.value).toBe("a"); + }); + + it("pings the current variant on mount", async () => { + const spy = vi.spyOn(HttpService, "sendData"); + + const { AbbyProvider, useAbby } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + tests: { + test: { variants: ["A", "B", "C"] }, + }, + }); + + renderComposable(() => useAbby("test"), AbbyProvider); + await settle(); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ type: AbbyEventType.PING }) + ); + }); + + it("notifies 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"] }, + }, + }); + + const { result } = renderComposable(() => useAbby("test"), AbbyProvider); + await settle(); + + result.onAct(); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ type: AbbyEventType.ACT }) + ); + }); + + it("returns the variants of a test", () => { + const { getVariants } = createAbby({ + environments: [], + projectId: "123", + currentEnvironment: "a", + tests: { + test: { variants: ["A", "B", "C"] }, + }, + }); + + expect(getVariants("test")).toEqual(["A", "B", "C"]); + }); + + it("uses the lookup object when retrieving a variant synchronously", () => { + 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] + ); + }); +}); + +describe("useFeatureFlag", () => { + it("returns the fetched feature flag value", async () => { + const { AbbyProvider, useFeatureFlag } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + flags: ["flag1", "flag2"], + }); + + const { result: flag1 } = renderComposable( + () => useFeatureFlag("flag1"), + AbbyProvider + ); + const { result: flag2 } = renderComposable( + () => useFeatureFlag("flag2"), + AbbyProvider + ); + + expect(flag1.value).toBe(false); + expect(flag2.value).toBe(false); + + await settle(); + + expect(flag1.value).toBe(true); + expect(flag2.value).toBe(false); + }); + + it("falls back to false for flags that are not returned by the server", async () => { + const { AbbyProvider, useFeatureFlag } = createAbby({ + environments: [], + projectId: "123", + flags: ["unknownFlag"], + currentEnvironment: "a", + }); + + const { result } = renderComposable( + () => useFeatureFlag("unknownFlag"), + AbbyProvider + ); + + expect(result.value).toBe(false); + await settle(); + expect(result.value).toBe(false); + }); + + it("uses the devOverrides", async () => { + process.env.NODE_ENV = "development"; + + const { AbbyProvider, useFeatureFlag } = createAbby({ + environments: [], + projectId: "123", + flags: ["flag1", "flag2"], + currentEnvironment: "a", + settings: { + flags: { + devOverrides: { + flag1: false, + flag2: true, + }, + }, + }, + }); + + const { result: flag1 } = renderComposable( + () => useFeatureFlag("flag1"), + AbbyProvider + ); + const { result: flag2 } = renderComposable( + () => useFeatureFlag("flag2"), + AbbyProvider + ); + + expect(flag1.value).toBe(false); + expect(flag2.value).toBe(true); + + await settle(); + + expect(flag1.value).toBe(false); + expect(flag2.value).toBe(true); + }); + + it("gets the stored feature flag value with a function", async () => { + const { getFeatureFlagValue, __abby__ } = createAbby({ + environments: [], + projectId: "123", + flags: ["flag1", "flag2"], + currentEnvironment: "a", + }); + + await __abby__.loadProjectData(); + + expect(getFeatureFlagValue("flag1")).toBe(true); + expect(getFeatureFlagValue("flag2")).toBe(false); + }); + + it("returns the list of feature flags", async () => { + const { AbbyProvider, useFeatureFlags } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + flags: ["flag1", "flag2"], + }); + + const { result } = renderComposable(() => useFeatureFlags(), AbbyProvider); + expect(result.value).toHaveLength(2); + + await settle(); + + expect(result.value).toEqual([ + { name: "flag1", value: true }, + { name: "flag2", value: false }, + ]); + }); +}); + +describe("useRemoteConfig", () => { + it("returns the fetched remote config value", async () => { + const { AbbyProvider, useRemoteConfig } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + remoteConfig: { + remoteConfig1: "String", + }, + }); + + const { result } = renderComposable( + () => useRemoteConfig("remoteConfig1"), + AbbyProvider + ); + + await settle(); + + expect(result.value).toBe("FooBar"); + }); + + it("uses the default value when remote config is not set", async () => { + const { AbbyProvider, useRemoteConfig } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + remoteConfig: { + unsetRemoteConfig: "String", + }, + settings: { + remoteConfig: { + defaultValues: { + String: "defaultValue", + }, + }, + }, + }); + + const { result } = renderComposable( + () => useRemoteConfig("unsetRemoteConfig"), + AbbyProvider + ); + + expect(result.value).toBe("defaultValue"); + }); + + it("uses the devOverride", () => { + process.env.NODE_ENV = "development"; + + const { AbbyProvider, useRemoteConfig } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + remoteConfig: { + remoteConfig1: "String", + }, + settings: { + remoteConfig: { + devOverrides: { + remoteConfig1: "overwrittenValue", + }, + }, + }, + }); + + const { result } = renderComposable( + () => useRemoteConfig("remoteConfig1"), + AbbyProvider + ); + + expect(result.value).toBe("overwrittenValue"); + }); + + it("returns the list of remote config variables", async () => { + const { AbbyProvider, useRemoteConfigVariables } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + remoteConfig: { + remoteConfig1: "String", + }, + }); + + const { result } = renderComposable( + () => useRemoteConfigVariables(), + AbbyProvider + ); + expect(result.value).toHaveLength(1); + + await settle(); + + expect(result.value).toEqual([{ name: "remoteConfig1", value: "FooBar" }]); + }); +}); diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json new file mode 100644 index 00000000..9eb7b119 --- /dev/null +++ b/packages/vue/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "tsconfig/base.json", + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "target": "es2020", + "jsx": "preserve", + "types": ["vitest/globals"] + } +} diff --git a/packages/vue/tsup.config.ts b/packages/vue/tsup.config.ts new file mode 100644 index 00000000..ef269755 --- /dev/null +++ b/packages/vue/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + dts: true, + clean: true, + external: ["vue"], + 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..0d431636 --- /dev/null +++ b/packages/vue/vite.config.ts @@ -0,0 +1,11 @@ +/// + +import { defineConfig } from "vite"; + +export default defineConfig({ + test: { + globals: true, + environment: "jsdom", + setupFiles: "./tests/setup.ts", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69fa896e..9dd81aeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1108,6 +1108,52 @@ 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 + '@vue/server-renderer': + specifier: 3.4.24 + version: 3.4.24(vue@3.4.24(typescript@5.5.4)) + '@vue/test-utils': + specifier: 2.4.6 + version: 2.4.6 + jsdom: + specifier: ^20.0.3 + version: 20.0.3 + msw: + specifier: ^0.49.1 + version: 0.49.3(encoding@0.1.13)(typescript@5.5.4) + node-fetch: + specifier: ^3.3.0 + version: 3.3.2 + 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.4.24 + version: 3.4.24(typescript@5.5.4) + packages: '@adobe/css-tools@4.2.0': @@ -14389,6 +14435,56 @@ packages: zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + '@one-ini/wasm@0.1.1': + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + + '@vue/compiler-sfc@3.4.24': + resolution: {integrity: sha512-nRAlJUK02FTWfA2nuvNBAqsDZuERGFgxZ8sGH62XgFSvMxO2URblzulExsmj4gFZ8e+VAyDooU9oAoXfEDNxTA==} + + '@vue/compiler-ssr@3.4.24': + resolution: {integrity: sha512-ZsAtr4fhaUFnVcDqwW3bYCSDwq+9Gk69q2r/7dAHDrOMw41kylaMgOP4zRnn6GIEJkQznKgrMOGPMFnLB52RbQ==} + + '@vue/reactivity@3.4.24': + resolution: {integrity: sha512-nup3fSYg4i4LtNvu9slF/HF/0dkMQYfepUdORBcMSsankzRPzE7ypAFurpwyRBfU1i7Dn1kcwpYsE1wETSh91g==} + + '@vue/runtime-core@3.4.24': + resolution: {integrity: sha512-c7iMfj6cJMeAG3s5yOn9Rc5D9e2/wIuaozmGf/ICGCY3KV5H7mbTVdvEkd4ZshTq7RUZqj2k7LMJWVx+EBiY1g==} + + '@vue/runtime-dom@3.4.24': + resolution: {integrity: sha512-uXKzuh/Emfad2Y7Qm0ABsLZZV6H3mAJ5ZVqmAOlrNQRf+T5mxpPGZBfec1hkP41t6h6FwF6RSGCs/gd8WbuySQ==} + + '@vue/server-renderer@3.4.24': + resolution: {integrity: sha512-H+DLK4sQF6sRgzKyofmlEVBIV/9KrQU6HIV7nt6yIwSGGKvSwlV8pqJlebUKLpbXaNHugdSfAbP6YmXF69lxow==} + peerDependencies: + vue: 3.4.24 + + '@vue/test-utils@2.4.6': + resolution: {integrity: sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + editorconfig@1.0.7: + resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==} + engines: {node: '>=14'} + hasBin: true + + js-beautify@1.15.4: + resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} + engines: {node: '>=14'} + hasBin: true + + vue-component-type-helpers@2.2.12: + resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==} + + vue@3.4.24: + resolution: {integrity: sha512-NPdx7dLGyHmKHGRRU5bMRYVE+rechR+KDU5R2tSTNG36PuMwbfAJ+amEvOAw7BPfZp5sQulNELSLm5YUkau+Sg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + snapshots: '@adobe/css-tools@4.2.0': {} @@ -31399,3 +31495,200 @@ snapshots: react: 18.2.0 zwitch@2.0.4: {} + + '@angular-devkit/build-webpack@0.1801.3(chokidar@3.6.0)(webpack-dev-server@5.0.4(webpack@5.92.1))(webpack@5.92.1)': + dependencies: + '@angular-devkit/architect': 0.1801.3(chokidar@3.6.0) + rxjs: 7.8.1 + webpack: 5.92.1(esbuild@0.21.5) + webpack-dev-server: 5.0.4(webpack@5.92.1) + transitivePeerDependencies: + - chokidar + + '@ngtools/webpack@18.1.3(@angular/compiler-cli@18.1.3(@angular/compiler@18.1.3(@angular/core@18.1.3(rxjs@7.8.1)(zone.js@0.12.0)))(typescript@5.5.4))(typescript@5.5.4)(webpack@5.92.1)': + dependencies: + '@angular/compiler-cli': 18.1.3(@angular/compiler@18.1.3(@angular/core@18.1.3(rxjs@7.8.1)(zone.js@0.12.0)))(typescript@5.5.4) + typescript: 5.5.4 + webpack: 5.92.1(esbuild@0.21.5) + + '@one-ini/wasm@0.1.1': {} + + '@vue/compiler-sfc@3.4.24': + dependencies: + '@babel/parser': 7.25.3 + '@vue/compiler-core': 3.4.24 + '@vue/compiler-dom': 3.4.24 + '@vue/compiler-ssr': 3.4.24 + '@vue/shared': 3.4.24 + estree-walker: 2.0.2 + magic-string: 0.30.10 + postcss: 8.4.41 + source-map-js: 1.2.0 + + '@vue/compiler-ssr@3.4.24': + dependencies: + '@vue/compiler-dom': 3.4.24 + '@vue/shared': 3.4.24 + + '@vue/reactivity@3.4.24': + dependencies: + '@vue/shared': 3.4.24 + + '@vue/runtime-core@3.4.24': + dependencies: + '@vue/reactivity': 3.4.24 + '@vue/shared': 3.4.24 + + '@vue/runtime-dom@3.4.24': + dependencies: + '@vue/runtime-core': 3.4.24 + '@vue/shared': 3.4.24 + csstype: 3.2.3 + + '@vue/server-renderer@3.4.24(vue@3.4.24(typescript@5.5.4))': + dependencies: + '@vue/compiler-ssr': 3.4.24 + '@vue/shared': 3.4.24 + vue: 3.4.24(typescript@5.5.4) + + '@vue/test-utils@2.4.6': + dependencies: + js-beautify: 1.15.4 + vue-component-type-helpers: 2.2.12 + + babel-loader@9.1.3(@babel/core@7.24.7)(webpack@5.92.1): + dependencies: + '@babel/core': 7.24.7 + find-cache-dir: 4.0.0 + schema-utils: 4.1.0 + webpack: 5.92.1(esbuild@0.21.5) + + copy-webpack-plugin@12.0.2(webpack@5.92.1): + dependencies: + fast-glob: 3.3.2 + glob-parent: 6.0.2 + globby: 14.0.2 + normalize-path: 3.0.0 + schema-utils: 4.2.0 + serialize-javascript: 6.0.2 + webpack: 5.92.1(esbuild@0.21.5) + + css-loader@7.1.2(webpack@5.92.1): + dependencies: + icss-utils: 5.1.0(postcss@8.4.41) + postcss: 8.4.41 + postcss-modules-extract-imports: 3.1.0(postcss@8.4.41) + postcss-modules-local-by-default: 4.0.5(postcss@8.4.41) + postcss-modules-scope: 3.2.0(postcss@8.4.41) + postcss-modules-values: 4.0.0(postcss@8.4.41) + postcss-value-parser: 4.2.0 + semver: 7.6.2 + optionalDependencies: + webpack: 5.92.1(esbuild@0.21.5) + + csstype@3.2.3: {} + + editorconfig@1.0.7: + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.4 + semver: 7.6.2 + + icss-utils@5.1.0(postcss@8.4.41): + dependencies: + postcss: 8.4.41 + + js-beautify@1.15.4: + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.7 + glob: 10.4.5 + js-cookie: 3.0.5 + nopt: 7.2.1 + + less-loader@12.2.0(less@4.2.0)(webpack@5.92.1): + dependencies: + less: 4.2.0 + optionalDependencies: + webpack: 5.92.1(esbuild@0.21.5) + + license-webpack-plugin@4.0.2(webpack@5.92.1): + dependencies: + webpack-sources: 3.2.3 + optionalDependencies: + webpack: 5.92.1(esbuild@0.21.5) + + mini-css-extract-plugin@2.9.0(webpack@5.92.1): + dependencies: + schema-utils: 4.1.0 + tapable: 2.2.1 + webpack: 5.92.1(esbuild@0.21.5) + + next-themes@0.2.1(next@14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8))(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + next: 14.1.1(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.8) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + postcss-loader@8.1.1(postcss@8.4.38)(typescript@5.5.4)(webpack@5.92.1): + dependencies: + cosmiconfig: 9.0.0(typescript@5.5.4) + jiti: 1.21.6 + postcss: 8.4.38 + semver: 7.6.2 + optionalDependencies: + webpack: 5.92.1(esbuild@0.21.5) + transitivePeerDependencies: + - typescript + + postcss-modules-extract-imports@3.1.0(postcss@8.4.41): + dependencies: + postcss: 8.4.41 + + postcss-modules-local-by-default@4.0.5(postcss@8.4.41): + dependencies: + icss-utils: 5.1.0(postcss@8.4.41) + postcss: 8.4.41 + postcss-selector-parser: 6.0.13 + postcss-value-parser: 4.2.0 + + postcss-modules-scope@3.2.0(postcss@8.4.41): + dependencies: + postcss: 8.4.41 + postcss-selector-parser: 6.0.13 + + postcss-modules-values@4.0.0(postcss@8.4.41): + dependencies: + icss-utils: 5.1.0(postcss@8.4.41) + postcss: 8.4.41 + + sass-loader@14.2.1(sass@1.77.6)(webpack@5.92.1): + dependencies: + neo-async: 2.6.2 + optionalDependencies: + sass: 1.77.6 + webpack: 5.92.1(esbuild@0.21.5) + + source-map-loader@5.0.0(webpack@5.92.1): + dependencies: + iconv-lite: 0.6.3 + source-map-js: 1.2.0 + webpack: 5.92.1(esbuild@0.21.5) + + vue-component-type-helpers@2.2.12: {} + + vue@3.4.24(typescript@5.5.4): + dependencies: + '@vue/compiler-dom': 3.4.24 + '@vue/compiler-sfc': 3.4.24 + '@vue/runtime-dom': 3.4.24 + '@vue/server-renderer': 3.4.24(vue@3.4.24(typescript@5.5.4)) + '@vue/shared': 3.4.24 + optionalDependencies: + typescript: 5.5.4 + + webpack-subresource-integrity@5.1.0(webpack@5.92.1): + dependencies: + typed-assert: 1.0.9 + webpack: 5.92.1(esbuild@0.21.5)