mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
* feat: ingest skill presentation metadata * feat: render skill icons and clean titles * feat: add skill presentation backfill * fix: preserve hosted icons during backfill * docs: clarify backfill icon ownership * fix: track skill presentation provenance
851 lines
28 KiB
TypeScript
851 lines
28 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@convex-dev/auth/server", () => ({
|
|
getAuthUserId: vi.fn(),
|
|
authTables: {},
|
|
}));
|
|
|
|
import { insertVersion } from "./skills";
|
|
|
|
type WrappedHandler<TArgs> = {
|
|
_handler: (ctx: unknown, args: TArgs) => Promise<unknown>;
|
|
};
|
|
|
|
const insertVersionHandler = (insertVersion as unknown as WrappedHandler<Record<string, unknown>>)
|
|
._handler;
|
|
|
|
const OWNER_USER_ID = "users:owner";
|
|
const OWNER_PUBLISHER_ID = "publishers:owner";
|
|
const SKILL_ID = "skills:1";
|
|
const PREV_LATEST_VERSION_ID = "skillVersions:prev";
|
|
const PREV_EMBEDDING_ID = "skillEmbeddings:prev";
|
|
const NEW_VERSION_ID = "skillVersions:new";
|
|
const NEW_EMBEDDING_ID = "skillEmbeddings:new";
|
|
|
|
type SkillDoc = {
|
|
_id: string;
|
|
slug: string;
|
|
displayName: string;
|
|
summary: string;
|
|
ownerUserId: string;
|
|
ownerPublisherId: string;
|
|
latestVersionId: string | undefined;
|
|
latestVersionSummary:
|
|
| {
|
|
version: string;
|
|
createdAt: number;
|
|
changelog: string;
|
|
changelogSource?: "auto" | "user";
|
|
clawdis?: unknown;
|
|
}
|
|
| undefined;
|
|
tags: Record<string, string>;
|
|
capabilityTags: string[] | undefined;
|
|
stats: {
|
|
downloads: number;
|
|
installsCurrent: number;
|
|
installsAllTime: number;
|
|
stars: number;
|
|
versions: number;
|
|
comments: number;
|
|
};
|
|
badges: Record<string, unknown>;
|
|
moderationStatus: string;
|
|
moderationReason: string;
|
|
moderationFlags: string[] | undefined;
|
|
isSuspicious: boolean;
|
|
softDeletedAt: number | undefined;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
manualOverride?: unknown;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
function buildExistingSkill(overrides: Partial<SkillDoc> = {}): SkillDoc {
|
|
return {
|
|
_id: SKILL_ID,
|
|
slug: "my-skill",
|
|
displayName: "My Skill v2",
|
|
summary: "Summary of v2.0.0",
|
|
ownerUserId: OWNER_USER_ID,
|
|
ownerPublisherId: OWNER_PUBLISHER_ID,
|
|
latestVersionId: PREV_LATEST_VERSION_ID,
|
|
latestVersionSummary: {
|
|
version: "2.0.0",
|
|
createdAt: 1,
|
|
changelog: "Major release",
|
|
changelogSource: "user",
|
|
clawdis: {},
|
|
},
|
|
tags: { latest: PREV_LATEST_VERSION_ID },
|
|
capabilityTags: ["cap-v2"],
|
|
categories: ["development"],
|
|
topics: ["typescript"],
|
|
stats: {
|
|
downloads: 0,
|
|
installsCurrent: 0,
|
|
installsAllTime: 0,
|
|
stars: 0,
|
|
versions: 1,
|
|
comments: 0,
|
|
},
|
|
badges: {
|
|
redactionApproved: undefined,
|
|
highlighted: undefined,
|
|
official: undefined,
|
|
deprecated: undefined,
|
|
},
|
|
moderationStatus: "active",
|
|
moderationReason: "pending.scan",
|
|
moderationFlags: undefined,
|
|
isSuspicious: false,
|
|
softDeletedAt: undefined,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
manualOverride: undefined,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function buildPublishArgs(overrides?: Partial<Record<string, unknown>>) {
|
|
return {
|
|
userId: OWNER_USER_ID,
|
|
slug: "my-skill",
|
|
displayName: "My Skill v1 backport",
|
|
version: "1.0.1",
|
|
changelog: "Backport fix",
|
|
changelogSource: "user",
|
|
tags: [] as string[],
|
|
capabilityTags: ["cap-v1-backport"],
|
|
categories: ["operations"],
|
|
topics: ["backport-only"],
|
|
summary: "Summary of v1.0.1 backport",
|
|
fingerprint: "f".repeat(64),
|
|
files: [
|
|
{
|
|
path: "SKILL.md",
|
|
size: 128,
|
|
storageId: "_storage:1",
|
|
sha256: "a".repeat(64),
|
|
contentType: "text/markdown",
|
|
},
|
|
],
|
|
parsed: {
|
|
frontmatter: { description: "backport summary from frontmatter" },
|
|
metadata: {},
|
|
clawdis: {},
|
|
},
|
|
staticScan: {
|
|
status: "clean",
|
|
reasonCodes: [],
|
|
findings: [],
|
|
summary: "",
|
|
engineVersion: "v2.2.0",
|
|
checkedAt: Date.now(),
|
|
},
|
|
embedding: [0.1, 0.2],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
type Captured = {
|
|
skillPatches: Array<Record<string, unknown>>;
|
|
embeddingInserts: Array<Record<string, unknown>>;
|
|
embeddingPatches: Array<{ id: string; value: Record<string, unknown> }>;
|
|
versionInserted: Record<string, unknown> | null;
|
|
allPatches: Array<{ id: string; value: Record<string, unknown> }>;
|
|
};
|
|
|
|
function buildDb(
|
|
skill: SkillDoc,
|
|
captured: Captured,
|
|
existingVersion?: Record<string, unknown> | null,
|
|
) {
|
|
// Trigger-driven code (syncSkillSearchDigestForSkill -> getOwnerPublisher)
|
|
// will ask for publishers via `db.get(ownerPublisherId)`. Return null so
|
|
// getOwnerPublisher falls back to resolving the publisher from the owner user.
|
|
const publisherTableQuery = () => ({
|
|
withIndex: () => ({
|
|
unique: async () => null,
|
|
take: async () => [],
|
|
}),
|
|
});
|
|
|
|
const db = {
|
|
get: vi.fn(async (arg0: string, arg1?: string) => {
|
|
// triggers.wrapDB calls innerDb.get(tableName, id) for tables with
|
|
// registered triggers; other call sites use db.get(id).
|
|
const id = arg1 !== undefined ? arg1 : arg0;
|
|
if (id === OWNER_USER_ID) {
|
|
return {
|
|
_id: OWNER_USER_ID,
|
|
_creationTime: Date.now() - 60 * 24 * 60 * 60 * 1000,
|
|
createdAt: Date.now() - 60 * 24 * 60 * 60 * 1000,
|
|
updatedAt: Date.now() - 60 * 24 * 60 * 60 * 1000,
|
|
handle: "alice",
|
|
name: "Alice",
|
|
email: "alice@example.com",
|
|
displayName: "Alice",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
trustedPublisher: true,
|
|
role: "user",
|
|
personalPublisherId: OWNER_PUBLISHER_ID,
|
|
};
|
|
}
|
|
if (id === OWNER_PUBLISHER_ID) {
|
|
// Returning null lets getOwnerPublisher fall back to user-based
|
|
// resolution, which then hits the synthesize fallback.
|
|
return null;
|
|
}
|
|
if (id === SKILL_ID) return skill;
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publishers" || table === "publisherMembers") {
|
|
// ensurePersonalPublisherForUser / getOwnerPublisher may poll these
|
|
// tables during triggers. Returning an empty index matches the state
|
|
// of a fresh test environment without real publisher rows.
|
|
return publisherTableQuery();
|
|
}
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name === "by_slug") {
|
|
return { unique: async () => skill };
|
|
}
|
|
if (name === "by_owner_publisher_slug" || name === "by_owner_slug") {
|
|
return { unique: async () => skill };
|
|
}
|
|
if (name === "by_owner") {
|
|
return { order: () => ({ take: async () => [skill] }) };
|
|
}
|
|
throw new Error(`unexpected skills index ${name}`);
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (
|
|
name !== "by_slug" &&
|
|
name !== "by_owner_publisher_slug" &&
|
|
name !== "by_owner_slug"
|
|
) {
|
|
throw new Error(`unexpected skillSlugAliases index ${name}`);
|
|
}
|
|
return { unique: async () => null };
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillVersions") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name === "by_skill_version") {
|
|
return { unique: async () => existingVersion ?? null };
|
|
}
|
|
if (name === "by_skill_active_created") {
|
|
return {
|
|
order: () => ({
|
|
take: async (limit: number) => {
|
|
const insertedVersion = captured.versionInserted
|
|
? {
|
|
_id: NEW_VERSION_ID,
|
|
skillId: SKILL_ID,
|
|
softDeletedAt: undefined,
|
|
...captured.versionInserted,
|
|
}
|
|
: null;
|
|
const previousVersion = {
|
|
_id: PREV_LATEST_VERSION_ID,
|
|
skillId: SKILL_ID,
|
|
softDeletedAt: undefined,
|
|
version: "2.0.0",
|
|
vtAnalysis: { status: "clean" },
|
|
llmAnalysis: { status: "clean" },
|
|
staticScan: { status: "clean" },
|
|
};
|
|
return [insertedVersion, previousVersion].filter(Boolean).slice(0, limit);
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`unexpected skillVersions index ${name}`);
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillVersionFingerprints") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_fingerprint") {
|
|
throw new Error(`unexpected skillVersionFingerprints index ${name}`);
|
|
}
|
|
return { take: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillBadges") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_skill") {
|
|
throw new Error(`unexpected skillBadges index ${name}`);
|
|
}
|
|
return { take: async () => [] };
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillEmbeddings") {
|
|
return {
|
|
withIndex: (
|
|
name: string,
|
|
build: ((q: { eq: (field: string, value: string) => unknown }) => unknown) | undefined,
|
|
) => {
|
|
if (name !== "by_version") {
|
|
throw new Error(`unexpected skillEmbeddings index ${name}`);
|
|
}
|
|
let requestedVersionId: string | null = null;
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
if (field !== "versionId") throw new Error(`unexpected field ${field}`);
|
|
requestedVersionId = value;
|
|
return q;
|
|
},
|
|
};
|
|
build?.(q);
|
|
return {
|
|
unique: async () => {
|
|
if (requestedVersionId === PREV_LATEST_VERSION_ID) {
|
|
return {
|
|
_id: PREV_EMBEDDING_ID,
|
|
versionId: PREV_LATEST_VERSION_ID,
|
|
isLatest: true,
|
|
isApproved: false,
|
|
visibility: "public",
|
|
};
|
|
}
|
|
return null;
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "globalStats") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_key") {
|
|
throw new Error(`unexpected globalStats index ${name}`);
|
|
}
|
|
return {
|
|
unique: async () => ({
|
|
_id: "globalStats:1",
|
|
activeSkillsCount: 100,
|
|
}),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSearchDigest") {
|
|
return {
|
|
withIndex: () => ({
|
|
unique: async () => null,
|
|
}),
|
|
};
|
|
}
|
|
if (table === "skillTopicSearchDigest") {
|
|
return {
|
|
withIndex: () => ({
|
|
collect: async () => [],
|
|
}),
|
|
};
|
|
}
|
|
if (table === "reservedSlugs") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_slug_active_deletedAt") {
|
|
throw new Error(`unexpected reservedSlugs index ${name}`);
|
|
}
|
|
return { order: () => ({ take: async () => [] }) };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
patch: vi.fn(async (arg0: unknown, arg1: unknown, arg2?: unknown) => {
|
|
// convex-helpers `triggers` calls innerDb.patch(tableName, id, value)
|
|
// for tables with registered triggers (e.g. "skills"); otherwise it
|
|
// falls back to innerDb.patch(id, value).
|
|
const [id, value] = arg2 !== undefined ? [arg1 as string, arg2] : [arg0 as string, arg1];
|
|
|
|
captured.allPatches.push({
|
|
id: id,
|
|
value: value as Record<string, unknown>,
|
|
});
|
|
|
|
if (id === SKILL_ID) {
|
|
captured.skillPatches.push(value as Record<string, unknown>);
|
|
Object.assign(skill, value as Record<string, unknown>);
|
|
return;
|
|
}
|
|
if (id === PREV_EMBEDDING_ID) {
|
|
captured.embeddingPatches.push({ id, value: value as Record<string, unknown> });
|
|
return;
|
|
}
|
|
if (typeof id === "string" && id.startsWith("users:")) return;
|
|
if (typeof id === "string" && id.startsWith("publishers:")) return;
|
|
}),
|
|
insert: vi.fn(async (table: string, value: Record<string, unknown>) => {
|
|
if (table === "skillVersions") {
|
|
captured.versionInserted = value;
|
|
return NEW_VERSION_ID;
|
|
}
|
|
if (table === "skillEmbeddings") {
|
|
captured.embeddingInserts.push(value);
|
|
return NEW_EMBEDDING_ID;
|
|
}
|
|
if (table === "embeddingSkillMap") {
|
|
return "embeddingSkillMap:1";
|
|
}
|
|
if (table === "skillVersionFingerprints") {
|
|
return "skillVersionFingerprints:1";
|
|
}
|
|
// Trigger side-effect / digest tables: accept silently so the async
|
|
// digest plumbing invoked by the skills trigger doesn't fail the test.
|
|
if (table === "skillSearchDigest" || table === "skillTopicSearchDigest") {
|
|
return `${table}:mock`;
|
|
}
|
|
// Intentionally throw for publishers / publisherMembers so
|
|
// ensurePersonalPublisherForUser's `isMissingPublisherTableError` branch
|
|
// takes the synthesize fallback, avoiding the publishers trigger chain.
|
|
throw new Error(`unexpected insert table ${table}`);
|
|
}),
|
|
normalizeId: vi.fn((tableName: string, id: string) =>
|
|
id.startsWith(`${tableName}:`) ? id : null,
|
|
),
|
|
};
|
|
return db;
|
|
}
|
|
|
|
function buildCtx(skill: SkillDoc, existingVersion?: Record<string, unknown> | null) {
|
|
const captured: Captured = {
|
|
skillPatches: [],
|
|
embeddingInserts: [],
|
|
embeddingPatches: [],
|
|
versionInserted: null,
|
|
allPatches: [],
|
|
};
|
|
const db = buildDb(skill, captured, existingVersion);
|
|
const ctx = {
|
|
db,
|
|
scheduler: { runAfter: vi.fn() },
|
|
};
|
|
return { ctx, captured, db };
|
|
}
|
|
|
|
describe("skills.insertVersion latest-tag protection", () => {
|
|
it("tells authors to increment the version when publishing a duplicate skill version", async () => {
|
|
const skill = buildExistingSkill();
|
|
const { ctx, captured } = buildCtx(skill, {
|
|
_id: "skillVersions:existing",
|
|
skillId: SKILL_ID,
|
|
version: "1.0.1",
|
|
softDeletedAt: 123,
|
|
ownerDeletedAt: 123,
|
|
ownerDeletedBy: OWNER_USER_ID,
|
|
});
|
|
|
|
await expect(
|
|
insertVersionHandler(ctx as never, buildPublishArgs({ version: "1.0.1" }) as never),
|
|
).rejects.toThrow("Version 1.0.1 already exists. Increment the version number and try again.");
|
|
expect(captured.versionInserted).toBeNull();
|
|
});
|
|
|
|
it("promotes latest when publishing a strictly higher version", async () => {
|
|
const skill = buildExistingSkill({
|
|
inferredCategories: ["automation"],
|
|
inferredTopics: ["Old inference"],
|
|
inferredFromVersionId: PREV_LATEST_VERSION_ID,
|
|
inferredCategoryConfidence: "high",
|
|
inferredTopicConfidence: "high",
|
|
inferredClassifierVersion: "taxonomy-prototype-v9",
|
|
inferredTopicClassifierVersion: "topic-prototype-v1",
|
|
inferredInputHash: "category-hash",
|
|
inferredTopicInputHash: "topic-hash",
|
|
inferredAt: 123,
|
|
});
|
|
const { ctx, captured } = buildCtx(skill);
|
|
|
|
const result = await insertVersionHandler(
|
|
ctx as never,
|
|
buildPublishArgs({
|
|
version: "2.1.0",
|
|
displayName: "My Skill v2.1",
|
|
summary: "Summary of v2.1.0",
|
|
}) as never,
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
skillId: SKILL_ID,
|
|
versionId: NEW_VERSION_ID,
|
|
embeddingId: NEW_EMBEDDING_ID,
|
|
});
|
|
|
|
const finalPatch = captured.skillPatches.at(-1);
|
|
expect(finalPatch).toBeDefined();
|
|
expect(finalPatch).toMatchObject({
|
|
latestVersionId: NEW_VERSION_ID,
|
|
displayName: "My Skill v2.1",
|
|
tags: expect.objectContaining({ latest: NEW_VERSION_ID }),
|
|
});
|
|
expect((finalPatch as Record<string, unknown>).latestVersionSummary).toMatchObject({
|
|
version: "2.1.0",
|
|
});
|
|
for (const field of [
|
|
"inferredCategories",
|
|
"inferredTopics",
|
|
"inferredFromVersionId",
|
|
"inferredCategoryConfidence",
|
|
"inferredTopicConfidence",
|
|
"inferredClassifierVersion",
|
|
"inferredTopicClassifierVersion",
|
|
"inferredInputHash",
|
|
"inferredTopicInputHash",
|
|
"inferredAt",
|
|
]) {
|
|
expect(finalPatch).toHaveProperty(field, undefined);
|
|
}
|
|
|
|
// New embedding is the latest; the previous latest embedding is demoted.
|
|
expect(captured.embeddingInserts[0]).toMatchObject({
|
|
versionId: NEW_VERSION_ID,
|
|
isLatest: true,
|
|
});
|
|
expect(captured.embeddingPatches).toHaveLength(1);
|
|
expect(captured.embeddingPatches[0]).toMatchObject({
|
|
id: PREV_EMBEDDING_ID,
|
|
value: expect.objectContaining({ isLatest: false }),
|
|
});
|
|
});
|
|
|
|
it("clears a stale hosted presentation icon when the new latest omits it", async () => {
|
|
const skill = buildExistingSkill({
|
|
icon: `/api/v1/skill-icons/${"a".repeat(64)}`,
|
|
});
|
|
const { ctx, captured } = buildCtx(skill);
|
|
|
|
await insertVersionHandler(
|
|
ctx as never,
|
|
buildPublishArgs({ version: "2.1.0", icon: undefined }) as never,
|
|
);
|
|
|
|
expect(captured.skillPatches.at(-1)).toHaveProperty("icon", undefined);
|
|
});
|
|
|
|
it("preserves a legacy publisher icon when the new latest omits presentation metadata", async () => {
|
|
const skill = buildExistingSkill({ icon: "lucide:Plug" });
|
|
const { ctx, captured } = buildCtx(skill);
|
|
|
|
await insertVersionHandler(
|
|
ctx as never,
|
|
buildPublishArgs({ version: "2.1.0", icon: undefined }) as never,
|
|
);
|
|
|
|
expect(captured.skillPatches.at(-1)).toMatchObject({ icon: "lucide:Plug" });
|
|
});
|
|
|
|
it("publishes suspicious prepublication results as active and flagged", async () => {
|
|
const skill = buildExistingSkill();
|
|
const { ctx, captured } = buildCtx(skill);
|
|
const llmAnalysis = {
|
|
status: "completed",
|
|
verdict: "suspicious",
|
|
summary: "Review before installing.",
|
|
checkedAt: 123,
|
|
};
|
|
|
|
await insertVersionHandler(
|
|
ctx as never,
|
|
buildPublishArgs({
|
|
version: "2.1.0",
|
|
llmAnalysis,
|
|
}) as never,
|
|
);
|
|
|
|
expect(captured.versionInserted).toMatchObject({ llmAnalysis });
|
|
expect(captured.skillPatches.at(-1)).toMatchObject({
|
|
moderationStatus: "active",
|
|
moderationVerdict: "clean",
|
|
moderationFlags: ["flagged.review"],
|
|
});
|
|
});
|
|
|
|
it("does not clobber latest when publishing an older (backport) version", async () => {
|
|
const skill = buildExistingSkill({
|
|
inferredCategories: ["automation"],
|
|
inferredTopics: ["Old inference"],
|
|
inferredFromVersionId: PREV_LATEST_VERSION_ID,
|
|
});
|
|
const { ctx, captured } = buildCtx(skill);
|
|
|
|
const result = await insertVersionHandler(
|
|
ctx as never,
|
|
buildPublishArgs({
|
|
version: "1.0.1",
|
|
displayName: "My Skill v1 backport",
|
|
summary: "Summary of v1.0.1 backport",
|
|
}) as never,
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
skillId: SKILL_ID,
|
|
versionId: NEW_VERSION_ID,
|
|
embeddingId: NEW_EMBEDDING_ID,
|
|
});
|
|
|
|
const finalPatch = captured.skillPatches.at(-1) as Record<string, unknown>;
|
|
expect(finalPatch).toBeDefined();
|
|
|
|
// Latest pointer is preserved.
|
|
expect(finalPatch.latestVersionId).toBe(PREV_LATEST_VERSION_ID);
|
|
expect(finalPatch.latestVersionSummary).toMatchObject({ version: "2.0.0" });
|
|
|
|
// Skill card fields must keep tracking the existing latest, not the backport.
|
|
expect(finalPatch.displayName).toBe("My Skill v2");
|
|
expect(finalPatch.summary).toBe("Summary of v2.0.0");
|
|
|
|
// `tags.latest` still points to the previous version.
|
|
expect(finalPatch.tags).toEqual(expect.objectContaining({ latest: PREV_LATEST_VERSION_ID }));
|
|
|
|
// versions counter still increments on every publish, regardless of version order.
|
|
expect(finalPatch.stats).toMatchObject({ versions: 2 });
|
|
expect(finalPatch).not.toHaveProperty("inferredCategories");
|
|
expect(finalPatch).not.toHaveProperty("inferredTopics");
|
|
expect(finalPatch).not.toHaveProperty("inferredFromVersionId");
|
|
});
|
|
|
|
it("keeps the previous latest embedding untouched on backport publishes", async () => {
|
|
const skill = buildExistingSkill();
|
|
const { ctx, captured } = buildCtx(skill);
|
|
|
|
await insertVersionHandler(ctx as never, buildPublishArgs({ version: "1.0.1" }) as never);
|
|
|
|
// New version embedding is NOT marked latest.
|
|
expect(captured.embeddingInserts).toHaveLength(1);
|
|
expect(captured.embeddingInserts[0]).toMatchObject({
|
|
versionId: NEW_VERSION_ID,
|
|
isLatest: false,
|
|
});
|
|
|
|
// Previous latest embedding must not be demoted.
|
|
expect(captured.embeddingPatches).toHaveLength(0);
|
|
});
|
|
|
|
it("routes a custom tag to the backport version but leaves latest alone", async () => {
|
|
const skill = buildExistingSkill();
|
|
const { ctx, captured } = buildCtx(skill);
|
|
|
|
await insertVersionHandler(
|
|
ctx as never,
|
|
buildPublishArgs({
|
|
version: "1.0.1",
|
|
tags: ["lts"],
|
|
}) as never,
|
|
);
|
|
|
|
const finalPatch = captured.skillPatches.at(-1) as Record<string, unknown>;
|
|
expect(finalPatch.tags).toEqual(
|
|
expect.objectContaining({
|
|
latest: PREV_LATEST_VERSION_ID,
|
|
lts: NEW_VERSION_ID,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("ignores an explicit `latest` in args.tags for a backport publish", async () => {
|
|
// Security regression: a caller must not be able to defeat the semver
|
|
// guard by smuggling `latest` through the custom-tag loop.
|
|
const skill = buildExistingSkill();
|
|
const { ctx, captured } = buildCtx(skill);
|
|
|
|
await insertVersionHandler(
|
|
ctx as never,
|
|
buildPublishArgs({
|
|
version: "1.0.1",
|
|
tags: ["latest", "lts"],
|
|
}) as never,
|
|
);
|
|
|
|
const finalPatch = captured.skillPatches.at(-1) as Record<string, unknown>;
|
|
expect(finalPatch.latestVersionId).toBe(PREV_LATEST_VERSION_ID);
|
|
expect(finalPatch.latestVersionSummary).toMatchObject({ version: "2.0.0" });
|
|
expect(finalPatch.tags).toEqual(
|
|
expect.objectContaining({
|
|
latest: PREV_LATEST_VERSION_ID,
|
|
lts: NEW_VERSION_ID,
|
|
}),
|
|
);
|
|
// New embedding must still be marked non-latest.
|
|
expect(captured.embeddingInserts[0]).toMatchObject({ isLatest: false });
|
|
expect(captured.embeddingPatches).toHaveLength(0);
|
|
});
|
|
|
|
it("ignores case-variant `LaTeSt` in args.tags for a backport publish", async () => {
|
|
// Defense in depth against case-only bypass attempts.
|
|
const skill = buildExistingSkill();
|
|
const { ctx, captured } = buildCtx(skill);
|
|
|
|
await insertVersionHandler(
|
|
ctx as never,
|
|
buildPublishArgs({
|
|
version: "1.0.1",
|
|
tags: ["LaTeSt"],
|
|
}) as never,
|
|
);
|
|
|
|
const finalPatch = captured.skillPatches.at(-1) as Record<string, unknown>;
|
|
expect(finalPatch.latestVersionId).toBe(PREV_LATEST_VERSION_ID);
|
|
expect(finalPatch.tags).toEqual(expect.objectContaining({ latest: PREV_LATEST_VERSION_ID }));
|
|
// The case-variant tag must not leak into the stored tag map either.
|
|
const tags = finalPatch.tags as Record<string, string>;
|
|
expect(tags.LaTeSt).toBeUndefined();
|
|
});
|
|
|
|
it("treats the very first publish as latest even when the version is low", async () => {
|
|
const skill = buildExistingSkill({
|
|
latestVersionId: undefined,
|
|
latestVersionSummary: undefined,
|
|
tags: {},
|
|
stats: {
|
|
downloads: 0,
|
|
installsCurrent: 0,
|
|
installsAllTime: 0,
|
|
stars: 0,
|
|
versions: 0,
|
|
comments: 0,
|
|
},
|
|
});
|
|
const { ctx, captured } = buildCtx(skill);
|
|
|
|
await insertVersionHandler(
|
|
ctx as never,
|
|
buildPublishArgs({
|
|
version: "0.0.1",
|
|
displayName: "My Skill v0",
|
|
summary: "Summary of v0.0.1",
|
|
}) as never,
|
|
);
|
|
|
|
const finalPatch = captured.skillPatches.at(-1) as Record<string, unknown>;
|
|
expect(finalPatch.latestVersionId).toBe(NEW_VERSION_ID);
|
|
expect(finalPatch.latestVersionSummary).toMatchObject({ version: "0.0.1" });
|
|
expect(finalPatch.tags).toEqual(expect.objectContaining({ latest: NEW_VERSION_ID }));
|
|
expect(finalPatch.displayName).toBe("My Skill v0");
|
|
expect(captured.embeddingInserts[0]).toMatchObject({ isLatest: true });
|
|
});
|
|
|
|
it("does not derive moderation flags from a backport's displayName", async () => {
|
|
// Regression for reviewer catch: on backport publishes the skill card
|
|
// keeps the old displayName, so the moderation evaluation must run
|
|
// against the old displayName too. Otherwise we persist flags that were
|
|
// triggered by text the user can never see on the card.
|
|
const skill = buildExistingSkill({ displayName: "Harmless Card Title" });
|
|
const { ctx, captured } = buildCtx(skill);
|
|
|
|
await insertVersionHandler(
|
|
ctx as never,
|
|
buildPublishArgs({
|
|
version: "1.0.1",
|
|
// "phishing" would match FLAG_RULES ("suspicious.keyword") if it
|
|
// actually reached deriveModerationFlags.
|
|
displayName: "backport phishing helper",
|
|
}) as never,
|
|
);
|
|
|
|
const finalPatch = captured.skillPatches.at(-1) as Record<string, unknown>;
|
|
// Card displayName is unchanged (backport cannot leak its title).
|
|
expect(finalPatch.displayName).toBe("Harmless Card Title");
|
|
// And the flags derived from that evaluation must not contain the
|
|
// keyword match sourced from the backport-only title.
|
|
const flags = (finalPatch.moderationFlags ?? []) as string[];
|
|
expect(flags).not.toContain("suspicious.keyword");
|
|
});
|
|
|
|
it("still derives moderation flags from displayName when the publish IS the new latest", async () => {
|
|
// Counter-case for the guard above: when the publish actually promotes
|
|
// to latest, the new displayName is what lives on the card, so flags
|
|
// derived from it must be recorded.
|
|
const skill = buildExistingSkill({ displayName: "Harmless Card Title" });
|
|
const { ctx, captured } = buildCtx(skill);
|
|
|
|
await insertVersionHandler(
|
|
ctx as never,
|
|
buildPublishArgs({
|
|
version: "3.0.0",
|
|
displayName: "shiny phishing helper",
|
|
}) as never,
|
|
);
|
|
|
|
const finalPatch = captured.skillPatches.at(-1) as Record<string, unknown>;
|
|
expect(finalPatch.displayName).toBe("shiny phishing helper");
|
|
const flags = (finalPatch.moderationFlags ?? []) as string[];
|
|
expect(flags).toContain("suspicious.keyword");
|
|
});
|
|
|
|
it("does not throw when the persisted latestVersionSummary.version is not valid semver", async () => {
|
|
// Regression for reviewer catch: the schema only enforces v.string() on
|
|
// latestVersionSummary.version, so legacy / imported skills may persist
|
|
// non-semver values. Without the semver.valid() guard, semver.gt would
|
|
// throw `TypeError: Invalid Version` and crash the publish mutation.
|
|
const skill = buildExistingSkill({
|
|
latestVersionSummary: {
|
|
version: "not-a-semver",
|
|
createdAt: 1000,
|
|
changelog: "legacy",
|
|
},
|
|
});
|
|
const { ctx, captured } = buildCtx(skill);
|
|
|
|
// Must not throw `TypeError: Invalid Version` from semver.gt().
|
|
await insertVersionHandler(
|
|
ctx as never,
|
|
buildPublishArgs({
|
|
version: "1.0.0",
|
|
displayName: "Recovered v1",
|
|
}) as never,
|
|
);
|
|
|
|
// The new publish should self-heal the skill back into a valid semver
|
|
// latest pointer, since the persisted one is unusable for comparison.
|
|
const finalPatch = captured.skillPatches.at(-1) as Record<string, unknown>;
|
|
expect(finalPatch.latestVersionId).toBe(NEW_VERSION_ID);
|
|
expect(finalPatch.latestVersionSummary).toMatchObject({ version: "1.0.0" });
|
|
expect(finalPatch.tags).toEqual(expect.objectContaining({ latest: NEW_VERSION_ID }));
|
|
expect(captured.embeddingInserts[0]).toMatchObject({ isLatest: true });
|
|
});
|
|
|
|
it("does not throw when the persisted latestVersionSummary.version is an empty string", async () => {
|
|
// Empty string is falsy but still fails semver.valid(); make sure both
|
|
// guard clauses (`!prevLatestVersion` and `!semver.valid(...)`) keep us
|
|
// safe rather than only one of them.
|
|
const skill = buildExistingSkill({
|
|
latestVersionSummary: {
|
|
version: "",
|
|
createdAt: 1000,
|
|
changelog: "legacy",
|
|
},
|
|
});
|
|
const { ctx, captured } = buildCtx(skill);
|
|
|
|
await insertVersionHandler(
|
|
ctx as never,
|
|
buildPublishArgs({
|
|
version: "0.1.0",
|
|
displayName: "Recovered v0.1",
|
|
}) as never,
|
|
);
|
|
|
|
const finalPatch = captured.skillPatches.at(-1) as Record<string, unknown>;
|
|
expect(finalPatch.latestVersionId).toBe(NEW_VERSION_ID);
|
|
expect(finalPatch.latestVersionSummary).toMatchObject({ version: "0.1.0" });
|
|
});
|
|
});
|