1 parent
6af337e67c
commit
34d8811cd3
36 files changed
+1765
-26
No files matched your search
+9
-12
@@ -32,11 +32,7 @@ function scriptSetupContent(): string {
|
||||
function functionBody(script: string, signature: string): string {
|
||||
const sigIndex = script.indexOf(signature);
|
||||
assert.notEqual(sigIndex, -1, `expected to find ${signature}`);
|
||||
assert.equal(
|
||||
script.indexOf(signature, sigIndex + signature.length),
|
||||
-1,
|
||||
`${signature} should be unique`,
|
||||
);
|
||||
assert.equal(script.indexOf(signature, sigIndex + signature.length), -1, `${signature} should be unique`);
|
||||
const openBrace = script.indexOf("{", sigIndex + signature.length);
|
||||
assert.notEqual(openBrace, -1, "function body should open with a brace");
|
||||
|
||||
@@ -72,11 +68,15 @@ test("send() acquires the guard synchronously before the first await", () => {
|
||||
assert.ok(entryCheck < guardSet, "isGenerating check must precede the guard set");
|
||||
assert.ok(guardSet < firstAwait, "guard must be set before the first await (no await between check and set)");
|
||||
|
||||
// The first suspension point is specifically the prompt-template load — the
|
||||
// await the reviewer flagged as happening before the guard existed.
|
||||
// The first suspension point is the send-time skill read or the prompt-template
|
||||
// load — both run after the guard and both are pipeline-entry awaits whose
|
||||
// failure paths release the guard state explicitly (see the early returns
|
||||
// after `aiGenerationGuard.begin()`).
|
||||
const ensureLoadedAwait = body.indexOf("await promptTemplateStore.ensureLoaded()");
|
||||
assert.notEqual(ensureLoadedAwait, -1, "send() should await promptTemplateStore.ensureLoaded()");
|
||||
assert.equal(firstAwait, ensureLoadedAwait, "ensureLoaded() must be the first await in send()");
|
||||
const skillReadAwait = body.indexOf("await readUserSkills([...selectedSkillIds.value], skillRootSettings())");
|
||||
const pipelineEntryAwaits = [ensureLoadedAwait, skillReadAwait].filter((index) => index >= 0);
|
||||
assert.ok(pipelineEntryAwaits.includes(firstAwait), "the first await in send() must be the skill read or the prompt-template load");
|
||||
});
|
||||
|
||||
test("send() never leaks the guard on an early return after acquiring it", () => {
|
||||
@@ -93,10 +93,7 @@ test("send() never leaks the guard on an early return after acquiring it", () =>
|
||||
|
||||
for (const match of returnsAfterGuard) {
|
||||
const preceding = tail.slice(0, match.index);
|
||||
assert.ok(
|
||||
preceding.includes("isGenerating.value = false"),
|
||||
"any early return after the guard is acquired must reset isGenerating first",
|
||||
);
|
||||
assert.ok(preceding.includes("isGenerating.value = false"), "any early return after the guard is acquired must reset isGenerating first");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
|
||||
// AiAssistant.vue has no mounted-DOM harness in this repo (no @vue/test-utils),
|
||||
// so this pins the /skill contract on the compiled SFC source, the same way
|
||||
// aiAssistantComposerLayout.test.ts pins the composer layout.
|
||||
const source = readFileSync(new URL("../../apps/desktop/src/components/editor/AiAssistant.vue", import.meta.url), "utf8");
|
||||
|
||||
function section(startMarker: string, endMarker: string): string {
|
||||
const start = source.indexOf(startMarker);
|
||||
assert.notEqual(start, -1, `missing marker: ${startMarker}`);
|
||||
const end = source.indexOf(endMarker, start + startMarker.length);
|
||||
assert.notEqual(end, -1, `missing end marker: ${endMarker}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test("/skill rides its own command source instead of the action lists", () => {
|
||||
const commands = section("const filteredCommands = computed", "const AI_SQL_FILE_MENTION_CANDIDATE_LIMIT");
|
||||
assert.ok(commands.includes('{ type: "skills" }'), "filteredCommands must merge a dedicated skills entry");
|
||||
assert.ok(commands.includes('type: "action"'), "action entries must stay in the same command list");
|
||||
|
||||
// The built-in action arrays must not gain a skill entry: they also feed the
|
||||
// mode+action picker, which would then offer /skill as a selectable action.
|
||||
for (const marker of ["/** Ask-mode actions: SQL-producing, never auto-run. */", "/** Agent-mode actions: task-oriented, drive tool use and real results. */"]) {
|
||||
const list = section(marker, "];");
|
||||
assert.ok(!/skill/i.test(list.replace(/AiActionButton/g, "")), `action list must not contain a skill entry: ${marker}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("selecting /skill never changes the built-in action", () => {
|
||||
const body = section("function selectCommand(command: AiSlashEntry)", "\nfunction insertMention");
|
||||
const skillsBranch = body.indexOf('command.type === "skills"');
|
||||
const actionAssign = body.indexOf("activeAction.value = command.button.action");
|
||||
assert.notEqual(skillsBranch, -1, "selectCommand must branch on the skills entry");
|
||||
assert.notEqual(actionAssign, -1, "selectCommand must still assign the chosen action");
|
||||
assert.ok(skillsBranch < actionAssign, "the skills branch must be evaluated before any action assignment");
|
||||
|
||||
const branch = body.slice(skillsBranch, actionAssign);
|
||||
assert.ok(branch.includes("showSkillSelector.value = true"), "the skills branch must open the selector");
|
||||
assert.ok(branch.includes("return"), "the skills branch must return before the action assignment");
|
||||
assert.ok(!branch.includes("activeAction"), "the skills branch must not touch activeAction");
|
||||
});
|
||||
|
||||
test("the skills selector only ever lists metadata", () => {
|
||||
const selector = section('<Popover v-model:open="showSkillSelector">', 'v-if="mentionOpen"');
|
||||
assert.ok(selector.length > 0);
|
||||
assert.ok(selector.includes("userSkillStore.groupedSkills"), "the selector renders the metadata catalog");
|
||||
assert.ok(!selector.includes("readUserSkills"), "the selector must never read skill bodies");
|
||||
assert.ok(selector.includes("refreshSkills"), "the selector exposes Refresh");
|
||||
});
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { beforeEach, test, vi } from "vitest";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import type { UserSkillRootSettings, UserSkillsListResult } from "../../apps/desktop/src/types/userSkills";
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
listUserSkills: vi.fn(),
|
||||
readUserSkills: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => apiMock);
|
||||
|
||||
import { useUserSkillStore } from "../../apps/desktop/src/stores/userSkillStore.ts";
|
||||
|
||||
const rootSettings: UserSkillRootSettings = { customRootEnabled: true, customRoot: "D:/Team/dbx-skills" };
|
||||
|
||||
function listing(status: "ok" | "missing" | "invalid", skills: Array<{ id: string; name: string; description: string }>) {
|
||||
return { status, skills };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
apiMock.listUserSkills.mockReset();
|
||||
apiMock.readUserSkills.mockReset();
|
||||
});
|
||||
|
||||
test("refresh groups custom root first and tracks statuses", async () => {
|
||||
apiMock.listUserSkills.mockResolvedValueOnce({
|
||||
defaultRoot: listing("ok", [
|
||||
{ id: "d-2", name: "Zeta", description: "z" },
|
||||
{ id: "d-1", name: "Alpha", description: "a" },
|
||||
]),
|
||||
customRoot: listing("ok", [{ id: "c-1", name: "Team Rules", description: "t" }]),
|
||||
} satisfies UserSkillsListResult);
|
||||
|
||||
const store = useUserSkillStore();
|
||||
assert.equal(await store.refresh(rootSettings), true);
|
||||
assert.deepEqual(
|
||||
store.groupedSkills.map((group) => group.source),
|
||||
["custom", "default"],
|
||||
);
|
||||
assert.equal(store.totalCount, 3);
|
||||
assert.equal(store.metaFor("c-1")?.name, "Team Rules");
|
||||
assert.equal(store.defaultRootStatus, "ok");
|
||||
assert.equal(store.customRootStatus, "ok");
|
||||
assert.equal(apiMock.listUserSkills.mock.calls[0][0], rootSettings);
|
||||
});
|
||||
|
||||
test("absent default root and disabled custom root yield empty groups, not errors", async () => {
|
||||
apiMock.listUserSkills.mockResolvedValueOnce({
|
||||
defaultRoot: listing("missing", []),
|
||||
customRoot: null,
|
||||
} satisfies UserSkillsListResult);
|
||||
|
||||
const store = useUserSkillStore();
|
||||
assert.equal(await store.refresh({ customRootEnabled: false, customRoot: null }), true);
|
||||
assert.equal(store.totalCount, 0);
|
||||
assert.deepEqual(store.groupedSkills, []);
|
||||
assert.equal(store.lastError, null);
|
||||
assert.equal(store.hasLoadedOnce, true);
|
||||
});
|
||||
|
||||
test("refresh failure keeps prior catalog and records retryable error", async () => {
|
||||
apiMock.listUserSkills.mockResolvedValueOnce({
|
||||
defaultRoot: listing("ok", [{ id: "d-1", name: "Alpha", description: "a" }]),
|
||||
customRoot: null,
|
||||
} satisfies UserSkillsListResult);
|
||||
const store = useUserSkillStore();
|
||||
await store.refresh(rootSettings);
|
||||
|
||||
apiMock.listUserSkills.mockRejectedValueOnce(new Error("backend unavailable"));
|
||||
assert.equal(await store.refresh(rootSettings), false);
|
||||
assert.equal(store.lastError, "backend unavailable");
|
||||
// Prior catalog remains so chips keep their display metadata.
|
||||
assert.equal(store.totalCount, 1);
|
||||
assert.equal(store.metaFor("d-1")?.name, "Alpha");
|
||||
});
|
||||
Reference in new issue
Block a user