diff --git a/README.md b/README.md index 08b1b43..412f484 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,23 @@ await safeFlush(); `signal()`, `queue()`, and `flush()` return promises and may reject (for example on network failures). Use `safeSignal()`, `safeQueue()`, and `safeFlush()` for fire-and-forget analytics calls to avoid unhandled promise rejections. +## TypeScript Helpers + +The package exports named public types for the composable return value and each telemetry method. Prefer these named types when your application stores or accepts one of the functions outside of the component that calls `useTelemetryDeck()`. + +```ts +import type { + TelemetryDeckHooks, + TelemetryDeckSafeSignal, +} from "@peerigon/telemetrydeck-vue"; + +let safeSignal: TelemetryDeckSafeSignal | undefined; + +function installTelemetry(hooks: TelemetryDeckHooks) { + safeSignal = hooks.safeSignal; +} +``` + ## Contributions ### Local Development diff --git a/package.json b/package.json index 3203c71..877e9be 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "release": "semantic-release", "size": "size-limit", "test": "vitest --coverage", - "test:exports": "node tests/exports/import.mjs && node tests/exports/require.cjs", + "test:exports": "node tests/exports/import.mjs && node tests/exports/require.cjs && tsc --noEmit -p tests/exports/tsconfig.json", "test:pack": "node tests/pack/contents.mjs", "typecheck": "vue-tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.build.json" }, diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 57c70a1..d585bc3 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -5,7 +5,15 @@ import type { } from "@telemetrydeck/sdk"; import { inject } from "vue"; -export type TelemetryDeckMethod = "signal" | "queue" | "flush"; +export type { + TelemetryDeckOptions, + TelemetryDeckPayload, +} from "@telemetrydeck/sdk"; + +export type TelemetryDeckMethod = keyof Pick< + TelemetryDeck, + "signal" | "queue" | "flush" +>; export interface TelemetryDeckErrorMeta { method: TelemetryDeckMethod; @@ -19,7 +27,55 @@ export type TelemetryDeckErrorHandler = ( meta: TelemetryDeckErrorMeta, ) => void | Promise; -export function useTelemetryDeck() { +export type TelemetryDeckSetClientUser = (clientUser: string) => Promise; + +type TelemetryDeckMethodReturn = + Awaited>; + +type TelemetryDeckMethodParameters< + TMethod extends "signal" | "queue" | "flush", +> = Parameters; + +export type TelemetryDeckSignal = ( + ...args: TelemetryDeckMethodParameters<"signal"> +) => Promise | undefined>; + +export type TelemetryDeckQueue = ( + ...args: TelemetryDeckMethodParameters<"queue"> +) => Promise | undefined>; + +export type TelemetryDeckFlush = ( + ...args: TelemetryDeckMethodParameters<"flush"> +) => Promise | undefined>; + +export type TelemetryDeckGetQueueCount = () => number; + +export type TelemetryDeckSafeSignal = ( + type: string, + payload?: TelemetryDeckPayload, + options?: TelemetryDeckOptions, +) => Promise; + +export type TelemetryDeckSafeQueue = ( + type: string, + payload?: TelemetryDeckPayload, + options?: TelemetryDeckOptions, +) => Promise; + +export type TelemetryDeckSafeFlush = () => Promise; + +export interface TelemetryDeckHooks { + setClientUser: TelemetryDeckSetClientUser; + signal: TelemetryDeckSignal; + queue: TelemetryDeckQueue; + flush: TelemetryDeckFlush; + getQueueCount: TelemetryDeckGetQueueCount; + safeSignal: TelemetryDeckSafeSignal; + safeQueue: TelemetryDeckSafeQueue; + safeFlush: TelemetryDeckSafeFlush; +} + +export function useTelemetryDeck(): TelemetryDeckHooks { const td = inject("td"); const onError = inject( "tdOnError", diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 6404d61..e8f1a79 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -1,14 +1,14 @@ -import TelemetryDeck from "@telemetrydeck/sdk"; +import TelemetryDeck, { type TelemetryDeckOptions } from "@telemetrydeck/sdk"; import type { TelemetryDeckErrorHandler } from "../hooks"; interface TelemetryDeckApp { provide: (key: string, value: unknown) => void; } -export interface TelemetryDeckPluginOptions { - appID: string; - clientUser?: string; - testMode?: boolean; +export interface TelemetryDeckPluginOptions extends Pick< + TelemetryDeckOptions, + "appID" | "clientUser" | "testMode" +> { onError?: TelemetryDeckErrorHandler; } diff --git a/src/tests/App.test.ts b/src/tests/App.test.ts index 13351ea..4402f0c 100644 --- a/src/tests/App.test.ts +++ b/src/tests/App.test.ts @@ -90,121 +90,131 @@ describe("App", () => { it("calls TelemetryDeck methods from the demo controls", async () => { const wrapper = mountApp(); - - mockTelemetryDeck.signal.mockResolvedValueOnce({ accepted: true }); - - await wrapper.find("#btnSignalClick").trigger("click"); - await waitForClickHandler(); - expect(mockTelemetryDeck.signal).toHaveBeenCalledWith( - "example_signal_event_name", - expect.objectContaining({ - custom_data: "other_data", - source: "signal", - timestamp: expect.any(String), - }), - undefined, - ); - expect(wrapper.find("#lastTelemetryResult").text()).toBe( - 'signal response: {"accepted":true}', - ); - - await wrapper.find("#btnQueueClick").trigger("click"); - await waitForClickHandler(); - expect(mockTelemetryDeck.queue).toHaveBeenCalledWith( - "example_queue_event_name", - expect.objectContaining({ - custom_data: "other_data", - source: "queue", - timestamp: expect.any(String), - }), - undefined, - ); - expect(wrapper.find("#lastTelemetryResult").text()).toBe( - "queue promise resolved without response", - ); - expect(wrapper.find("#queuedTelemetryEvents").text()).toBe("1"); - - await wrapper.find("#btnQueueClickWithOptions").trigger("click"); - await waitForClickHandler(); - expect(mockTelemetryDeck.queue).toHaveBeenCalledWith( - "example_queue_event_name_with_options", - expect.objectContaining({ - custom_data: "other_data", - source: "queue_with_options", - timestamp: expect.any(String), - }), - { testMode: true, clientUser: "other_user", appID: "other_app_id" }, - ); - - await wrapper.find("#btnSignalClickWithOptions").trigger("click"); - await waitForClickHandler(); - expect(mockTelemetryDeck.signal).toHaveBeenCalledWith( - "example_signal_event_name_with_options", - expect.objectContaining({ - custom_data: "other_data", - source: "signal_with_options", - timestamp: expect.any(String), - }), - { testMode: true, clientUser: "other_user", appID: "other_app_id" }, - ); - - await wrapper.find("#btnFlushClick").trigger("click"); - await waitForClickHandler(); - expect(mockTelemetryDeck.flush).toHaveBeenCalledTimes(1); - expect(wrapper.find("#lastTelemetryResult").text()).toBe( - "flush promise resolved without response", - ); - expect(wrapper.find("#queuedTelemetryEvents").text()).toBe("0"); - - await wrapper.find("#btnSafeSignalClick").trigger("click"); - await waitForClickHandler(); - expect(mockTelemetryDeck.signal).toHaveBeenCalledWith( - "example_safe_signal_event_name", - expect.objectContaining({ - source: "safe_signal", - }), - undefined, - ); - expect(wrapper.find("#lastTelemetryResult").text()).toBe( - "safeSignal promise resolved", - ); - - await wrapper.find("#btnSafeQueueClick").trigger("click"); - await waitForClickHandler(); - expect(mockTelemetryDeck.queue).toHaveBeenCalledWith( - "example_safe_queue_event_name", - expect.objectContaining({ - source: "safe_queue", - }), - undefined, - ); - expect(wrapper.find("#lastTelemetryResult").text()).toBe( - "safeQueue promise resolved", - ); - expect(wrapper.find("#queuedTelemetryEvents").text()).toBe("1"); - - await wrapper.find("#btnSafeFlushClick").trigger("click"); - await waitForClickHandler(); - expect(mockTelemetryDeck.flush).toHaveBeenCalledTimes(2); - expect(wrapper.find("#lastTelemetryResult").text()).toBe( - "safeFlush promise resolved", - ); - expect(wrapper.find("#queuedTelemetryEvents").text()).toBe("0"); - - let previousClientUser = mockTelemetryDeck.clientUser; - await wrapper.find("#btnSetClient").trigger("click"); - await waitForClickHandler(); - expect(mockTelemetryDeck.clientUser).not.toBe(""); - expect(mockTelemetryDeck.clientUser).not.toBe(previousClientUser); - - previousClientUser = mockTelemetryDeck.clientUser; - await wrapper.find("#btnSetClient").trigger("click"); - await waitForClickHandler(); - expect(mockTelemetryDeck.clientUser).not.toBe(""); - expect(mockTelemetryDeck.clientUser).not.toBe(previousClientUser); - expect(wrapper.find("#lastTelemetryResult").text()).toBe( - "setClientUser completed", - ); + const randomSpy = vi + .spyOn(Math, "random") + .mockReturnValueOnce(0.694) + .mockReturnValueOnce(0.695); + + try { + mockTelemetryDeck.signal.mockResolvedValueOnce({ accepted: true }); + + await wrapper.find("#btnSignalClick").trigger("click"); + await waitForClickHandler(); + expect(mockTelemetryDeck.signal).toHaveBeenCalledWith( + "example_signal_event_name", + expect.objectContaining({ + custom_data: "other_data", + source: "signal", + timestamp: expect.any(String), + }), + undefined, + ); + expect(wrapper.find("#lastTelemetryResult").text()).toBe( + 'signal response: {"accepted":true}', + ); + + await wrapper.find("#btnQueueClick").trigger("click"); + await waitForClickHandler(); + expect(mockTelemetryDeck.queue).toHaveBeenCalledWith( + "example_queue_event_name", + expect.objectContaining({ + custom_data: "other_data", + source: "queue", + timestamp: expect.any(String), + }), + undefined, + ); + expect(wrapper.find("#lastTelemetryResult").text()).toBe( + "queue promise resolved without response", + ); + expect(wrapper.find("#queuedTelemetryEvents").text()).toBe("1"); + + await wrapper.find("#btnQueueClickWithOptions").trigger("click"); + await waitForClickHandler(); + expect(mockTelemetryDeck.queue).toHaveBeenCalledWith( + "example_queue_event_name_with_options", + expect.objectContaining({ + custom_data: "other_data", + source: "queue_with_options", + timestamp: expect.any(String), + }), + { testMode: true, clientUser: "other_user", appID: "other_app_id" }, + ); + + await wrapper.find("#btnSignalClickWithOptions").trigger("click"); + await waitForClickHandler(); + expect(mockTelemetryDeck.signal).toHaveBeenCalledWith( + "example_signal_event_name_with_options", + expect.objectContaining({ + custom_data: "other_data", + source: "signal_with_options", + timestamp: expect.any(String), + }), + { testMode: true, clientUser: "other_user", appID: "other_app_id" }, + ); + + await wrapper.find("#btnFlushClick").trigger("click"); + await waitForClickHandler(); + expect(mockTelemetryDeck.flush).toHaveBeenCalledTimes(1); + expect(wrapper.find("#lastTelemetryResult").text()).toBe( + "flush promise resolved without response", + ); + expect(wrapper.find("#queuedTelemetryEvents").text()).toBe("0"); + + await wrapper.find("#btnSafeSignalClick").trigger("click"); + await waitForClickHandler(); + expect(mockTelemetryDeck.signal).toHaveBeenCalledWith( + "example_safe_signal_event_name", + expect.objectContaining({ + source: "safe_signal", + }), + undefined, + ); + expect(wrapper.find("#lastTelemetryResult").text()).toBe( + "safeSignal promise resolved", + ); + + await wrapper.find("#btnSafeQueueClick").trigger("click"); + await waitForClickHandler(); + expect(mockTelemetryDeck.queue).toHaveBeenCalledWith( + "example_safe_queue_event_name", + expect.objectContaining({ + source: "safe_queue", + }), + undefined, + ); + expect(wrapper.find("#lastTelemetryResult").text()).toBe( + "safeQueue promise resolved", + ); + expect(wrapper.find("#queuedTelemetryEvents").text()).toBe("1"); + + await wrapper.find("#btnSafeFlushClick").trigger("click"); + await waitForClickHandler(); + expect(mockTelemetryDeck.flush).toHaveBeenCalledTimes(2); + expect(wrapper.find("#lastTelemetryResult").text()).toBe( + "safeFlush promise resolved", + ); + expect(wrapper.find("#queuedTelemetryEvents").text()).toBe("0"); + + let previousClientUser = mockTelemetryDeck.clientUser; + await wrapper.find("#btnSetClient").trigger("click"); + await waitForClickHandler(); + expect(mockTelemetryDeck.clientUser).toBe("user694"); + expect(mockTelemetryDeck.clientUser).not.toBe(""); + expect(mockTelemetryDeck.clientUser).not.toBe(previousClientUser); + + previousClientUser = mockTelemetryDeck.clientUser; + await wrapper.find("#btnSetClient").trigger("click"); + await waitForClickHandler(); + expect(mockTelemetryDeck.clientUser).toBe("user695"); + expect(mockTelemetryDeck.clientUser).not.toBe(""); + expect(mockTelemetryDeck.clientUser).not.toBe(previousClientUser); + expect(wrapper.find("#lastTelemetryResult").text()).toBe( + "setClientUser completed", + ); + } finally { + randomSpy.mockRestore(); + } }); it("displays TelemetryDeck errors captured by the demo onError handler", async () => { diff --git a/src/tests/hooks.test.ts b/src/tests/hooks.test.ts index 839b942..e84e7f1 100644 --- a/src/tests/hooks.test.ts +++ b/src/tests/hooks.test.ts @@ -1,7 +1,7 @@ import { mount } from "@vue/test-utils"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { defineComponent } from "vue"; -import { useTelemetryDeck } from "../hooks"; +import { useTelemetryDeck, type TelemetryDeckHooks } from "../hooks"; const mockTelemetryDeck = { clientUser: "test-user", @@ -51,7 +51,7 @@ describe("useTelemetryDeck safe methods", () => { }, }, }); - const vm = wrapper.vm as unknown as ReturnType; + const vm = wrapper.vm as unknown as TelemetryDeckHooks; await expect( vm.safeSignal("ui.opened", signalPayload), @@ -98,7 +98,7 @@ describe("useTelemetryDeck safe methods", () => { }, }, }); - const vm = wrapper.vm as unknown as ReturnType; + const vm = wrapper.vm as unknown as TelemetryDeckHooks; await expect(vm.safeSignal("ui.opened")).resolves.toBeUndefined(); await expect(vm.safeQueue("button.clicked")).resolves.toBeUndefined(); @@ -141,7 +141,7 @@ describe("useTelemetryDeck safe methods", () => { }, }, }); - const vm = wrapper.vm as unknown as ReturnType; + const vm = wrapper.vm as unknown as TelemetryDeckHooks; await expect(vm.safeSignal("ui.opened")).resolves.toBeUndefined(); await expect(vm.safeQueue("button.clicked")).resolves.toBeUndefined(); @@ -180,7 +180,7 @@ describe("useTelemetryDeck safe methods", () => { }, }, }); - const vm = wrapper.vm as unknown as ReturnType; + const vm = wrapper.vm as unknown as TelemetryDeckHooks; await expect(vm.signal("ui.opened")).rejects.toBe(signalError); await expect(vm.queue("button.clicked")).rejects.toBe(queueError); @@ -197,7 +197,7 @@ describe("useTelemetryDeck safe methods", () => { }, }, }); - const vm = wrapper.vm as unknown as ReturnType; + const vm = wrapper.vm as unknown as TelemetryDeckHooks; expect(vm.getQueueCount()).toBe(2); }); diff --git a/src/tests/plugin.test.ts b/src/tests/plugin.test.ts index f420161..369a059 100644 --- a/src/tests/plugin.test.ts +++ b/src/tests/plugin.test.ts @@ -1,7 +1,7 @@ import { mount } from "@vue/test-utils"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { defineComponent } from "vue"; -import { useTelemetryDeck } from "../hooks"; +import { useTelemetryDeck, type TelemetryDeckHooks } from "../hooks"; import { plugin } from "../plugin"; const { mockTelemetryDeck, telemetryDeckCtor } = vi.hoisted(() => { @@ -55,7 +55,7 @@ describe("plugin onError integration", () => { plugins: [[plugin, { appID: "test-app-id", onError }]], }, }); - const vm = wrapper.vm as unknown as ReturnType; + const vm = wrapper.vm as unknown as TelemetryDeckHooks; await expect(vm.safeSignal("ui.opened")).resolves.toBeUndefined(); await expect(vm.safeQueue("button.clicked")).resolves.toBeUndefined(); diff --git a/tests/exports/tsconfig.json b/tests/exports/tsconfig.json new file mode 100644 index 0000000..31f0d2e --- /dev/null +++ b/tests/exports/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.app.json", + "compilerOptions": { + "tsBuildInfoFile": "../../node_modules/.tmp/tsconfig.exports.tsbuildinfo" + }, + "include": ["types.ts"] +} diff --git a/tests/exports/types.ts b/tests/exports/types.ts new file mode 100644 index 0000000..c7f4de5 --- /dev/null +++ b/tests/exports/types.ts @@ -0,0 +1,21 @@ +import { + useTelemetryDeck, + type TelemetryDeckHooks, + type TelemetryDeckOptions, + type TelemetryDeckPayload, + type TelemetryDeckSafeSignal, + type TelemetryDeckSignal, +} from "@peerigon/telemetrydeck-vue"; + +const hooks: TelemetryDeckHooks = useTelemetryDeck(); +const signal: TelemetryDeckSignal = hooks.signal; +const safeSignal: TelemetryDeckSafeSignal = hooks.safeSignal; + +const payload: TelemetryDeckPayload = { source: "type-smoke-test" }; +const options: TelemetryDeckOptions = { + appID: "test-app-id", + clientUser: "test-user", +}; + +void signal("example.signal", payload, options); +void safeSignal("example.safeSignal", payload, options);