mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
* feat: integrate external skills.sh listings * test: record permanent Test external flow * fix: distinguish GitHub alias source fingerprints * fix: match controlled skills.sh source URL * test: disambiguate external detail heading * fix: use folder hash for controlled skills.sh fixture * test: prepare controlled external fixture for proof * fix: preserve OpenClaw external trust state
937 lines
29 KiB
TypeScript
937 lines
29 KiB
TypeScript
/* @vitest-environment node */
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const telemetryRefs = vi.hoisted(() => ({
|
|
clearUserTelemetryInternal: Symbol("clearUserTelemetryInternal"),
|
|
pruneInstallTelemetryDedupesInternal: Symbol("pruneInstallTelemetryDedupesInternal"),
|
|
}));
|
|
|
|
vi.mock("./functions", () => ({
|
|
internalAction: (def: { handler: unknown }) => ({ _handler: def.handler }),
|
|
internalMutation: (def: { handler: unknown }) => ({ _handler: def.handler }),
|
|
internalQuery: (def: { handler: unknown }) => ({ _handler: def.handler }),
|
|
mutation: (def: { handler: unknown }) => ({ _handler: def.handler }),
|
|
query: (def: { handler: unknown }) => ({ _handler: def.handler }),
|
|
}));
|
|
|
|
vi.mock("./_generated/api", () => ({
|
|
internal: {
|
|
skillStatEvents: {
|
|
processSkillStatEventsAction: Symbol("processSkillStatEventsAction"),
|
|
processSkillStatEventsInternal: Symbol("processSkillStatEventsInternal"),
|
|
},
|
|
telemetry: telemetryRefs,
|
|
},
|
|
}));
|
|
|
|
const {
|
|
__test,
|
|
clearUserTelemetryInternal,
|
|
pruneInstallTelemetryDedupesInternal,
|
|
reportCliInstallInternal,
|
|
reportCliLegacyInstallBatchInternal,
|
|
reportCliPluginInstallInternal,
|
|
} = await import("./telemetry");
|
|
|
|
const reportCliInstallHandler = (
|
|
reportCliInstallInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: {
|
|
userId: string;
|
|
slug: string;
|
|
ownerHandle?: string;
|
|
sourceRef?: string;
|
|
sourceKind?: "skills-sh";
|
|
sourceRepository?: string;
|
|
sourcePath?: string;
|
|
sourceUrl?: string;
|
|
canonicalRef?: string;
|
|
clawhubScan?: "unscanned" | "scanned";
|
|
trustLabel?: string;
|
|
version?: string;
|
|
},
|
|
) => Promise<void>;
|
|
}
|
|
)._handler;
|
|
|
|
const reportCliLegacyInstallBatchHandler = (
|
|
reportCliLegacyInstallBatchInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: {
|
|
userId: string;
|
|
skills: Array<{ slug: string; version?: string }>;
|
|
},
|
|
) => Promise<void>;
|
|
}
|
|
)._handler;
|
|
|
|
const reportCliPluginInstallHandler = (
|
|
reportCliPluginInstallInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: {
|
|
userId: string;
|
|
packageName: string;
|
|
version?: string;
|
|
},
|
|
) => Promise<void>;
|
|
}
|
|
)._handler;
|
|
|
|
const clearUserTelemetryHandler = (
|
|
clearUserTelemetryInternal as unknown as {
|
|
_handler: (ctx: unknown, args: { userId: string; clearStartedAt?: number }) => Promise<void>;
|
|
}
|
|
)._handler;
|
|
|
|
const pruneInstallTelemetryDedupesHandler = (
|
|
pruneInstallTelemetryDedupesInternal as unknown as {
|
|
_handler: (ctx: unknown) => Promise<{ deleted: number; hasMore: boolean }>;
|
|
}
|
|
)._handler;
|
|
|
|
function makeIndexBuilder() {
|
|
const builder = {
|
|
eq: vi.fn(() => builder),
|
|
lt: vi.fn(() => builder),
|
|
lte: vi.fn(() => builder),
|
|
};
|
|
return builder;
|
|
}
|
|
|
|
function makeInstallCtx(params: {
|
|
skills: Array<{ _id: string; slug: string; softDeletedAt?: number } | null>;
|
|
dedupes: Array<Record<string, unknown> | null>;
|
|
installs: Array<Record<string, unknown> | null>;
|
|
}) {
|
|
const skills = [...params.skills];
|
|
const dedupes = [...params.dedupes];
|
|
const installs = [...params.installs];
|
|
const insert = vi.fn();
|
|
const patch = vi.fn();
|
|
const query = vi.fn((table: string) => ({
|
|
withIndex: vi.fn(
|
|
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
|
callback(makeIndexBuilder());
|
|
if (table === "skills" && indexName === "by_slug") {
|
|
return {
|
|
unique: async () => skills.shift() ?? null,
|
|
take: async () => {
|
|
const skill = skills.shift() ?? null;
|
|
return skill ? [skill] : [];
|
|
},
|
|
};
|
|
}
|
|
if (table === "installTelemetryDedupes" && indexName === "by_user_skill_day") {
|
|
return { unique: async () => dedupes.shift() ?? null };
|
|
}
|
|
if (table === "userSkillInstalls" && indexName === "by_user_skill") {
|
|
return { unique: async () => installs.shift() ?? null };
|
|
}
|
|
throw new Error(`unexpected query ${table}.${indexName}`);
|
|
},
|
|
),
|
|
}));
|
|
|
|
return { ctx: { db: { insert, patch, query } }, insert, patch, query };
|
|
}
|
|
|
|
describe("telemetry install events", () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("records legacy snapshot batches as rootless user-skill installs", async () => {
|
|
const { ctx, insert } = makeInstallCtx({
|
|
skills: [
|
|
{ _id: "skills:weather", slug: "weather" },
|
|
{ _id: "skills:calendar", slug: "calendar" },
|
|
],
|
|
dedupes: [null, null],
|
|
installs: [null, null],
|
|
});
|
|
|
|
await reportCliLegacyInstallBatchHandler(ctx, {
|
|
userId: "users:one",
|
|
skills: [{ slug: "weather", version: "1.0.0" }, { slug: "calendar" }],
|
|
});
|
|
|
|
expect(insert).toHaveBeenCalledTimes(6);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"installTelemetryDedupes",
|
|
expect.objectContaining({
|
|
userId: "users:one",
|
|
skillId: "skills:weather",
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"userSkillInstalls",
|
|
expect.objectContaining({
|
|
userId: "users:one",
|
|
skillId: "skills:weather",
|
|
lastVersion: "1.0.0",
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"skillStatEvents",
|
|
expect.objectContaining({ skillId: "skills:calendar", kind: "install_new" }),
|
|
);
|
|
});
|
|
|
|
it("records the first CLI install without root state", async () => {
|
|
const { ctx, insert } = makeInstallCtx({
|
|
skills: [{ _id: "skills:demo", slug: "demo" }],
|
|
dedupes: [null],
|
|
installs: [null],
|
|
});
|
|
|
|
await reportCliInstallHandler(ctx, {
|
|
userId: "users:one",
|
|
slug: "demo",
|
|
version: "1.0.0",
|
|
});
|
|
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"installTelemetryDedupes",
|
|
expect.objectContaining({
|
|
userId: "users:one",
|
|
skillId: "skills:demo",
|
|
dayStart: expect.any(Number),
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith("userSkillInstalls", {
|
|
userId: "users:one",
|
|
skillId: "skills:demo",
|
|
firstSeenAt: expect.any(Number),
|
|
lastSeenAt: expect.any(Number),
|
|
lastVersion: "1.0.0",
|
|
});
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"skillStatEvents",
|
|
expect.objectContaining({ skillId: "skills:demo", kind: "install_new" }),
|
|
);
|
|
});
|
|
|
|
it("records the first plugin install for a canonical scoped package", async () => {
|
|
const insert = vi.fn(async (table: string) =>
|
|
table === "userPackageInstalls" ? "userPackageInstalls:one" : "packageStatEvents:one",
|
|
);
|
|
const patch = vi.fn();
|
|
const packageDoc = {
|
|
_id: "packages:voice-call",
|
|
normalizedName: "@openclaw/voice-call",
|
|
};
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn(
|
|
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
|
callback(makeIndexBuilder());
|
|
if (table === "packages" && indexName === "by_name") {
|
|
return { unique: async () => packageDoc };
|
|
}
|
|
if (table === "userPackageInstalls" && indexName === "by_user_package") {
|
|
return { unique: async () => null };
|
|
}
|
|
throw new Error(`unexpected query ${table}.${indexName}`);
|
|
},
|
|
),
|
|
})),
|
|
insert,
|
|
patch,
|
|
},
|
|
};
|
|
|
|
await reportCliPluginInstallHandler(ctx, {
|
|
userId: "users:one",
|
|
packageName: "@OpenClaw/Voice-Call",
|
|
version: "2026.7.23",
|
|
});
|
|
|
|
expect(insert).toHaveBeenCalledWith("userPackageInstalls", {
|
|
userId: "users:one",
|
|
packageId: "packages:voice-call",
|
|
firstSeenAt: expect.any(Number),
|
|
lastSeenAt: expect.any(Number),
|
|
lastVersion: "2026.7.23",
|
|
});
|
|
expect(insert).toHaveBeenCalledWith("packageStatEvents", {
|
|
packageId: "packages:voice-call",
|
|
kind: "install",
|
|
occurredAt: expect.any(Number),
|
|
processedAt: undefined,
|
|
});
|
|
expect(patch).toHaveBeenCalledWith("userPackageInstalls:one", {
|
|
metricRecordedAt: expect.any(Number),
|
|
});
|
|
});
|
|
|
|
it("updates repeated plugin installs without incrementing package metrics", async () => {
|
|
const insert = vi.fn();
|
|
const patch = vi.fn();
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn(
|
|
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
|
callback(makeIndexBuilder());
|
|
if (table === "packages" && indexName === "by_name") {
|
|
return {
|
|
unique: async () => ({
|
|
_id: "packages:voice-call",
|
|
normalizedName: "@openclaw/voice-call",
|
|
}),
|
|
};
|
|
}
|
|
if (table === "userPackageInstalls" && indexName === "by_user_package") {
|
|
return {
|
|
unique: async () => ({
|
|
_id: "userPackageInstalls:one",
|
|
lastVersion: "2026.7.22",
|
|
metricRecordedAt: 123,
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`unexpected query ${table}.${indexName}`);
|
|
},
|
|
),
|
|
})),
|
|
insert,
|
|
patch,
|
|
},
|
|
};
|
|
|
|
await reportCliPluginInstallHandler(ctx, {
|
|
userId: "users:one",
|
|
packageName: "@openclaw/voice-call",
|
|
version: "2026.7.23",
|
|
});
|
|
|
|
expect(patch).toHaveBeenCalledWith("userPackageInstalls:one", {
|
|
lastSeenAt: expect.any(Number),
|
|
lastVersion: "2026.7.23",
|
|
});
|
|
expect(insert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("retries a pending package metric on a repeated plugin install", async () => {
|
|
const insert = vi.fn();
|
|
const patch = vi.fn();
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn(
|
|
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
|
callback(makeIndexBuilder());
|
|
if (table === "packages" && indexName === "by_name") {
|
|
return {
|
|
unique: async () => ({
|
|
_id: "packages:voice-call",
|
|
normalizedName: "@openclaw/voice-call",
|
|
}),
|
|
};
|
|
}
|
|
if (table === "userPackageInstalls" && indexName === "by_user_package") {
|
|
return {
|
|
unique: async () => ({
|
|
_id: "userPackageInstalls:one",
|
|
lastVersion: "2026.7.22",
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`unexpected query ${table}.${indexName}`);
|
|
},
|
|
),
|
|
})),
|
|
insert,
|
|
patch,
|
|
},
|
|
};
|
|
|
|
await reportCliPluginInstallHandler(ctx, {
|
|
userId: "users:one",
|
|
packageName: "@openclaw/voice-call",
|
|
version: "2026.7.23",
|
|
});
|
|
|
|
expect(insert).toHaveBeenCalledWith("packageStatEvents", {
|
|
packageId: "packages:voice-call",
|
|
kind: "install",
|
|
occurredAt: expect.any(Number),
|
|
processedAt: undefined,
|
|
});
|
|
expect(patch).toHaveBeenCalledWith("userPackageInstalls:one", {
|
|
metricRecordedAt: expect.any(Number),
|
|
});
|
|
});
|
|
|
|
it("ignores unknown or deleted plugin packages", async () => {
|
|
const insert = vi.fn();
|
|
const packages = [
|
|
null,
|
|
{
|
|
_id: "packages:deleted",
|
|
normalizedName: "@openclaw/deleted",
|
|
softDeletedAt: 123,
|
|
},
|
|
];
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn(() => ({
|
|
withIndex: vi.fn(
|
|
(_indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
|
callback(makeIndexBuilder());
|
|
return { unique: async () => packages.shift() ?? null };
|
|
},
|
|
),
|
|
})),
|
|
insert,
|
|
patch: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await reportCliPluginInstallHandler(ctx, {
|
|
userId: "users:one",
|
|
packageName: "@openclaw/missing",
|
|
});
|
|
await reportCliPluginInstallHandler(ctx, {
|
|
userId: "users:one",
|
|
packageName: "@openclaw/deleted",
|
|
});
|
|
|
|
expect(insert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps package metric queueing best-effort after persisting the install relationship", async () => {
|
|
const insert = vi.fn(async (table: string) => {
|
|
if (table === "packageStatEvents") {
|
|
throw new Error("metrics unavailable");
|
|
}
|
|
return "userPackageInstalls:one";
|
|
});
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn(
|
|
(_indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
|
callback(makeIndexBuilder());
|
|
return {
|
|
unique: async () =>
|
|
table === "packages"
|
|
? { _id: "packages:voice-call", normalizedName: "@openclaw/voice-call" }
|
|
: null,
|
|
};
|
|
},
|
|
),
|
|
})),
|
|
insert,
|
|
patch: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
reportCliPluginInstallHandler(ctx, {
|
|
userId: "users:one",
|
|
packageName: "@openclaw/voice-call",
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"userPackageInstalls",
|
|
expect.objectContaining({ packageId: "packages:voice-call" }),
|
|
);
|
|
});
|
|
|
|
it("does not attribute an unclaimed skills.sh install to a same-slug native skill", async () => {
|
|
const query = vi.fn();
|
|
const insert = vi.fn();
|
|
const ctx = { db: { query, insert, patch: vi.fn() } };
|
|
|
|
await reportCliInstallHandler(ctx, {
|
|
userId: "users:one",
|
|
slug: "demo",
|
|
sourceRef: "skills-sh:alice/skills/demo",
|
|
sourceKind: "skills-sh",
|
|
sourceRepository: "alice/skills",
|
|
sourcePath: "skills/demo",
|
|
sourceUrl: "https://github.com/alice/skills/tree/abc/skills/demo",
|
|
clawhubScan: "unscanned",
|
|
trustLabel: "Not scanned by ClawHub",
|
|
version: "a".repeat(40),
|
|
});
|
|
|
|
expect(query).not.toHaveBeenCalled();
|
|
expect(insert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses owner identity when recording an owner-qualified install", async () => {
|
|
const publisher = {
|
|
_id: "publishers:alice",
|
|
handle: "alice",
|
|
kind: "user",
|
|
};
|
|
const skill = {
|
|
_id: "skills:alice-demo",
|
|
slug: "demo",
|
|
ownerPublisherId: publisher._id,
|
|
};
|
|
const insert = vi.fn();
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn(
|
|
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
|
callback(makeIndexBuilder());
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return { unique: async () => publisher };
|
|
}
|
|
if (table === "skills" && indexName === "by_owner_publisher_slug") {
|
|
return { unique: async () => skill };
|
|
}
|
|
if (table === "installTelemetryDedupes" && indexName === "by_user_skill_day") {
|
|
return { unique: async () => null };
|
|
}
|
|
if (table === "userSkillInstalls" && indexName === "by_user_skill") {
|
|
return { unique: async () => null };
|
|
}
|
|
throw new Error(`unexpected query ${table}.${indexName}`);
|
|
},
|
|
),
|
|
})),
|
|
insert,
|
|
patch: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await reportCliInstallHandler(ctx, {
|
|
userId: "users:one",
|
|
slug: "demo",
|
|
ownerHandle: "alice",
|
|
version: "1.0.0",
|
|
});
|
|
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"userSkillInstalls",
|
|
expect.objectContaining({ skillId: "skills:alice-demo" }),
|
|
);
|
|
});
|
|
|
|
it("resolves an owner-qualified slug alias before recording an install", async () => {
|
|
const publisher = {
|
|
_id: "publishers:source",
|
|
handle: "source",
|
|
kind: "org",
|
|
};
|
|
const skill = {
|
|
_id: "skills:target-demo",
|
|
slug: "demo",
|
|
ownerPublisherId: "publishers:target",
|
|
};
|
|
const alias = {
|
|
_id: "skillSlugAliases:source-old-demo",
|
|
slug: "old-demo",
|
|
ownerPublisherId: publisher._id,
|
|
skillId: skill._id,
|
|
};
|
|
const insert = vi.fn();
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => (id === skill._id ? skill : null)),
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn(
|
|
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
|
callback(makeIndexBuilder());
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return { unique: async () => publisher };
|
|
}
|
|
if (table === "skills" && indexName === "by_owner_publisher_slug") {
|
|
return { unique: async () => null };
|
|
}
|
|
if (table === "skillSlugAliases" && indexName === "by_owner_publisher_slug") {
|
|
return { unique: async () => alias };
|
|
}
|
|
if (table === "installTelemetryDedupes" && indexName === "by_user_skill_day") {
|
|
return { unique: async () => null };
|
|
}
|
|
if (table === "userSkillInstalls" && indexName === "by_user_skill") {
|
|
return { unique: async () => null };
|
|
}
|
|
throw new Error(`unexpected query ${table}.${indexName}`);
|
|
},
|
|
),
|
|
})),
|
|
insert,
|
|
patch: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await reportCliInstallHandler(ctx, {
|
|
userId: "users:one",
|
|
slug: "old-demo",
|
|
ownerHandle: "source",
|
|
version: "1.0.0",
|
|
});
|
|
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"userSkillInstalls",
|
|
expect.objectContaining({ skillId: "skills:target-demo" }),
|
|
);
|
|
});
|
|
|
|
it("skips ambiguous bare slugs instead of failing or guessing an owner", async () => {
|
|
const insert = vi.fn();
|
|
const duplicateSkills = [
|
|
{ _id: "skills:alice-demo", slug: "demo" },
|
|
{ _id: "skills:bob-demo", slug: "demo" },
|
|
];
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn(
|
|
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
|
callback(makeIndexBuilder());
|
|
if (table === "skills" && indexName === "by_slug") {
|
|
return {
|
|
unique: async () => {
|
|
throw new Error("unique query matched multiple skills");
|
|
},
|
|
take: async () => duplicateSkills,
|
|
};
|
|
}
|
|
throw new Error(`unexpected query ${table}.${indexName}`);
|
|
},
|
|
),
|
|
})),
|
|
insert,
|
|
patch: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
reportCliLegacyInstallBatchHandler(ctx, {
|
|
userId: "users:one",
|
|
skills: [{ slug: "demo", version: "1.0.0" }],
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
expect(insert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips bare slugs when more matches exist beyond the inspection cap", async () => {
|
|
const insert = vi.fn();
|
|
const candidates = [
|
|
{ _id: "skills:first-active", slug: "demo" },
|
|
...Array.from({ length: 24 }, (_, index) => ({
|
|
_id: `skills:deleted-${index}`,
|
|
slug: "demo",
|
|
softDeletedAt: index + 1,
|
|
})),
|
|
{ _id: "skills:second-active", slug: "demo" },
|
|
];
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn(
|
|
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
|
callback(makeIndexBuilder());
|
|
if (table === "skills" && indexName === "by_slug") {
|
|
return { take: async (limit: number) => candidates.slice(0, limit) };
|
|
}
|
|
throw new Error(`unexpected query ${table}.${indexName}`);
|
|
},
|
|
),
|
|
})),
|
|
insert,
|
|
patch: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await reportCliLegacyInstallBatchHandler(ctx, {
|
|
userId: "users:one",
|
|
skills: [{ slug: "demo", version: "1.0.0" }],
|
|
});
|
|
|
|
expect(insert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps repeated CLI install events idempotent per user and skill", async () => {
|
|
const { ctx, insert, patch } = makeInstallCtx({
|
|
skills: [{ _id: "skills:demo", slug: "demo" }],
|
|
dedupes: [null],
|
|
installs: [
|
|
{
|
|
_id: "userSkillInstalls:one",
|
|
userId: "users:one",
|
|
skillId: "skills:demo",
|
|
lastVersion: "1.0.0",
|
|
},
|
|
],
|
|
});
|
|
|
|
await reportCliInstallHandler(ctx, {
|
|
userId: "users:one",
|
|
slug: "demo",
|
|
version: "1.0.1",
|
|
});
|
|
|
|
expect(patch).toHaveBeenCalledWith("userSkillInstalls:one", {
|
|
lastSeenAt: expect.any(Number),
|
|
lastVersion: "1.0.1",
|
|
});
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"installTelemetryDedupes",
|
|
expect.objectContaining({ userId: "users:one", skillId: "skills:demo" }),
|
|
);
|
|
expect(insert).not.toHaveBeenCalledWith("skillStatEvents", expect.anything());
|
|
});
|
|
|
|
it("dedupes repeated install telemetry for the same user, skill, and day", async () => {
|
|
vi.setSystemTime(86_500_000);
|
|
const skill = { _id: "skills:demo", slug: "demo" };
|
|
const insert = vi.fn();
|
|
const patch = vi.fn();
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn(
|
|
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
|
const builder = makeIndexBuilder();
|
|
callback(builder);
|
|
if (table === "skills" && indexName === "by_slug") {
|
|
return { unique: async () => skill, take: async () => [skill] };
|
|
}
|
|
if (table === "installTelemetryDedupes" && indexName === "by_user_skill_day") {
|
|
expect(builder.eq).toHaveBeenCalledWith("userId", "users:one");
|
|
expect(builder.eq).toHaveBeenCalledWith("skillId", "skills:demo");
|
|
expect(builder.eq).toHaveBeenCalledWith("dayStart", 86_400_000);
|
|
return { unique: async () => ({ _id: "installTelemetryDedupes:existing" }) };
|
|
}
|
|
throw new Error(`unexpected query ${table}.${indexName}`);
|
|
},
|
|
),
|
|
})),
|
|
insert,
|
|
patch,
|
|
},
|
|
};
|
|
|
|
await reportCliInstallHandler(ctx, {
|
|
userId: "users:one",
|
|
slug: "demo",
|
|
version: "1.0.1",
|
|
});
|
|
|
|
expect(insert).not.toHaveBeenCalled();
|
|
expect(patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("clears installs and dedupe rows", async () => {
|
|
const insert = vi.fn();
|
|
const deleteDoc = vi.fn();
|
|
const installs = [
|
|
{ _id: "installs:one", skillId: "skills:one" },
|
|
{ _id: "installs:two", skillId: "skills:two" },
|
|
];
|
|
const packageInstalls = [
|
|
{
|
|
_id: "userPackageInstalls:one",
|
|
packageId: "packages:one",
|
|
metricRecordedAt: 86_500_000,
|
|
},
|
|
{
|
|
_id: "userPackageInstalls:pending",
|
|
packageId: "packages:pending",
|
|
},
|
|
];
|
|
const dedupes = [{ _id: "installTelemetryDedupes:one" }];
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn(
|
|
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
|
callback(makeIndexBuilder());
|
|
if (table === "userSkillInstalls" && indexName === "by_user_lastSeenAt") {
|
|
return { take: async () => installs };
|
|
}
|
|
if (table === "userPackageInstalls" && indexName === "by_user_lastSeenAt") {
|
|
return { take: async () => packageInstalls };
|
|
}
|
|
if (table === "installTelemetryDedupes" && indexName === "by_user_createdAt") {
|
|
return { take: async () => dedupes };
|
|
}
|
|
throw new Error(`unexpected query ${table}.${indexName}`);
|
|
},
|
|
),
|
|
})),
|
|
get: vi.fn(async (id: string) => ({ _id: id })),
|
|
insert,
|
|
delete: deleteDoc,
|
|
},
|
|
scheduler: { runAfter: vi.fn() },
|
|
};
|
|
|
|
await clearUserTelemetryHandler(ctx, { userId: "users:one", clearStartedAt: 123 });
|
|
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"skillStatEvents",
|
|
expect.objectContaining({
|
|
skillId: "skills:one",
|
|
kind: "install_clear",
|
|
delta: { allTime: -1, current: -1 },
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"skillStatEvents",
|
|
expect.objectContaining({
|
|
skillId: "skills:two",
|
|
kind: "install_clear",
|
|
delta: { allTime: -1, current: -1 },
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith("packageStatEvents", {
|
|
packageId: "packages:one",
|
|
kind: "install_clear",
|
|
occurredAt: 86_500_000,
|
|
processedAt: undefined,
|
|
});
|
|
expect(insert.mock.calls.filter(([table]) => table === "packageStatEvents")).toHaveLength(1);
|
|
expect(deleteDoc).toHaveBeenCalledTimes(5);
|
|
expect(deleteDoc).toHaveBeenCalledWith("userPackageInstalls:one");
|
|
expect(deleteDoc).toHaveBeenCalledWith("userPackageInstalls:pending");
|
|
expect(deleteDoc).toHaveBeenCalledWith("installTelemetryDedupes:one");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
table: "userSkillInstalls",
|
|
indexName: "by_user_lastSeenAt",
|
|
batchSize: 5_000,
|
|
laterTables: ["userPackageInstalls", "installTelemetryDedupes"],
|
|
},
|
|
{
|
|
table: "userPackageInstalls",
|
|
indexName: "by_user_lastSeenAt",
|
|
batchSize: 5_000,
|
|
laterTables: ["installTelemetryDedupes"],
|
|
},
|
|
{
|
|
table: "installTelemetryDedupes",
|
|
indexName: "by_user_createdAt",
|
|
batchSize: 10_000,
|
|
laterTables: [],
|
|
},
|
|
])(
|
|
"reschedules user telemetry clearing after a full $table batch before reading later tables",
|
|
async ({ table, indexName, batchSize, laterTables }) => {
|
|
const rows = Array.from({ length: batchSize }, (_, index) => ({
|
|
_id: `${table}:${index}`,
|
|
skillId: "skills:demo",
|
|
}));
|
|
const queriedTables: string[] = [];
|
|
const deleteDoc = vi.fn();
|
|
const runAfter = vi.fn();
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async () => ({ _id: "skills:demo" })),
|
|
query: vi.fn((queriedTable: string) => {
|
|
queriedTables.push(queriedTable);
|
|
return {
|
|
withIndex: vi.fn(
|
|
(
|
|
actualIndexName: string,
|
|
callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown,
|
|
) => {
|
|
if (queriedTable === table) {
|
|
expect(actualIndexName).toBe(indexName);
|
|
}
|
|
callback(makeIndexBuilder());
|
|
return {
|
|
take: async () => (queriedTable === table ? rows : []),
|
|
};
|
|
},
|
|
),
|
|
};
|
|
}),
|
|
insert: vi.fn(),
|
|
delete: deleteDoc,
|
|
},
|
|
scheduler: { runAfter },
|
|
};
|
|
|
|
await clearUserTelemetryHandler(ctx, { userId: "users:one", clearStartedAt: 123 });
|
|
|
|
expect(deleteDoc).toHaveBeenCalledTimes(batchSize);
|
|
expect(runAfter).toHaveBeenCalledWith(0, telemetryRefs.clearUserTelemetryInternal, {
|
|
userId: "users:one",
|
|
clearStartedAt: 123,
|
|
});
|
|
for (const laterTable of laterTables) {
|
|
expect(queriedTables).not.toContain(laterTable);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("prunes stale install telemetry dedupe rows by day bucket", async () => {
|
|
vi.setSystemTime(20 * 86_400_000);
|
|
const stale = [{ _id: "installTelemetryDedupes:one" }, { _id: "installTelemetryDedupes:two" }];
|
|
const deleteDoc = vi.fn();
|
|
const take = vi.fn(async () => stale);
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn(
|
|
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
|
expect(table).toBe("installTelemetryDedupes");
|
|
expect(indexName).toBe("by_day");
|
|
const builder = makeIndexBuilder();
|
|
callback(builder);
|
|
expect(builder.lt).toHaveBeenCalledWith("dayStart", 6 * 86_400_000);
|
|
return { take };
|
|
},
|
|
),
|
|
})),
|
|
delete: deleteDoc,
|
|
},
|
|
scheduler: { runAfter: vi.fn() },
|
|
};
|
|
|
|
const result = await pruneInstallTelemetryDedupesHandler(ctx);
|
|
|
|
expect(take).toHaveBeenCalledWith(500);
|
|
expect(result).toEqual({ deleted: 2, hasMore: false });
|
|
expect(deleteDoc).toHaveBeenCalledWith("installTelemetryDedupes:one");
|
|
expect(deleteDoc).toHaveBeenCalledWith("installTelemetryDedupes:two");
|
|
});
|
|
|
|
it("reschedules stale dedupe pruning when one bounded batch fills", async () => {
|
|
vi.setSystemTime(20 * 86_400_000);
|
|
const stale = Array.from({ length: 500 }, (_, index) => ({
|
|
_id: `installTelemetryDedupes:${index}`,
|
|
}));
|
|
const runAfter = vi.fn();
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn(() => ({
|
|
withIndex: vi.fn(() => ({ take: async () => stale })),
|
|
})),
|
|
delete: vi.fn(),
|
|
},
|
|
scheduler: { runAfter },
|
|
};
|
|
|
|
const result = await pruneInstallTelemetryDedupesHandler(ctx);
|
|
|
|
expect(result).toEqual({ deleted: 500, hasMore: true });
|
|
expect(runAfter).toHaveBeenCalledWith(
|
|
0,
|
|
telemetryRefs.pruneInstallTelemetryDedupesInternal,
|
|
{},
|
|
);
|
|
});
|
|
|
|
it("computes UTC day starts", () => {
|
|
expect(__test.getDayStart(86_399_999)).toBe(0);
|
|
expect(__test.getDayStart(86_400_000)).toBe(86_400_000);
|
|
});
|
|
});
|