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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default tseslint.config({
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/consistent-type-imports": "error",
"@eslint-react/no-children-only": "off",
"@eslint-react/no-context-provider": "off",
"import/order": [
"error",
{
Expand Down
3 changes: 1 addition & 2 deletions packages/event-tracker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,7 @@
],
"devDependencies": {
"@types/react": "^19.1.10",
"react": "^19.1.1",
"zod": "^4.0.17"
"react": "^19.1.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
Expand Down
17 changes: 13 additions & 4 deletions packages/event-tracker/src/__tests__/debounce.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { render } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { z } from "zod";

import { createTracker } from "../tracker";
import { debounce } from "../utils/debounce";

import { sleep, anyFn } from "./utils";
import { sleep, anyFn, createSchema, isString, isObject } from "./utils";

describe("debounce", () => {
beforeEach(() => {
Expand Down Expand Up @@ -316,8 +315,18 @@
batch: { enable: false },
schema: {
schemas: {
clickSchema: z.object({
action: z.string(),
clickSchema: createSchema((value: unknown): { action: string } => {
if (!isObject(value)) {
throw new Error("Expected object");
}

const { action } = value;

if (!isString(action)) {
throw new Error("action must be a string");
}

return { action };
}),
},
},
Expand Down Expand Up @@ -502,7 +511,7 @@
const page = render(
<Track.Provider>
<Track.Click params={{ action: "component_test" }} debounce={{ delay: 80 }}>
<button>Component Button</button>

Check warning on line 514 in packages/event-tracker/src/__tests__/debounce.test.tsx

View workflow job for this annotation

GitHub Actions / CI (ci:lint)

Add missing 'type' attribute on 'button' component
</Track.Click>
</Track.Provider>,
);
Expand Down Expand Up @@ -537,7 +546,7 @@
const page = render(
<Track.Provider>
<Track.DOMEvent type="onClick" params={{ action: "click_test" }} debounce={{ delay: 60 }}>
<button>Click Test Button</button>

Check warning on line 549 in packages/event-tracker/src/__tests__/debounce.test.tsx

View workflow job for this annotation

GitHub Actions / CI (ci:lint)

Add missing 'type' attribute on 'button' component
</Track.DOMEvent>
</Track.Provider>,
);
Expand Down Expand Up @@ -572,7 +581,7 @@
const page = render(
<Track.Provider>
<Track.Click params={{ action: "no_debounce" }}>
<button>No Debounce Button</button>

Check warning on line 584 in packages/event-tracker/src/__tests__/debounce.test.tsx

View workflow job for this annotation

GitHub Actions / CI (ci:lint)

Add missing 'type' attribute on 'button' component
</Track.Click>
</Track.Provider>,
);
Expand Down
69 changes: 59 additions & 10 deletions packages/event-tracker/src/__tests__/schema.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { render, renderHook } from "@testing-library/react";
import { vi } from "vitest";
import { z } from "zod";

import { createTracker } from "..";

import { sleep } from "./utils";
import { createSchema, isNumber, isObject, isString, sleep } from "./utils";

const initFn = vi.fn();
const clickFn = vi.fn();
Expand All @@ -17,12 +16,28 @@ interface Context {
interface Params {}

const schemas = {
test_button_click: z
.object({
text: z.string(),
button_id: z.number(),
})
.strict(),
test_button_click: createSchema((value: unknown): { text: string; button_id: number } => {
if (!isObject(value)) {
throw new Error("Expected object");
}

const { text, button_id, ...rest } = value;

// strict validation - no extra properties allowed
if (Object.keys(rest).length > 0) {
throw new Error(`Unexpected properties: ${Object.keys(rest).join(", ")}`);
}

if (!isString(text)) {
throw new Error("text must be a string");
}

if (!isNumber(button_id)) {
throw new Error("button_id must be a number");
}

return { text, button_id };
}),
};

const [Track, useTracker] = createTracker<Context, Params, typeof schemas>({
Expand Down Expand Up @@ -63,7 +78,7 @@ describe("schemas", async () => {
expect(clickFn).toHaveBeenCalledWith({ text: "click", button_id: 2, userId: "id" });
});

it("throws error when schema is not defined in config", async () => {
it("validates schema and calls onSchemaError when validation fails", async () => {
const page = render(
<Track.Provider initialContext={{ userId: "id" }}>
{/* No 'button_id', so error expected */}
Expand All @@ -77,6 +92,40 @@ describe("schemas", async () => {
page.getByText("click").click();
await sleep(1);

expect(schemaErrorFn).toHaveBeenCalled();
expect(schemaErrorFn).toHaveBeenCalledWith([{ message: "button_id must be a number" }]);
});

it("validates schema and rejects extra properties (strict mode)", async () => {
const page = render(
<Track.Provider initialContext={{ userId: "id" }}>
{/* Extra property 'extra_field' should cause validation error */}
{/* @ts-expect-error */}
<Track.Click schema="test_button_click" params={{ text: "click", button_id: 2, extra_field: "invalid" }}>
<button type="button">click</button>
</Track.Click>
</Track.Provider>,
);

page.getByText("click").click();
await sleep(1);

expect(schemaErrorFn).toHaveBeenCalledWith([{ message: "Unexpected properties: extra_field" }]);
});

it("validates schema and rejects wrong types", async () => {
const page = render(
<Track.Provider initialContext={{ userId: "id" }}>
{/* Wrong type for button_id (string instead of number) */}
{/* @ts-expect-error */}
<Track.Click schema="test_button_click" params={{ text: "click", button_id: "not_a_number" }}>
<button type="button">click</button>
</Track.Click>
</Track.Provider>,
);

page.getByText("click").click();
await sleep(1);

expect(schemaErrorFn).toHaveBeenCalledWith([{ message: "button_id must be a number" }]);
});
});
17 changes: 13 additions & 4 deletions packages/event-tracker/src/__tests__/throttle.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";

import { createTracker } from "../tracker";
import { throttle } from "../utils/throttle";

import { sleep, anyFn } from "./utils";
import { sleep, anyFn, createSchema, isString, isObject } from "./utils";

describe("throttle", () => {
beforeEach(() => {
Expand Down Expand Up @@ -409,8 +408,18 @@ describe("Throttle Integration Tests", () => {
batch: { enable: false },
schema: {
schemas: {
clickSchema: z.object({
action: z.string(),
clickSchema: createSchema((value: unknown): { action: string } => {
if (!isObject(value)) {
throw new Error("Expected object");
}

const { action } = value;

if (!isString(action)) {
throw new Error("action must be a string");
}

return { action };
}),
},
},
Expand Down
28 changes: 28 additions & 0 deletions packages/event-tracker/src/__tests__/utils.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,30 @@
import { StandardSchemaV1 } from "..";

export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const anyFn = expect.any(Function);

// Standard Schema compliant schema definition
export function createSchema<T>(validator: (value: unknown) => T): StandardSchemaV1<unknown, T> {
return {
"~standard": {
version: 1,
vendor: "custom",
validate: (value: unknown) => {
try {
const result = validator(value);
return { value: result };
} catch (error) {
return {
issues: [{ message: error instanceof Error ? error.message : "Validation failed" }],
};
}
},
},
};
}

// Type guard functions
export const isString = (value: unknown): value is string => typeof value === "string";
export const isNumber = (value: unknown): value is number => typeof value === "number";
export const isObject = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value);
8 changes: 0 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading