From 98955a548860524d4fe4b8d8a172f6a7e99948d3 Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sat, 24 Oct 2026 14:42:37 +0200 Subject: [PATCH 1/2] Add typed Vue integration with SSR-safe providers and reactive composables --- .changeset/vue-integration.md | 5 + apps/docs/pages/integrations/_meta.json | 1 + apps/docs/pages/integrations/vue.mdx | 46 +++ packages/vue/.gitignore | 1 + packages/vue/README.md | 144 +++++++ packages/vue/demo/.gitignore | 1 + packages/vue/demo/index.html | 5 + packages/vue/demo/main.ts | 154 ++++++++ packages/vue/demo/serve.mjs | 72 ++++ packages/vue/demo/style.css | 24 ++ packages/vue/demo/vite.config.ts | 10 + packages/vue/package.json | 43 +++ packages/vue/src/index.ts | 475 ++++++++++++++++++++++++ packages/vue/tests/consumer.ts | 25 ++ packages/vue/tests/fixtures.ts | 39 ++ packages/vue/tests/package.test.ts | 27 ++ packages/vue/tests/runtime.test.ts | 456 +++++++++++++++++++++++ packages/vue/tests/ssr.test.ts | 59 +++ packages/vue/tests/types.ts | 46 +++ packages/vue/tsconfig.json | 12 + packages/vue/tsconfig.test.json | 5 + packages/vue/tsup.config.ts | 9 + packages/vue/vite.config.ts | 10 + pnpm-lock.yaml | 335 +++++++++++++++++ 24 files changed, 2004 insertions(+) create mode 100644 .changeset/vue-integration.md create mode 100644 apps/docs/pages/integrations/vue.mdx create mode 100644 packages/vue/.gitignore create mode 100644 packages/vue/README.md create mode 100644 packages/vue/demo/.gitignore create mode 100644 packages/vue/demo/index.html create mode 100644 packages/vue/demo/main.ts create mode 100644 packages/vue/demo/serve.mjs create mode 100644 packages/vue/demo/style.css create mode 100644 packages/vue/demo/vite.config.ts create mode 100644 packages/vue/package.json create mode 100644 packages/vue/src/index.ts create mode 100644 packages/vue/tests/consumer.ts create mode 100644 packages/vue/tests/fixtures.ts create mode 100644 packages/vue/tests/package.test.ts create mode 100644 packages/vue/tests/runtime.test.ts create mode 100644 packages/vue/tests/ssr.test.ts create mode 100644 packages/vue/tests/types.ts create mode 100644 packages/vue/tsconfig.json create mode 100644 packages/vue/tsconfig.test.json create mode 100644 packages/vue/tsup.config.ts create mode 100644 packages/vue/vite.config.ts diff --git a/.changeset/vue-integration.md b/.changeset/vue-integration.md new file mode 100644 index 00000000..1c4301a2 --- /dev/null +++ b/.changeset/vue-integration.md @@ -0,0 +1,5 @@ +--- +"@tryabby/vue": minor +--- + +Add the Vue integration with typed reactive composables, a request-local provider, React-parity helpers, cookie consent controls, devtools support and Nuxt usage guidance. diff --git a/apps/docs/pages/integrations/_meta.json b/apps/docs/pages/integrations/_meta.json index 5cbf2999..aa3900c1 100644 --- a/apps/docs/pages/integrations/_meta.json +++ b/apps/docs/pages/integrations/_meta.json @@ -1,6 +1,7 @@ { "nextjs": "Next.js", "react": "React", + "vue": "Vue", "remix": "Remix", "svelte": "Svelte", "angular": "Angular", diff --git a/apps/docs/pages/integrations/vue.mdx b/apps/docs/pages/integrations/vue.mdx new file mode 100644 index 00000000..c319c757 --- /dev/null +++ b/apps/docs/pages/integrations/vue.mdx @@ -0,0 +1,46 @@ +# Vue + +Use `@tryabby/vue` with Vue 3.3 or later. It builds on Abby core and provides typed, reactive A/B tests, feature flags and remote config. + +```sh +pnpm add @tryabby/vue +``` + +```ts +// abby.ts (client-only app) +import { createAbby } from "@tryabby/vue"; + +export const abby = createAbby({ + projectId: "YOUR_PROJECT_ID", + environments: ["production"], + currentEnvironment: "production", + tests: { checkout: { variants: ["original", "fresh"] } }, + flags: ["premium"], + remoteConfig: { headline: "String" }, +}); +``` + +Wrap the app with `abby.AbbyProvider`, then call the composables in a descendant's setup function: + +```vue + + + +``` + +Values are readonly computed refs. Vue unwraps them in templates; use `.value` in TypeScript. A/B variants are pending (`undefined`) through SSR and initial hydration, then resolve after mount. Names, variant unions, complete lookup objects and remote config return types are inferred from your configuration. + +The provider fetches once on mount unless `initialData` was supplied. It accepts an SSR cookie header through `cookies` and typed targeting properties through `userProperties`. Direct getters, list composables, reset helpers, consent controls and the existing devtools are also supported. + +For SSR, create a fresh SDK instance for each request and pass matching initial data to hydration. A Nuxt plugin can create this instance and expose it as `$abby`; do not export a shared SDK singleton from a server module. + +See the [Vue package guide](https://github.com/tryabby/abby/tree/main/packages/vue#readme) for the complete API, provider setup, cookie behavior, devtools and a Nuxt plugin example. 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..e95a7bac --- /dev/null +++ b/packages/vue/README.md @@ -0,0 +1,144 @@ +# @tryabby/vue + +Typed Vue 3 composables for Abby A/B tests, feature flags and remote configuration. + +```sh +pnpm add @tryabby/vue +``` + +Create an instance with the same configuration used by the other Abby integrations: + +```ts +// abby.ts — a client-only app can export this instance. +import { createAbby } from "@tryabby/vue"; + +export const abby = createAbby({ + projectId: "YOUR_PROJECT_ID", + environments: ["production"], + currentEnvironment: "production", + tests: { checkout: { variants: ["original", "fresh"] } }, + flags: ["premium"], + remoteConfig: { headline: "String", price: "Number", theme: "JSON" }, + user: { role: { type: "string" } }, +}); +``` + +Wrap the application in that instance's provider: + +```vue + + + +``` + +Consume reactive values inside a descendant's `setup()`: + +```vue + + + +``` + +Composables return readonly computed refs: use `.value` in TypeScript; Vue unwraps them in templates. Names accept a string, ref or getter. Variant values remain `undefined` during SSR and initial hydration, then resolve after the provider mounts and loads data. A view event is sent when the selected test/variant changes; `onAct()` sends a conversion for the current selection. Both use core's telemetry behavior, including its localhost suppression. + +`useAbby("checkout", { original: "Buy", fresh: "Try now" })` maps every variant to a typed value. Missing lookup keys and unknown names are TypeScript errors. + +## API + +| Member | Result / behavior | +| --- | --- | +| `useAbby(name, lookup?)` | `{ variant: ComputedRef, onAct }` | +| `useFeatureFlag(name)` | `ComputedRef` | +| `useRemoteConfig(name)` | A computed string, number or JSON object, inferred from config | +| `useFeatureFlags()` / `useRemoteConfigVariables()` | Computed arrays of `{ name, value }` | +| `getFeatureFlagValue(name)` / `getRemoteConfig(name)` | Nonreactive values outside component setup | +| `getABTestValue(name, lookup?)` | Current variant or mapped value; retains the assignment within this instance | +| `getVariants(name)` | The configured readonly variant tuple | +| `getABResetFunction(name)` | Removes the saved assignment; takes effect on the next page load | +| `updateUserProperties(properties)` | Validates a partial user update and refreshes targeting | +| `enableCookies()` / `disableCookies()` | Changes assignment consent; revocation removes assignment cookies | +| `loadProjectData()` | Explicit refresh, with concurrent fetches shared | +| `__abby__` | The underlying core instance, also used by devtools | +| `withDevtools(factory, props)` | Vue component that creates and destroys the existing devtools | + +The provider accepts `initialData?: AbbyDataResponse`, `cookies?: string` (an SSR request's cookie header), and typed `userProperties`. With initial data it makes no initial network request. Otherwise it fetches centrally on mount. Configured defaults/fallbacks cover failed requests and absent flags/config variables. Updates to `initialData` or `userProperties` propagate to descendants. Provider teardown removes its subscription; unmounted consumers stop emitting events. + +Cookie names and expiry follow core's existing conventions. Assignments stay stable in memory if the browser cannot persist cookies. Set `cookies: { disableByDefault: true }` to start without assignment cookies, then call `enableCookies()` after consent. This setting governs storage; it does not disable core's event transport. `disableCookies()` records the consent preference and removes assignment cookies. Flag/config overrides keep core semantics. + +## SSR and Nuxt + +**Create a fresh instance per app and per server request.** Do not share a mutable SDK instance in a server module. Reusing one instance across SSR apps raises an error instead of sharing user state. The input config can be shared; each instance copies the mutable test/consent configuration. + +Pass the same fetched `initialData` to server rendering and hydration. Feature flags and remote config can render on the server; A/B content remains pending until client mount, avoiding a randomly selected hydration mismatch. Pass the request cookie header only to the server's provider when using existing assignments/overrides. Do not serialize an entire request cookie header into public HTML. + +A Nuxt plugin is a suitable place to create the request-local instance: + +For the typed server fetch below, also install core as a direct dependency: `pnpm add @tryabby/core`. + +```ts +// plugins/abby.ts +import { createAbby } from "@tryabby/vue"; +import config from "~/abby.config"; + +export default defineNuxtPlugin(() => ({ + provide: { abby: createAbby(config) }, +})); +``` + +```vue + + + + +``` + +Descendants call `useNuxtApp().$abby.useFeatureFlag(...)`, and so on. This is a Nuxt usage recipe, not an auto-installed Nuxt module. + +## Devtools + +```ts +import Devtools from "@tryabby/devtools"; +const AbbyDevtools = abby.withDevtools(Devtools, { defaultShow: true }); +``` + +Render `` once. It initializes on mount in development and cleans up on unmount. `dangerouslyForceShow: true` explicitly enables it in other environments; do not expose it to production users by accident. The devtools package is optional and is not bundled with this SDK. + +## Development and local demo + +From the repository root, build core and this package before testing: + +```sh +pnpm --filter @tryabby/core build +pnpm --filter @tryabby/vue build +pnpm --filter @tryabby/vue test +pnpm --filter @tryabby/vue demo:build +node packages/vue/demo/serve.mjs +``` + +Open `http://127.0.0.1:8766`. The demo uses the built package with a labelled local fixture, and shows PING/ACT events received by its local server. It does not contact the hosted Abby backend. Use “Target admin user” before manually toggling the flag, since a manual override takes precedence over targeting. diff --git a/packages/vue/demo/.gitignore b/packages/vue/demo/.gitignore new file mode 100644 index 00000000..567609b1 --- /dev/null +++ b/packages/vue/demo/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/packages/vue/demo/index.html b/packages/vue/demo/index.html new file mode 100644 index 00000000..8e0ef12a --- /dev/null +++ b/packages/vue/demo/index.html @@ -0,0 +1,5 @@ + + + Abby Vue integration demo +
+ diff --git a/packages/vue/demo/main.ts b/packages/vue/demo/main.ts new file mode 100644 index 00000000..9daceb02 --- /dev/null +++ b/packages/vue/demo/main.ts @@ -0,0 +1,154 @@ +import type { AbbyDataResponse } from "@tryabby/core"; +import { createAbby } from "@tryabby/vue"; +import { + createApp, + defineComponent, + h, + onMounted, + onUnmounted, + ref, +} from "vue"; +import "./style.css"; + +const fixture: AbbyDataResponse = { + tests: [{ name: "checkout", weights: [1, 0] }], + flags: [ + { + name: "premium", + value: false, + ruleSet: [ + { + propertyName: "role", + propertyType: "string", + operator: "eq", + value: "admin", + thenValue: true, + }, + ], + }, + ], + remoteConfig: [ + { name: "headline", value: "A small change. A clear result." }, + ], +}; +const sdk = createAbby({ + projectId: "vue-local-demo", + currentEnvironment: "demo", + environments: ["demo"], + apiUrl: "/", + tests: { checkout: { variants: ["original", "fresh"] } }, + flags: ["premium"], + remoteConfig: { headline: "String" }, + user: { role: { type: "string" } }, +}); +const Demo = defineComponent({ + setup() { + const { variant, onAct } = sdk.useAbby("checkout"); + const premium = sdk.useFeatureFlag("premium"); + const headline = sdk.useRemoteConfig("headline"); + const views = ref(0); + const acts = ref(0); + const targeting = ref("visitor"); + const consent = ref(!sdk.__abby__.getConfig().cookies?.disableByDefault); + let timer: ReturnType; + onMounted(() => { + const read = async () => { + const events = (await fetch("/events").then((response) => + response.json() + )) as { type: number }[]; + views.value = events.filter((event) => event.type === 0).length; + acts.value = events.filter((event) => event.type === 1).length; + }; + void read(); + timer = setInterval(() => { + void read(); + }, 300); + }); + onUnmounted(() => clearInterval(timer)); + const button = (text: string, action: () => void) => + h("button", { type: "button", onClick: action }, text); + return () => + h("main", [ + h("p", { class: "eyebrow" }, "ABBY / VUE 3"), + h("h1", "Vue integration, live."), + h( + "p", + { class: "intro" }, + "A/B testing, feature flags and remote config — reacting together." + ), + h( + "p", + { class: "fixture" }, + "Local deterministic fixture · actual built SDK · no hosted Abby connection" + ), + h("section", { class: "preview", "aria-label": "Live preview" }, [ + h("div", { class: "topline" }, [ + h("span", "CHECKOUT PREVIEW"), + h( + "span", + { class: "pill" }, + `Variant: ${variant.value ?? "pending"}` + ), + ]), + h("h2", headline.value), + h( + "p", + `Premium feature: ${premium.value ? "enabled" : "disabled"} · User role: ${targeting.value}` + ), + h( + "button", + { type: "button", class: `cta ${variant.value}`, onClick: onAct }, + "Record conversion" + ), + ]), + h("section", { class: "controls", "aria-label": "SDK controls" }, [ + button("Switch variant", () => + sdk.__abby__.updateLocalVariant( + "checkout", + variant.value === "original" ? "fresh" : "original" + ) + ), + button("Toggle feature flag", () => + sdk.__abby__.updateFlag("premium", !premium.value) + ), + button("Change remote title", () => + sdk.__abby__.updateRemoteConfig( + "headline", + "Updated remotely. Rendered immediately." + ) + ), + button("Target admin user", () => { + sdk.updateUserProperties({ role: "admin" }); + targeting.value = "admin"; + }), + button( + consent.value ? "Revoke cookie consent" : "Enable cookie consent", + () => { + consent.value = !consent.value; + if (consent.value) sdk.enableCookies(); + else sdk.disableCookies(); + } + ), + ]), + h("footer", [ + h("div", [ + h("strong", String(views.value)), + h("span", "View events"), + ]), + h("div", [ + h("strong", String(acts.value)), + h("span", "Conversion events"), + ]), + h("div", [ + h("strong", consent.value ? "On" : "Off"), + h("span", "Assignment cookies"), + ]), + h("p", "Events shown here were received by this local demo server."), + ]), + ]); + }, +}); +createApp({ + render: () => + h(sdk.AbbyProvider, { initialData: fixture }, { default: () => h(Demo) }), +}).mount("#app"); diff --git a/packages/vue/demo/serve.mjs b/packages/vue/demo/serve.mjs new file mode 100644 index 00000000..5a89e408 --- /dev/null +++ b/packages/vue/demo/serve.mjs @@ -0,0 +1,72 @@ +import { readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(fileURLToPath(new URL("./build/", import.meta.url))); +const events = []; +const port = Number(process.env.PORT ?? 8766); +const server = createServer(async (request, response) => { + const url = new URL(request.url, `http://127.0.0.1:${port}`); + if (request.method === "POST" && url.pathname === "/api/data") { + let body = ""; + for await (const chunk of request) { + body += chunk; + if (body.length > 16384) { + response.writeHead(413).end(); + return; + } + } + try { + const event = JSON.parse(body); + if (event.projectId !== "vue-local-demo" || ![0, 1].includes(event.type)) + throw new Error("Invalid event"); + events.push(event); + response + .writeHead(200, { "Content-Type": "application/json" }) + .end('{"ok":true}'); + } catch { + response.writeHead(400).end(); + } + return; + } + if (url.pathname === "/events") { + response + .writeHead(200, { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }) + .end(JSON.stringify(events)); + return; + } + const filename = path.resolve( + root, + `.${decodeURIComponent(url.pathname === "/" ? "/index.html" : url.pathname)}` + ); + if ( + !filename.startsWith(`${root}${path.sep}`) && + filename !== path.join(root, "index.html") + ) { + response.writeHead(403).end(); + return; + } + try { + const content = await readFile(filename); + const types = { + ".html": "text/html", + ".js": "text/javascript", + ".css": "text/css", + }; + response + .writeHead(200, { + "Content-Type": + types[path.extname(filename)] ?? "application/octet-stream", + }) + .end(content); + } catch { + response.writeHead(404).end(); + } +}); +server.listen(port, "127.0.0.1", () => + console.log(`Abby Vue local fixture: http://127.0.0.1:${port}`) +); diff --git a/packages/vue/demo/style.css b/packages/vue/demo/style.css new file mode 100644 index 00000000..2db7e0b0 --- /dev/null +++ b/packages/vue/demo/style.css @@ -0,0 +1,24 @@ +:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #182c3a; background: #f1f5f5; font-synthesis: none; } +* { box-sizing: border-box; } +body { margin: 0; } +main { max-width: 1040px; margin: 0 auto; padding: 42px 48px; } +.eyebrow { color: #16775e; letter-spacing: .18em; font-size: 12px; font-weight: 800; } +h1 { font-size: 44px; letter-spacing: -.04em; margin: 14px 0 8px; } +.intro { font-size: 18px; color: #536874; margin-bottom: 12px; } +.fixture { display: inline-block; font-size: 12px; background: #e0ece7; color: #315849; padding: 8px 12px; border-radius: 5px; } +.preview { margin-top: 22px; background: #182f3b; color: white; border-radius: 16px; padding: 28px 32px; } +.topline { display: flex; justify-content: space-between; align-items: center; font-size: 11px; letter-spacing: .1em; color: #a3c4cb; } +.pill { letter-spacing: normal; border: 1px solid #73989c; border-radius: 20px; padding: 6px 12px; color: white; } +h2 { font-size: 29px; letter-spacing: -.025em; margin: 24px 0 8px; } +.preview p { color: #c4d7dc; } +button { cursor: pointer; font: inherit; font-size: 13px; font-weight: 650; padding: 12px 16px; border: 1px solid #c7d3d5; border-radius: 8px; background: white; color: #25424d; } +button:hover { border-color: #16775e; background: #e9f4ee; } +button:focus-visible { outline: 3px solid #d4a531; outline-offset: 3px; } +.cta { margin-top: 12px; color: #183e31; background: #c6edb9; border: none; } +.cta.fresh { background: #a4d9f1; color: #143e64; } +.controls { display: flex; flex-wrap: wrap; gap: 9px; margin: 24px 0; } +footer { display: flex; gap: 36px; align-items: center; border-top: 1px solid #cedadb; padding-top: 20px; } +footer strong { display: block; font-size: 27px; } +footer span { font-size: 12px; color: #536874; } +footer p { max-width: 225px; margin-left: auto; font-size: 12px; line-height: 1.5; color: #536874; } +@media (max-width: 650px) { main { padding: 24px; } h1 { font-size: 34px; } footer { flex-wrap: wrap; gap: 20px; } footer p { margin-left: 0; } } diff --git a/packages/vue/demo/vite.config.ts b/packages/vue/demo/vite.config.ts new file mode 100644 index 00000000..586be58b --- /dev/null +++ b/packages/vue/demo/vite.config.ts @@ -0,0 +1,10 @@ +import path from "node:path"; +import { defineConfig } from "vite"; + +export default defineConfig({ + root: path.resolve(__dirname), + resolve: { + alias: { "@tryabby/vue": path.resolve(__dirname, "../dist/index.mjs") }, + }, + build: { outDir: "build", emptyOutDir: true }, +}); diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 00000000..7a47dee0 --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,43 @@ +{ + "name": "@tryabby/vue", + "version": "0.1.0", + "description": "Typed Vue composables for Abby feature flags, A/B tests and remote config", + "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, + "scripts": { + "build": "tsup src/index.ts", + "dev": "pnpm run build --watch", + "test": "vitest run && tsc --noEmit -p tsconfig.test.json", + "demo:build": "vite build --config demo/vite.config.ts" + }, + "homepage": "https://docs.tryabby.com", + "license": "ISC", + "dependencies": { + "@tryabby/core": "workspace:*", + "js-cookie": "^3.0.1" + }, + "peerDependencies": { "vue": "^3.3.0" }, + "devDependencies": { + "@types/js-cookie": "^3.0.3", + "@types/node": "^22.5.1", + "@vue/server-renderer": "3.5.13", + "@vue/test-utils": "2.4.6", + "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.13" + } +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 00000000..5b571d17 --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,475 @@ +import { + type ABConfig, + Abby, + type AbbyConfig, + type AbbyDataResponse, + AbbyEventType, + HttpService, + type RemoteConfigValueString, + type RemoteConfigValueStringToType, + type ValidatorType, + getABStorageKey, + getDefaultRemoteConfigValue, + getFFStorageKey, + getRCStorageKey, +} from "@tryabby/core"; +import type { Infer } from "@tryabby/core/validation"; +import Cookie from "js-cookie"; +import { + type ComputedRef, + type InjectionKey, + type MaybeRefOrGetter, + computed, + defineComponent, + getCurrentInstance, + inject, + onMounted, + onUnmounted, + provide, + shallowRef, + toValue, + watch, +} from "vue"; + +export { defineConfig, type ABConfig, type AbbyConfig } from "@tryabby/core"; + +export type ABTestReturnValue = Lookup extends undefined + ? Variant + : Variant extends keyof Lookup + ? Lookup[Variant] + : never; + +/** Create one instance per Vue app, and a fresh instance for each SSR request. */ +export function createAbby< + const FlagName extends string, + const Tests extends Record, + const RemoteConfig extends Record, + const RemoteConfigName extends Extract, + const User extends Record = Record< + string, + ValidatorType + >, +>( + input: AbbyConfig< + FlagName, + Tests, + string[], + RemoteConfigName, + RemoteConfig, + User + > +) { + // Core mutates tests and cookie consent. Never mutate a shared config object. + const config = { + ...input, + cookies: { ...input.cookies }, + tests: Object.fromEntries( + Object.entries(input.tests ?? {}).map(([name, test]) => { + if (!test.variants.length) + throw new Error(`Abby test "${name}" needs a variant`); + return [name, { ...test, variants: [...test.variants] }]; + }) + ) as unknown as Tests, + }; + type UserProperties = Partial<{ + -readonly [K in keyof User]: Infer; + }>; + const revision = shallowRef(0); + const mounted = shallowRef(false); + const ready = shallowRef(false); + const assignments = new Map(); + let cookieHeader: string | undefined; + let selectionReady = false; + let initialized = false; + let dataVersion = 0; + let owner: object | undefined; + let loading: Promise | undefined; + const consentKey = getABStorageKey(config.projectId, "$_abcc_$"); + + const readCookie = (key: string) => { + if (cookieHeader === undefined) + return typeof document === "undefined" ? undefined : Cookie.get(key); + for (const entry of cookieHeader.split(";")) { + const separator = entry.indexOf("="); + if (separator < 0) continue; + try { + if (decodeURIComponent(entry.slice(0, separator).trim()) === key) + return decodeURIComponent(entry.slice(separator + 1)); + } catch { + /* Ignore malformed cookie encoding. */ + } + } + return undefined; + }; + const writeCookie = (key: string, value: string) => { + if (!mounted.value || config.cookies.disableByDefault) return; + Cookie.set(key, value, { expires: config.cookies.expiresInDays ?? 365 }); + }; + const storage = (key: (projectId: string, name: string) => string) => ({ + get: (name: string) => readCookie(key(config.projectId, name)) ?? null, + set: (name: string, value: string) => + writeCookie(key(config.projectId, name), value), + }); + const abby = new Abby< + FlagName, + Extract, + Tests, + RemoteConfig, + RemoteConfigName, + string[], + User + >( + config, + { + get: (name) => { + if (config.cookies.disableByDefault) return null; + const value = + assignments.get(name) ?? + readCookie(getABStorageKey(config.projectId, name)); + return config.tests[name]?.variants.includes(value ?? "") + ? (value ?? null) + : null; + }, + set: (name, value) => { + if (name === "$_abcc_$") { + writeCookie(consentKey, value); + return; + } + if (!selectionReady) return; + assignments.set(name, value); + writeCookie(getABStorageKey(config.projectId, name), value); + }, + }, + storage(getFFStorageKey), + storage(getRCStorageKey) + ); + + const normalize = (data: AbbyDataResponse): AbbyDataResponse => ({ + tests: data.tests, + flags: (config.flags ?? []).map( + (name) => + data.flags.find((flag) => flag.name === name) ?? { + name, + value: + config.settings?.flags?.fallbackValues?.[name] ?? + config.settings?.flags?.defaultValue ?? + false, + } + ), + remoteConfig: ( + Object.entries(config.remoteConfig ?? {}) as Array< + [RemoteConfigName, RemoteConfigValueString] + > + ).map( + ([name, type]) => + data.remoteConfig?.find((item) => item.name === name) ?? { + name, + value: + config.settings?.remoteConfig?.fallbackValues?.[ + name as RemoteConfigName + ] ?? + config.settings?.remoteConfig?.defaultValues?.[type] ?? + getDefaultRemoteConfigValue(type), + } + ), + }); + const initialize = (data: AbbyDataResponse) => { + dataVersion++; + abby.init(normalize(data)); + if (cookieHeader !== undefined) abby.setLocalOverrides(cookieHeader); + initialized = true; + revision.value++; + }; + const ensureInitialized = () => { + if (!initialized) initialize({ tests: [], flags: [], remoteConfig: [] }); + }; + const loadProjectData = () => { + if (!loading) { + const version = dataVersion; + loading = (async () => { + const data = await HttpService.getProjectData({ + projectId: config.projectId, + environment: config.currentEnvironment, + url: config.apiUrl, + fetch: config.fetch, + __experimentalCdnUrl: config.__experimentalCdnUrl + ? `${config.__experimentalCdnUrl}/${config.projectId}/${config.currentEnvironment}` + : undefined, + }); + if (version === dataVersion) { + if (data) initialize(data); + else ensureInitialized(); + } + })().finally(() => { + loading = undefined; + }); + } + return loading; + }; + + const key: InjectionKey = Symbol("Abby"); + const requireProvider = () => { + if (!getCurrentInstance() || !inject(key, undefined)) + throw new Error( + "Abby composables must be used in setup() inside this instance's AbbyProvider" + ); + }; + const AbbyProvider = defineComponent( + ( + props: { + initialData?: AbbyDataResponse; + cookies?: string; + userProperties?: UserProperties; + }, + { slots } + ) => { + const app = getCurrentInstance()?.appContext; + if (owner && owner !== app) + throw new Error( + "Create a fresh Abby instance for each Vue app or SSR request" + ); + owner = app; + cookieHeader = props.cookies; + const consent = readCookie(consentKey); + if (consent !== undefined) + config.cookies.disableByDefault = consent !== "true"; + if (props.userProperties) abby.updateUserProperties(props.userProperties); + if (props.initialData) initialize(props.initialData); + else ensureInitialized(); + provide(key, revision); + let unsubscribe: (() => void) | undefined; + let alive = true; + onMounted(async () => { + mounted.value = true; + unsubscribe = abby.subscribe(() => { + revision.value++; + }); + if (!props.initialData) await loadProjectData(); + if (!alive) return; + selectionReady = true; + ready.value = true; + revision.value++; + }); + watch( + () => props.initialData, + (data) => { + if (data) initialize(data); + } + ); + watch( + () => props.userProperties, + (user) => { + if (user) abby.updateUserProperties(user); + }, + { deep: true } + ); + onUnmounted(() => { + alive = false; + unsubscribe?.(); + mounted.value = false; + ready.value = false; + owner = undefined; + }); + return () => slots.default?.(); + }, + { + name: "AbbyProvider", + props: ["initialData", "cookies", "userProperties"], + } + ); + + const assertName = (name: string, names: readonly string[], kind: string) => { + if (!names.includes(name)) throw new Error(`Unknown Abby ${kind}: ${name}`); + }; + const getFeatureFlagValue = (name: FlagName): boolean => { + assertName(name, config.flags ?? [], "feature flag"); + ensureInitialized(); + return abby.getFeatureFlag(name); + }; + const getRemoteConfig = ( + name: K + ): RemoteConfigValueStringToType => { + assertName(name, Object.keys(config.remoteConfig ?? {}), "remote config"); + ensureInitialized(); + return abby.getRemoteConfig(name); + }; + const getVariants = ( + name: K + ): Tests[K]["variants"] => { + assertName(String(name), Object.keys(config.tests), "test"); + return abby.getVariants(name); + }; + const getABTestValue = < + K extends keyof Tests, + const Lookup extends + | Record + | undefined = undefined, + >( + name: K, + lookup?: Lookup + ): ABTestReturnValue => { + getVariants(name); + ensureInitialized(); + selectionReady = true; + const variant = abby.getTestVariant(name); + return (lookup ? lookup[variant] : variant) as ABTestReturnValue< + Lookup, + Tests[K]["variants"][number] + >; + }; + const useFeatureFlag = ( + name: MaybeRefOrGetter + ): ComputedRef => { + requireProvider(); + return computed(() => { + revision.value; + return getFeatureFlagValue(toValue(name)); + }); + }; + const useRemoteConfig = ( + name: MaybeRefOrGetter + ): ComputedRef> => { + requireProvider(); + return computed(() => { + revision.value; + return getRemoteConfig(toValue(name)); + }); + }; + const useFeatureFlags = () => { + requireProvider(); + return computed(() => { + revision.value; + return (config.flags ?? []).map((name) => ({ + name, + value: getFeatureFlagValue(name), + })); + }); + }; + const useRemoteConfigVariables = () => { + requireProvider(); + return computed(() => { + revision.value; + return (Object.keys(config.remoteConfig ?? {}) as RemoteConfigName[]).map( + (name) => ({ name, value: getRemoteConfig(name) }) + ); + }); + }; + const useAbby = < + K extends keyof Tests, + const Lookup extends + | Record + | undefined = undefined, + >( + name: MaybeRefOrGetter, + lookup?: Lookup + ): { + variant: ComputedRef< + ABTestReturnValue | undefined + >; + onAct: () => void; + } => { + requireProvider(); + const selected = computed(() => { + revision.value; + getVariants(toValue(name)); + return mounted.value && ready.value + ? getABTestValue(toValue(name)) + : undefined; + }); + const send = (type: AbbyEventType) => { + if (!mounted.value || !selected.value) return; + HttpService.sendData({ + url: config.apiUrl, + type, + data: { + projectId: config.projectId, + testName: String(toValue(name)), + selectedVariant: selected.value, + }, + }); + }; + watch([() => toValue(name), selected], () => send(AbbyEventType.PING), { + flush: "post", + }); + return { + variant: computed(() => + selected.value === undefined + ? undefined + : ((lookup + ? lookup[selected.value] + : selected.value) as ABTestReturnValue< + Lookup, + Tests[K]["variants"][number] + >) + ), + onAct: () => send(AbbyEventType.ACT), + }; + }; + const getABResetFunction = (name: K) => { + getVariants(name); + return () => { + assignments.delete(String(name)); + if (typeof document !== "undefined") + Cookie.remove(getABStorageKey(config.projectId, String(name))); + // Like React, reset takes effect on the next page load. + }; + }; + const updateUserProperties = (user: UserProperties) => + abby.updateUserProperties(user); + const enableCookies = () => { + abby.enableCookies(); + revision.value++; + }; + const disableCookies = () => { + abby.disableCookies(); + assignments.clear(); + if (typeof document !== "undefined") { + Cookie.set(consentKey, "false", { + expires: config.cookies.expiresInDays ?? 365, + }); + for (const name of Object.keys(config.tests)) + Cookie.remove(getABStorageKey(config.projectId, name)); + } + revision.value++; + }; + const withDevtools = ( + factory: { create(props: Props): () => void }, + props: Omit & { dangerouslyForceShow?: boolean } + ) => + 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 Props); + }); + onUnmounted(() => destroy?.()); + return () => null; + }, + }); + return { + AbbyProvider, + useAbby, + useFeatureFlag, + useRemoteConfig, + useFeatureFlags, + useRemoteConfigVariables, + getFeatureFlagValue, + getRemoteConfig, + getABTestValue, + getVariants, + getABResetFunction, + updateUserProperties, + withDevtools, + enableCookies, + disableCookies, + loadProjectData, + __abby__: abby, + }; +} diff --git a/packages/vue/tests/consumer.ts b/packages/vue/tests/consumer.ts new file mode 100644 index 00000000..9699c3ad --- /dev/null +++ b/packages/vue/tests/consumer.ts @@ -0,0 +1,25 @@ +import { createAbby, defineConfig } from "@tryabby/vue"; +import type { ComputedRef } from "vue"; + +// These checks resolve the published declaration entry, not src/index.ts. +export function declarationConsumer() { + const sdk = createAbby( + defineConfig( + { projectId: "consumer", currentEnvironment: "test" }, + { + environments: ["test"], + tests: { checkout: { variants: ["a", "b"] } }, + flags: ["premium"], + remoteConfig: { price: "Number" }, + } + ) + ); + const price: ComputedRef = sdk.useRemoteConfig("price"); + const variant: ComputedRef<"a" | "b" | undefined> = + sdk.useAbby("checkout").variant; + // @ts-expect-error Published declarations must reject unknown flags. + sdk.useFeatureFlag("typo"); + // @ts-expect-error Published declarations must reject incomplete lookups. + sdk.getABTestValue("checkout", { a: 1 }); + return { price, variant }; +} diff --git a/packages/vue/tests/fixtures.ts b/packages/vue/tests/fixtures.ts new file mode 100644 index 00000000..f57e7b1a --- /dev/null +++ b/packages/vue/tests/fixtures.ts @@ -0,0 +1,39 @@ +import type { AbbyDataResponse } from "@tryabby/core"; +import * as v from "@tryabby/core/validation"; +import { createAbby } from "../src"; + +export const config = { + projectId: "vue-test", + environments: ["test"], + currentEnvironment: "test", + tests: { + button: { variants: ["control", "blue"] }, + other: { variants: ["one"] }, + }, + flags: ["premium", "search"], + remoteConfig: { title: "String", count: "Number", theme: "JSON" }, + user: { role: v.string(), age: v.number(), active: v.optional(v.boolean()) }, +} as const; + +export const data: AbbyDataResponse = { + tests: [ + { name: "button", weights: [0, 1] }, + { name: "other", weights: [1] }, + ], + flags: [ + { name: "premium", value: true }, + { name: "search", value: false }, + ], + remoteConfig: [ + { name: "title", value: "Hello Vue" }, + { name: "count", value: 7 }, + { name: "theme", value: { dark: true } }, + ], +}; + +export const makeAbby = () => + createAbby({ + ...config, + environments: [...config.environments], + flags: [...config.flags], + }); diff --git a/packages/vue/tests/package.test.ts b/packages/vue/tests/package.test.ts new file mode 100644 index 00000000..330ac5c8 --- /dev/null +++ b/packages/vue/tests/package.test.ts @@ -0,0 +1,27 @@ +// @vitest-environment node +import { execFileSync } from "node:child_process"; +import { expect, it } from "vitest"; + +it.each(["module", "commonjs"])( + "loads the built %s package entry", + (format) => { + const load = + format === "module" + ? 'import { createAbby } from "@tryabby/vue";' + : 'const { createAbby } = require("@tryabby/vue");'; + const result = execFileSync( + process.execPath, + [ + "--input-type", + format, + "-e", + `${load} + const sdk = createAbby({projectId:'consumer',currentEnvironment:'test',environments:['test'],remoteConfig:{price:'Number'}}); + console.log(JSON.stringify({value:sdk.getRemoteConfig('price'), variants:typeof sdk.getVariants})); + `, + ], + { cwd: process.cwd(), encoding: "utf8" } + ); + expect(JSON.parse(result)).toEqual({ value: 0, variants: "function" }); + } +); diff --git a/packages/vue/tests/runtime.test.ts b/packages/vue/tests/runtime.test.ts new file mode 100644 index 00000000..cae47f27 --- /dev/null +++ b/packages/vue/tests/runtime.test.ts @@ -0,0 +1,456 @@ +import { + type AbbyDataResponse, + AbbyEventType, + HttpService, + getABStorageKey, +} from "@tryabby/core"; +import { renderToString } from "@vue/server-renderer"; +import { type VueWrapper, flushPromises, mount } from "@vue/test-utils"; +import Cookie from "js-cookie"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createSSRApp, defineComponent, h, nextTick, ref } from "vue"; +import { createAbby } from "../src"; +import { config, data, makeAbby } from "./fixtures"; + +const wrappers: VueWrapper[] = []; +beforeEach(() => { + // All HTTP is deterministic and local to the test, including core telemetry. + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("{}", { status: 200 })) + ); +}); +afterEach(() => { + for (const wrapper of wrappers.splice(0)) wrapper.unmount(); + for (const key of Object.keys(Cookie.get())) Cookie.remove(key); + vi.unstubAllGlobals(); +}); + +function render( + sdk: ReturnType, + setup: () => () => ReturnType, + initialData: AbbyDataResponse | null = data +) { + const Child = defineComponent({ setup }); + const wrapper = mount(sdk.AbbyProvider, { + props: { initialData: initialData ?? undefined }, + slots: { default: () => h(Child) }, + }); + wrappers.push(wrapper); + return wrapper; +} + +describe("Vue integration with real core", () => { + it("does not overwrite newer provider data with a late initial fetch", async () => { + let resolveFetch: (response: Response) => void = () => {}; + const sdk = createAbby({ + ...config, + environments: [...config.environments], + flags: [...config.flags], + fetch: () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + }); + const wrapper = render( + sdk, + () => { + const title = sdk.useRemoteConfig("title"); + return () => h("div", title.value); + }, + null + ); + await wrapper.setProps({ + initialData: { + ...data, + remoteConfig: [{ name: "title", value: "New provider data" }], + }, + }); + resolveFetch(new Response(JSON.stringify(data), { status: 200 })); + await flushPromises(); + expect(wrapper.text()).toBe("New provider data"); + }); + + it("does not emit events or recreate subscriptions when a fetch completes after unmount", async () => { + let resolveFetch: (response: Response) => void = () => {}; + const sdk = createAbby({ + ...config, + environments: [...config.environments], + flags: [...config.flags], + fetch: () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + }); + const send = vi + .spyOn(HttpService, "sendData") + .mockImplementation(() => undefined); + const wrapper = render( + sdk, + () => { + const { variant } = sdk.useAbby("button"); + return () => h("div", variant.value); + }, + null + ); + wrapper.unmount(); + resolveFetch(new Response(JSON.stringify(data), { status: 200 })); + await flushPromises(); + expect(send).not.toHaveBeenCalled(); + expect( + Cookie.get(getABStorageKey(config.projectId, "button")) + ).toBeUndefined(); + }); + + it("sends real core PING/ACT payloads for reactive test names without duplicate views", async () => { + const sdk = makeAbby(); + const name = ref<"button" | "other">("button"); + let act = () => {}; + render(sdk, () => { + const result = sdk.useAbby(name); + act = result.onAct; + return () => h("div", result.variant.value); + }); + await flushPromises(); + const fetcher = vi.mocked(globalThis.fetch); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toEqual({ + type: 0, + projectId: "vue-test", + testName: "button", + selectedVariant: "blue", + }); + sdk.__abby__.updateFlag("premium", false); + await nextTick(); + expect(fetcher).toHaveBeenCalledTimes(1); + name.value = "other"; + await nextTick(); + act(); + expect(JSON.parse(String(fetcher.mock.calls.at(-1)?.[1]?.body))).toEqual({ + type: 1, + projectId: "vue-test", + testName: "other", + selectedVariant: "one", + }); + }); + it("renders weighted variants, flags, remote configs and typed lookups reactively", async () => { + const sdk = makeAbby(); + const send = vi + .spyOn(HttpService, "sendData") + .mockImplementation(() => undefined); + let act = () => {}; + const wrapper = render(sdk, () => { + const { variant, onAct } = sdk.useAbby("button", { + control: "Original", + blue: "Blue button", + }); + act = onAct; + const flag = sdk.useFeatureFlag("premium"); + const title = sdk.useRemoteConfig("title"); + const all = sdk.useFeatureFlags(); + const configs = sdk.useRemoteConfigVariables(); + return () => + h( + "div", + `${variant.value}|${flag.value}|${title.value}|${all.value.length}|${configs.value.length}` + ); + }); + await flushPromises(); + expect(wrapper.text()).toBe("Blue button|true|Hello Vue|2|3"); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ + type: AbbyEventType.PING, + data: { + projectId: "vue-test", + testName: "button", + selectedVariant: "blue", + }, + }) + ); + act(); + expect(send).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: AbbyEventType.ACT, + data: expect.objectContaining({ selectedVariant: "blue" }), + }) + ); + sdk.__abby__.updateFlag("premium", false); + sdk.__abby__.updateRemoteConfig("title", "Updated"); + sdk.__abby__.updateLocalVariant("button", "control"); + await nextTick(); + expect(wrapper.text()).toBe("Original|false|Updated|2|3"); + act(); + expect(send).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: AbbyEventType.ACT, + data: expect.objectContaining({ selectedVariant: "control" }), + }) + ); + }); + + it("fetches centrally once and supports reactive names", async () => { + const fetcher = vi.fn( + async () => new Response(JSON.stringify(data), { status: 200 }) + ); + const sdk = createAbby({ + ...config, + environments: [...config.environments], + flags: [...config.flags], + fetch: fetcher, + }); + const name = ref<"premium" | "search">("premium"); + const wrapper = render( + sdk, + () => { + const first = sdk.useFeatureFlag(name); + const second = sdk.useRemoteConfig("count"); + return () => h("div", `${first.value}|${second.value}`); + }, + null + ); + await flushPromises(); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(fetcher.mock.calls[0][0]).toContain( + "api/v2/data/vue-test?environment=test" + ); + expect(wrapper.text()).toBe("true|7"); + name.value = "search"; + await nextTick(); + expect(wrapper.text()).toBe("false|7"); + }); + + it("keeps configured defaults on a failed or partial API response", async () => { + const sdk = createAbby({ + ...config, + environments: [...config.environments], + flags: [...config.flags], + settings: { + flags: { defaultValue: true }, + remoteConfig: { + fallbackValues: { title: "Fallback" }, + defaultValues: { Number: 42 }, + }, + }, + fetch: async () => new Response("", { status: 503 }), + }); + const wrapper = render( + sdk, + () => { + const flag = sdk.useFeatureFlag("search"); + const title = sdk.useRemoteConfig("title"); + const count = sdk.useRemoteConfig("count"); + return () => h("div", `${flag.value}|${title.value}|${count.value}`); + }, + null + ); + await flushPromises(); + expect(wrapper.text()).toBe("true|Fallback|42"); + await wrapper.setProps({ + initialData: { tests: [], flags: [], remoteConfig: [] }, + }); + expect(wrapper.text()).toBe("true|Fallback|42"); + }); + + it("persists valid variants with configured expiry, ignores stale assignments and resets", async () => { + Cookie.set(getABStorageKey(config.projectId, "button"), "obsolete"); + const set = vi.spyOn(Cookie, "set"); + const sdk = createAbby({ + ...config, + environments: [...config.environments], + flags: [...config.flags], + cookies: { expiresInDays: 3 }, + }); + render(sdk, () => { + const { variant } = sdk.useAbby("button"); + return () => h("div", variant.value); + }); + await flushPromises(); + expect(sdk.getABTestValue("button")).toBe("blue"); + expect(Cookie.get(getABStorageKey(config.projectId, "button"))).toBe( + "blue" + ); + expect(set).toHaveBeenCalledWith( + getABStorageKey(config.projectId, "button"), + "blue", + { expires: 3 } + ); + sdk.getABResetFunction("button")(); + expect( + Cookie.get(getABStorageKey(config.projectId, "button")) + ).toBeUndefined(); + }); + + it("does not write assignment cookies without consent and can enable/revoke consent", async () => { + const sdk = createAbby({ + ...config, + environments: [...config.environments], + flags: [...config.flags], + cookies: { disableByDefault: true }, + }); + render(sdk, () => { + const { variant } = sdk.useAbby("button"); + return () => h("div", variant.value); + }); + await flushPromises(); + expect( + Cookie.get(getABStorageKey(config.projectId, "button")) + ).toBeUndefined(); + sdk.enableCookies(); + expect(Cookie.get(getABStorageKey(config.projectId, "button"))).toBe( + "blue" + ); + sdk.disableCookies(); + expect( + Cookie.get(getABStorageKey(config.projectId, "button")) + ).toBeUndefined(); + expect(Cookie.get(getABStorageKey(config.projectId, "$_abcc_$"))).toBe( + "false" + ); + }); + + it("updates targeting rules and rejects invalid user properties", async () => { + const sdk = makeAbby(); + const initialData: AbbyDataResponse = { + ...data, + flags: [ + { + name: "premium", + value: false, + ruleSet: [ + { + propertyName: "role", + propertyType: "string", + operator: "eq", + value: "admin", + thenValue: true, + }, + ], + }, + ], + }; + const wrapper = render( + sdk, + () => { + const flag = sdk.useFeatureFlag("premium"); + return () => h("div", String(flag.value)); + }, + initialData + ); + sdk.updateUserProperties({ role: "admin" }); + await nextTick(); + expect(wrapper.text()).toBe("true"); + expect(() => sdk.updateUserProperties({ role: 1 } as never)).toThrow(); + expect(wrapper.text()).toBe("true"); + }); + + it("requires the matching provider and rejects unknown runtime names", () => { + const sdk = makeAbby(); + expect(() => sdk.useFeatureFlag("premium")).toThrow("AbbyProvider"); + expect(() => sdk.getFeatureFlagValue("unknown" as never)).toThrow( + "Unknown Abby feature flag" + ); + expect(() => sdk.getRemoteConfig("unknown" as never)).toThrow( + "Unknown Abby remote config" + ); + expect(() => sdk.getABTestValue("unknown" as never)).toThrow( + "Unknown Abby test" + ); + const other = makeAbby(); + expect(() => + render(sdk, () => { + other.useFeatureFlag("premium"); + return () => h("div"); + }) + ).toThrow("AbbyProvider"); + }); + + it("unsubscribes and stops events after unmount", async () => { + const sdk = makeAbby(); + const unsubscribe = vi.fn(); + const subscribe = sdk.__abby__.subscribe.bind(sdk.__abby__); + vi.spyOn(sdk.__abby__, "subscribe").mockImplementation((callback) => { + const stop = subscribe(callback); + return () => { + stop(); + unsubscribe(); + }; + }); + const send = vi + .spyOn(HttpService, "sendData") + .mockImplementation(() => undefined); + let act = () => {}; + const wrapper = render(sdk, () => { + const result = sdk.useAbby("button"); + act = result.onAct; + return () => h("div", result.variant.value); + }); + await flushPromises(); + wrapper.unmount(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + send.mockClear(); + sdk.__abby__.updateFlag("premium", false); + act(); + await nextTick(); + expect(send).not.toHaveBeenCalled(); + }); + + it("mounts devtools only when requested and destroys them on unmount", () => { + const sdk = makeAbby(); + const destroy = vi.fn(); + const factory = { + create: vi.fn( + (_props: { abby: typeof sdk.__abby__; defaultShow?: boolean }) => + destroy + ), + }; + const Disabled = sdk.withDevtools(factory, {}); + const disabled = mount(Disabled); + disabled.unmount(); + expect(factory.create).not.toHaveBeenCalled(); + const Enabled = sdk.withDevtools(factory, { + dangerouslyForceShow: true, + defaultShow: true, + }); + const wrapper = mount(Enabled); + expect(factory.create).toHaveBeenCalledWith({ + abby: sdk.__abby__, + defaultShow: true, + }); + wrapper.unmount(); + expect(destroy).toHaveBeenCalledTimes(1); + }); + + it("hydrates initial flags/config without mismatch and selects variants after mount", async () => { + function app() { + const sdk = makeAbby(); + const Child = defineComponent({ + setup() { + const flag = sdk.useFeatureFlag("premium"); + const { variant } = sdk.useAbby("button"); + return () => h("div", `${flag.value}|${variant.value ?? "pending"}`); + }, + }); + return createSSRApp({ + render: () => + h( + sdk.AbbyProvider, + { initialData: data }, + { default: () => h(Child) } + ), + }); + } + const html = await renderToString(app()); + expect(html).toContain("true|pending"); + const target = document.createElement("div"); + target.innerHTML = html; + document.body.append(target); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const client = app(); + client.mount(target); + await flushPromises(); + expect(target.textContent).toBe("true|blue"); + expect(warn).not.toHaveBeenCalled(); + client.unmount(); + target.remove(); + }); +}); diff --git a/packages/vue/tests/ssr.test.ts b/packages/vue/tests/ssr.test.ts new file mode 100644 index 00000000..df83e0eb --- /dev/null +++ b/packages/vue/tests/ssr.test.ts @@ -0,0 +1,59 @@ +import { HttpService, getABStorageKey } from "@tryabby/core"; +// @vitest-environment node +import { renderToString } from "@vue/server-renderer"; +import { expect, it, vi } from "vitest"; +import { createSSRApp, defineComponent, h } from "vue"; +import { data, makeAbby } from "./fixtures"; + +it("isolates concurrent SSR requests, input config and request cookies", async () => { + const first = makeAbby(); + const second = makeAbby(); + const send = vi.spyOn(HttpService, "sendData"); + function app( + sdk: ReturnType, + enabled: boolean, + variant: string + ) { + const Child = defineComponent({ + setup() { + const flag = sdk.useFeatureFlag("premium"); + const test = sdk.useAbby("button"); + return () => + h("div", `${flag.value}|${test.variant.value ?? "pending"}`); + }, + }); + return createSSRApp({ + render: () => + h( + sdk.AbbyProvider, + { + initialData: { + ...data, + flags: [{ name: "premium", value: enabled }], + }, + cookies: `${getABStorageKey("vue-test", "button")}=${variant}`, + }, + { default: () => h(Child) } + ), + }); + } + const result = await Promise.all([ + renderToString(app(first, true, "control")), + renderToString(app(second, false, "blue")), + ]); + expect(result[0]).toContain("true|pending"); + expect(result[1]).toContain("false|pending"); + expect(first.getABTestValue("button")).toBe("control"); + expect(second.getABTestValue("button")).toBe("blue"); + first.disableCookies(); + expect(second.__abby__.getConfig().cookies?.disableByDefault).not.toBe(true); + expect(send).not.toHaveBeenCalled(); +}); + +it("rejects accidental reuse across SSR applications instead of leaking user state", async () => { + const sdk = makeAbby(); + const app = () => + createSSRApp({ render: () => h(sdk.AbbyProvider, { initialData: data }) }); + await renderToString(app()); + await expect(renderToString(app())).rejects.toThrow("fresh Abby instance"); +}); diff --git a/packages/vue/tests/types.ts b/packages/vue/tests/types.ts new file mode 100644 index 00000000..ceceeec7 --- /dev/null +++ b/packages/vue/tests/types.ts @@ -0,0 +1,46 @@ +import { expectTypeOf } from "vitest"; +import type { ComputedRef } from "vue"; +import { makeAbby } from "./fixtures"; + +// Compiled by tsc; never executed outside a component's setup(). +export function checkTypes() { + const sdk = makeAbby(); + expectTypeOf(sdk.useFeatureFlag("premium")).toEqualTypeOf< + ComputedRef + >(); + expectTypeOf(sdk.useRemoteConfig("title")).toEqualTypeOf< + ComputedRef + >(); + expectTypeOf(sdk.useRemoteConfig("count")).toEqualTypeOf< + ComputedRef + >(); + expectTypeOf(sdk.useRemoteConfig("theme")).toEqualTypeOf< + ComputedRef> + >(); + expectTypeOf(sdk.useAbby("button").variant).toEqualTypeOf< + ComputedRef<"control" | "blue" | undefined> + >(); + expectTypeOf( + sdk.useAbby("button", { control: 1, blue: "two" }).variant + ).toEqualTypeOf>(); + expectTypeOf(sdk.getABTestValue("button")).toEqualTypeOf< + "control" | "blue" + >(); + expectTypeOf(sdk.getRemoteConfig("count")).toEqualTypeOf(); + expectTypeOf(sdk.getVariants("button")).toEqualTypeOf< + readonly ["control", "blue"] + >(); + sdk.updateUserProperties({ role: "admin", age: 18, active: undefined }); + // @ts-expect-error Unknown feature flag + sdk.useFeatureFlag("missing"); + // @ts-expect-error Unknown remote config + sdk.useRemoteConfig("missing"); + // @ts-expect-error Unknown A/B test + sdk.useAbby("missing"); + // @ts-expect-error Missing a variant in the lookup + sdk.useAbby("button", { control: 1 }); + // @ts-expect-error Invalid user property type + sdk.updateUserProperties({ age: "18" }); + // @ts-expect-error Unknown user property + sdk.updateUserProperties({ missing: true }); +} diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json new file mode 100644 index 00000000..b79120ba --- /dev/null +++ b/packages/vue/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "tsconfig/base.json", + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ESNext", "DOM"], + "types": ["node"], + "noEmit": true + }, + "include": ["src"] +} diff --git a/packages/vue/tsconfig.test.json b/packages/vue/tsconfig.test.json new file mode 100644 index 00000000..c503db22 --- /dev/null +++ b/packages/vue/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "types": ["node", "vitest/globals"] }, + "include": ["src", "tests"] +} 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..f8af668d --- /dev/null +++ b/packages/vue/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + environmentOptions: { jsdom: { url: "https://vue.example.test" } }, + include: ["tests/**/*.test.ts"], + restoreMocks: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69fa896e..efa72226 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1108,6 +1108,49 @@ 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 + "@types/node": + specifier: ^22.5.1 + version: 22.20.5 + "@vue/server-renderer": + specifier: 3.5.13 + version: 3.5.13(vue@3.5.13(typescript@5.5.4)) + "@vue/test-utils": + specifier: 2.4.6 + version: 2.4.6 + 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.5.31)(ts-node@10.9.1(@types/node@22.20.5)(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.20.5)(less@4.2.0)(sass@1.77.8)(terser@5.31.1) + vitest: + specifier: 2.0.5 + version: 2.0.5(@types/node@22.20.5)(jsdom@20.0.3)(less@4.2.0)(sass@1.77.8)(terser@5.31.1) + vue: + specifier: 3.5.13 + version: 3.5.13(typescript@5.5.4) + packages: '@adobe/css-tools@4.2.0': @@ -14389,6 +14432,99 @@ packages: zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + "@types/node@22.20.5": + resolution: + integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA== + undici-types@6.21.0: + resolution: + integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== + "@vue/server-renderer@3.5.13": + resolution: + integrity: sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA== + peerDependencies: + vue: 3.5.13 + "@vue/compiler-ssr@3.5.13": + resolution: + integrity: sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA== + "@vue/compiler-dom@3.5.13": + resolution: + integrity: sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA== + "@vue/compiler-core@3.5.13": + resolution: + integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q== + "@vue/shared@3.5.13": + resolution: + integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ== + vue@3.5.13: + resolution: + integrity: sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ== + peerDependencies: + typescript: "*" + peerDependenciesMeta: + typescript: + optional: true + "@vue/compiler-sfc@3.5.13": + resolution: + integrity: sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ== + magic-string@0.30.21: + resolution: + integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + "@jridgewell/sourcemap-codec@1.6.0": + resolution: + integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw== + postcss@8.5.31: + resolution: + integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg== + engines: + node: ^10 || ^12 || >=14 + nanoid@3.3.20: + resolution: + integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ== + engines: + node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 + hasBin: true + picocolors@1.1.1: + resolution: + integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + source-map-js@1.2.2: + resolution: + integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw== + engines: + node: ">=0.10.0" + "@vue/runtime-dom@3.5.13": + resolution: + integrity: sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog== + "@vue/reactivity@3.5.13": + resolution: + integrity: sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg== + "@vue/runtime-core@3.5.13": + resolution: + integrity: sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw== + csstype@3.2.3: + resolution: + integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + "@vue/test-utils@2.4.6": + resolution: + integrity: sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow== + js-beautify@1.15.4: + resolution: + integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA== + engines: + node: ">=14" + hasBin: true + editorconfig@1.0.7: + resolution: + integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw== + engines: + node: ">=14" + hasBin: true + "@one-ini/wasm@0.1.1": + resolution: + integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw== + vue-component-type-helpers@2.2.12: + resolution: + integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw== + snapshots: '@adobe/css-tools@4.2.0': {} @@ -31399,3 +31535,202 @@ snapshots: react: 18.2.0 zwitch@2.0.4: {} + + "@types/node@22.20.5": + dependencies: + undici-types: 6.21.0 + undici-types@6.21.0: {} + "@vue/server-renderer@3.5.13(vue@3.5.13(typescript@5.5.4))": + dependencies: + "@vue/compiler-ssr": 3.5.13 + "@vue/shared": 3.5.13 + vue: 3.5.13(typescript@5.5.4) + "@vue/compiler-ssr@3.5.13": + dependencies: + "@vue/compiler-dom": 3.5.13 + "@vue/shared": 3.5.13 + "@vue/compiler-dom@3.5.13": + dependencies: + "@vue/compiler-core": 3.5.13 + "@vue/shared": 3.5.13 + "@vue/compiler-core@3.5.13": + dependencies: + "@babel/parser": 7.25.3 + "@vue/shared": 3.5.13 + entities: 4.5.0 + estree-walker: 2.0.2 + source-map-js: 1.2.0 + "@vue/shared@3.5.13": {} + vue@3.5.13(typescript@5.5.4): + dependencies: + "@vue/compiler-dom": 3.5.13 + "@vue/compiler-sfc": 3.5.13 + "@vue/runtime-dom": 3.5.13 + "@vue/server-renderer": 3.5.13(vue@3.5.13(typescript@5.5.4)) + "@vue/shared": 3.5.13 + optionalDependencies: + typescript: 5.5.4 + "@vue/compiler-sfc@3.5.13": + dependencies: + "@babel/parser": 7.25.3 + "@vue/compiler-core": 3.5.13 + "@vue/compiler-dom": 3.5.13 + "@vue/compiler-ssr": 3.5.13 + "@vue/shared": 3.5.13 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.31 + source-map-js: 1.2.0 + magic-string@0.30.21: + dependencies: + "@jridgewell/sourcemap-codec": 1.6.0 + "@jridgewell/sourcemap-codec@1.6.0": {} + postcss@8.5.31: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + nanoid@3.3.20: {} + picocolors@1.1.1: {} + source-map-js@1.2.2: {} + "@vue/runtime-dom@3.5.13": + dependencies: + "@vue/reactivity": 3.5.13 + "@vue/runtime-core": 3.5.13 + "@vue/shared": 3.5.13 + csstype: 3.2.3 + "@vue/reactivity@3.5.13": + dependencies: + "@vue/shared": 3.5.13 + "@vue/runtime-core@3.5.13": + dependencies: + "@vue/reactivity": 3.5.13 + "@vue/shared": 3.5.13 + csstype@3.2.3: {} + "@vue/test-utils@2.4.6": + dependencies: + js-beautify: 1.15.4 + vue-component-type-helpers: 2.2.12 + 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 + editorconfig@1.0.7: + dependencies: + "@one-ini/wasm": 0.1.1 + commander: 10.0.1 + minimatch: 9.0.4 + semver: 7.6.2 + "@one-ini/wasm@0.1.1": {} + vue-component-type-helpers@2.2.12: {} + tsup@6.7.0(postcss@8.5.31)(ts-node@10.9.1(@types/node@22.20.5)(typescript@5.5.4))(typescript@5.5.4): + dependencies: + bundle-require: 4.0.1(esbuild@0.17.19) + cac: 6.7.14 + chokidar: 3.6.0 + debug: 4.3.5 + esbuild: 0.17.19 + execa: 5.1.1 + globby: 11.1.0 + joycon: 3.1.1 + postcss-load-config: 3.1.4(postcss@8.5.31)(ts-node@10.9.1(@types/node@22.20.5)(typescript@5.5.4)) + resolve-from: 5.0.0 + rollup: 3.29.4 + source-map: 0.8.0-beta.0 + sucrase: 3.32.0 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.31 + typescript: 5.5.4 + transitivePeerDependencies: + - supports-color + - ts-node + postcss-load-config@3.1.4(postcss@8.5.31)(ts-node@10.9.1(@types/node@22.20.5)(typescript@5.5.4)): + dependencies: + lilconfig: 2.1.0 + yaml: 1.10.2 + optionalDependencies: + postcss: 8.5.31 + ts-node: 10.9.1(@types/node@22.20.5)(typescript@5.5.4) + ts-node@10.9.1(@types/node@22.20.5)(typescript@5.5.4): + dependencies: + "@cspotcode/source-map-support": 0.8.1 + "@tsconfig/node10": 1.0.9 + "@tsconfig/node12": 1.0.11 + "@tsconfig/node14": 1.0.3 + "@tsconfig/node16": 1.0.4 + "@types/node": 22.20.5 + acorn: 8.8.2 + acorn-walk: 8.2.0 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.5.4 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optional: true + vite@5.4.0(@types/node@22.20.5)(less@4.2.0)(sass@1.77.8)(terser@5.31.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.4.41 + rollup: 4.18.0 + optionalDependencies: + "@types/node": 22.20.5 + fsevents: 2.3.3 + less: 4.2.0 + sass: 1.77.8 + terser: 5.31.1 + vitest@2.0.5(@types/node@22.20.5)(jsdom@20.0.3)(less@4.2.0)(sass@1.77.8)(terser@5.31.1): + dependencies: + "@ampproject/remapping": 2.3.0 + "@vitest/expect": 2.0.5 + "@vitest/pretty-format": 2.0.5 + "@vitest/runner": 2.0.5 + "@vitest/snapshot": 2.0.5 + "@vitest/spy": 2.0.5 + "@vitest/utils": 2.0.5 + chai: 5.1.1 + debug: 4.3.5 + execa: 8.0.1 + magic-string: 0.30.10 + pathe: 1.1.2 + std-env: 3.7.0 + tinybench: 2.8.0 + tinypool: 1.0.0 + tinyrainbow: 1.2.0 + vite: 5.4.0(@types/node@22.20.5)(less@4.2.0)(sass@1.77.8)(terser@5.31.1) + vite-node: 2.0.5(@types/node@22.20.5)(less@4.2.0)(sass@1.77.8)(terser@5.31.1) + why-is-node-running: 2.3.0 + optionalDependencies: + "@types/node": 22.20.5 + jsdom: 20.0.3 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-node@2.0.5(@types/node@22.20.5)(less@4.2.0)(sass@1.77.8)(terser@5.31.1): + dependencies: + cac: 6.7.14 + debug: 4.3.5 + pathe: 1.1.2 + tinyrainbow: 1.2.0 + vite: 5.4.0(@types/node@22.20.5)(less@4.2.0)(sass@1.77.8)(terser@5.31.1) + transitivePeerDependencies: + - "@types/node" + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser From 69ad323e18b9cff8008027a83161ddbc8a10f46d Mon Sep 17 00:00:00 2001 From: pythonyx135793 Date: Sat, 24 Oct 2026 15:06:27 +0200 Subject: [PATCH 2/2] Keep Vue demo serving malformed URLs and build before package tests --- packages/vue/README.md | 4 +- packages/vue/demo/serve.mjs | 14 +++-- packages/vue/package.json | 2 +- packages/vue/tests/demo-server.test.ts | 82 ++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 packages/vue/tests/demo-server.test.ts diff --git a/packages/vue/README.md b/packages/vue/README.md index e95a7bac..6c0271af 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -131,11 +131,9 @@ Render `` once. It initializes on mount in development and clean ## Development and local demo -From the repository root, build core and this package before testing: +From the repository root, the test command builds core and this package before running the runtime and type checks: ```sh -pnpm --filter @tryabby/core build -pnpm --filter @tryabby/vue build pnpm --filter @tryabby/vue test pnpm --filter @tryabby/vue demo:build node packages/vue/demo/serve.mjs diff --git a/packages/vue/demo/serve.mjs b/packages/vue/demo/serve.mjs index 5a89e408..095bb878 100644 --- a/packages/vue/demo/serve.mjs +++ b/packages/vue/demo/serve.mjs @@ -39,10 +39,16 @@ const server = createServer(async (request, response) => { .end(JSON.stringify(events)); return; } - const filename = path.resolve( - root, - `.${decodeURIComponent(url.pathname === "/" ? "/index.html" : url.pathname)}` - ); + let pathname; + try { + pathname = decodeURIComponent( + url.pathname === "/" ? "/index.html" : url.pathname + ); + } catch { + response.writeHead(400).end(); + return; + } + const filename = path.resolve(root, `.${pathname}`); if ( !filename.startsWith(`${root}${path.sep}`) && filename !== path.join(root, "index.html") diff --git a/packages/vue/package.json b/packages/vue/package.json index 7a47dee0..fa2e7859 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -17,7 +17,7 @@ "scripts": { "build": "tsup src/index.ts", "dev": "pnpm run build --watch", - "test": "vitest run && tsc --noEmit -p tsconfig.test.json", + "test": "pnpm --filter @tryabby/core build && pnpm run build && vitest run && tsc --noEmit -p tsconfig.test.json", "demo:build": "vite build --config demo/vite.config.ts" }, "homepage": "https://docs.tryabby.com", diff --git a/packages/vue/tests/demo-server.test.ts b/packages/vue/tests/demo-server.test.ts new file mode 100644 index 00000000..26c09d25 --- /dev/null +++ b/packages/vue/tests/demo-server.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment node +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { get } from "node:http"; +import { type AddressInfo, createServer } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, it } from "vitest"; + +it("returns 400 for malformed URL encoding and keeps serving requests", async () => { + const temporaryRoot = path.resolve(tmpdir()); + const directory = await mkdtemp(path.join(temporaryRoot, "abby-vue-demo-")); + // Only this test's direct temporary child may be removed during cleanup. + if (path.dirname(directory) !== temporaryRoot) + throw new Error("Unexpected temporary path"); + const listener = createServer(); + listener.listen(0, "127.0.0.1"); + await once(listener, "listening"); + const port = (listener.address() as AddressInfo).port; + await new Promise((resolve) => listener.close(() => resolve())); + await mkdir(path.join(directory, "build")); + await writeFile( + path.join(directory, "build/index.html"), + "Demo is available" + ); + await copyFile( + new URL("../demo/serve.mjs", import.meta.url), + path.join(directory, "serve.mjs") + ); + const child = spawn(process.execPath, [path.join(directory, "serve.mjs")], { + env: { ...process.env, PORT: String(port) }, + stdio: ["ignore", "pipe", "pipe"], + }); + const exited = once(child, "exit"); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + const request = (pathname: string) => + new Promise<{ status: number | undefined; body: string }>( + (resolve, reject) => { + const request = get( + { hostname: "127.0.0.1", port, path: pathname }, + (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { + body += chunk; + }); + response.on("end", () => + resolve({ status: response.statusCode, body }) + ); + } + ); + request.on("error", reject); + request.setTimeout(2000, () => + request.destroy(new Error("Request timed out")) + ); + } + ); + try { + await Promise.race([ + once(child.stdout, "data"), + exited.then(() => { + throw new Error(`Demo server exited before listening: ${stderr}`); + }), + ]); + expect((await request("/%ZZ")).status).toBe(400); + expect(await request("/")).toEqual({ + status: 200, + body: "Demo is available", + }); + expect(child.exitCode).toBeNull(); + expect(stderr).toBe(""); + } finally { + if (child.exitCode === null) child.kill(); + await exited; + await rm(directory, { recursive: true, force: true }); + if (stderr) console.error(stderr); + } +});