Add automated tests for skill version detection

Task #13: lock in the fingerprint/relation logic behind SkillGuard's
identical/modified/new version detection with automated tests.

What was added
- Set up Vitest in artifacts/api-server (dev dep + `test` script + vitest.config.ts
  using the "workspace" resolve condition so @workspace/* resolve to source).
- Unit tests (no DB):
  - src/lib/skillFingerprint.test.ts — hashText/hashBytes stability & agreement,
    computeFingerprint stable + order-independent + sensitive to content/path/add/remove,
    jaccard overlap/symmetry/empty handling.
  - src/lib/lineDiff.test.ts — lineSimilarity ratios (identical, single-edit, disjoint,
    symmetric, CRLF), lineDiff context/add/remove with line numbers and the 2000-line cap.
- DB-backed tests (use the existing DATABASE_URL):
  - src/routes/relation.test.ts — computeRelation: identical content under a different
    name -> "identical" + check-counter (countFingerprint) increments; one-line edit to a
    single-file skill -> "modified" with sensible similarity; unrelated skill -> "new".
    Also direct computeContentSimilarity cases. Fixtures use randomized content to avoid
    collisions with shared dev data and are cleaned up afterEach.
  - src/routes/compare.test.ts — e2e GET /api/scans/:id/compare/:otherId via a live
    server: asserts unchanged/modified/added/removed statuses, sorted file order, the
    line diff for the modified file, null diffs elsewhere, and 404 for missing scans.

Production code change
- Exported computeRelation, computeContentSimilarity, countFingerprint from
  src/routes/scans.ts so the relation logic can be unit-tested. No behavior change.

Verification
- `pnpm --filter @workspace/api-server run test` -> 34 tests, 4 files, all pass.
- `pnpm --filter @workspace/api-server run typecheck` passes (rebuilt stale lib/db
  declarations via `pnpm run typecheck:libs`).
- Production build unaffected: esbuild only bundles from src/index.ts, so *.test.ts
  files are not included.

Replit-Task-Id: e9ae5e24-1480-4a09-8436-1718c535573a
This commit is contained in:
amertensreplit 2026-06-10 19:48:10 +00:00
parent 54323706b5
commit 532f42117f
8 changed files with 803 additions and 5 deletions

View file

@ -7,7 +7,8 @@
"dev": "export NODE_ENV=development && pnpm run build && pnpm run start",
"build": "node ./build.mjs",
"start": "node --enable-source-maps ./dist/index.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit"
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"@workspace/api-zod": "workspace:*",
@ -28,6 +29,7 @@
"esbuild": "0.27.3",
"esbuild-plugin-pino": "^2.3.3",
"pino-pretty": "^13.1.3",
"thread-stream": "3.1.0"
"thread-stream": "3.1.0",
"vitest": "^4.1.8"
}
}

View file

@ -0,0 +1,95 @@
import { describe, it, expect } from "vitest";
import { lineSimilarity, lineDiff } from "./lineDiff";
describe("lineSimilarity", () => {
it("is 1 for identical text", () => {
const text = "line one\nline two\nline three";
expect(lineSimilarity(text, text)).toBe(1);
});
it("is 1 for two empty strings", () => {
expect(lineSimilarity("", "")).toBe(1);
});
it("is high for a single changed line", () => {
const prev = "a\nb\nc\nd";
const curr = "a\nB\nc\nd"; // one of four lines changed
const sim = lineSimilarity(prev, curr);
// LCS = {a,c,d} = 3, ratio = 2*3 / (4+4) = 0.75
expect(sim).toBeCloseTo(0.75, 10);
expect(sim).toBeGreaterThan(0.5);
});
it("is 0 when nothing is in common", () => {
expect(lineSimilarity("a\nb\nc", "x\ny\nz")).toBe(0);
});
it("is symmetric", () => {
const a = "one\ntwo\nthree\nfour";
const b = "one\ntwo\nTHREE\nfour\nfive";
expect(lineSimilarity(a, b)).toBeCloseTo(lineSimilarity(b, a), 10);
});
it("handles \\r\\n and \\n line endings equivalently", () => {
expect(lineSimilarity("a\r\nb", "a\nb")).toBe(1);
});
});
describe("lineDiff", () => {
it("marks every line as context for identical input", () => {
const text = "a\nb\nc";
const diff = lineDiff(text, text);
expect(diff).not.toBeNull();
expect(diff!.every((d) => d.type === "context")).toBe(true);
expect(diff!.map((d) => d.text)).toEqual(["a", "b", "c"]);
});
it("records a removed and an added line for a single-line edit", () => {
const diff = lineDiff("a\nb\nc", "a\nB\nc");
expect(diff).not.toBeNull();
const removed = diff!.filter((d) => d.type === "remove");
const added = diff!.filter((d) => d.type === "add");
expect(removed.map((d) => d.text)).toEqual(["b"]);
expect(added.map((d) => d.text)).toEqual(["B"]);
// unchanged lines remain context
expect(diff!.filter((d) => d.type === "context").map((d) => d.text)).toEqual(
["a", "c"],
);
});
it("sets correct line numbers for context, add and remove", () => {
const diff = lineDiff("a\nb", "a\nb\nc")!;
const added = diff.find((d) => d.type === "add")!;
expect(added.text).toBe("c");
expect(added.previousLine).toBeNull();
expect(added.currentLine).toBe(3);
const firstContext = diff.find((d) => d.type === "context")!;
expect(firstContext.previousLine).toBe(1);
expect(firstContext.currentLine).toBe(1);
});
it("treats a pure addition as only added lines", () => {
const diff = lineDiff("a", "a\nb\nc")!;
expect(diff.filter((d) => d.type === "remove")).toHaveLength(0);
expect(diff.filter((d) => d.type === "add").map((d) => d.text)).toEqual([
"b",
"c",
]);
});
it("treats a pure removal as only removed lines", () => {
const diff = lineDiff("a\nb\nc", "a")!;
expect(diff.filter((d) => d.type === "add")).toHaveLength(0);
expect(diff.filter((d) => d.type === "remove").map((d) => d.text)).toEqual([
"b",
"c",
]);
});
it("returns null when either side exceeds the diff line cap", () => {
const big = Array.from({ length: 2001 }, (_, i) => `line ${i}`).join("\n");
expect(lineDiff(big, "a")).toBeNull();
expect(lineDiff("a", big)).toBeNull();
});
});

View file

@ -0,0 +1,98 @@
import { describe, it, expect } from "vitest";
import {
hashText,
hashBytes,
computeFingerprint,
jaccard,
} from "./skillFingerprint";
describe("hashText / hashBytes", () => {
it("produces a stable sha256 hex digest", () => {
const h = hashText("hello world");
expect(h).toBe(
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
);
expect(h).toMatch(/^[0-9a-f]{64}$/);
});
it("agrees between hashText and hashBytes for the same content", () => {
const text = "some skill content\nwith two lines";
expect(hashText(text)).toBe(hashBytes(Buffer.from(text, "utf-8")));
});
it("changes when the content changes", () => {
expect(hashText("a")).not.toBe(hashText("b"));
});
});
describe("computeFingerprint", () => {
const files = [
{ path: "SKILL.md", hash: "aaa" },
{ path: "scripts/run.sh", hash: "bbb" },
{ path: "data.json", hash: "ccc" },
];
it("is stable across calls with the same input", () => {
expect(computeFingerprint(files)).toBe(computeFingerprint(files));
});
it("is independent of the order files are supplied in", () => {
const reordered = [files[2], files[0], files[1]];
expect(computeFingerprint(reordered)).toBe(computeFingerprint(files));
});
it("changes when a file's content hash changes", () => {
const changed = [
files[0],
{ path: "scripts/run.sh", hash: "DIFFERENT" },
files[2],
];
expect(computeFingerprint(changed)).not.toBe(computeFingerprint(files));
});
it("changes when a file's path changes", () => {
const renamed = [
{ path: "SKILL.md", hash: "aaa" },
{ path: "scripts/start.sh", hash: "bbb" },
files[2],
];
expect(computeFingerprint(renamed)).not.toBe(computeFingerprint(files));
});
it("changes when a file is added or removed", () => {
const fewer = [files[0], files[1]];
expect(computeFingerprint(fewer)).not.toBe(computeFingerprint(files));
});
it("returns a 64-char hex digest", () => {
expect(computeFingerprint(files)).toMatch(/^[0-9a-f]{64}$/);
});
});
describe("jaccard", () => {
it("is 1 for identical non-empty sets", () => {
expect(jaccard(new Set(["a", "b"]), new Set(["a", "b"]))).toBe(1);
});
it("is 0 for disjoint sets", () => {
expect(jaccard(new Set(["a"]), new Set(["b"]))).toBe(0);
});
it("computes intersection over union for partial overlap", () => {
// intersection {a} = 1, union {a,b,c} = 3
expect(jaccard(new Set(["a", "b"]), new Set(["a", "c"]))).toBeCloseTo(
1 / 3,
10,
);
});
it("is symmetric", () => {
const a = new Set(["a", "b", "c"]);
const b = new Set(["b", "c", "d"]);
expect(jaccard(a, b)).toBe(jaccard(b, a));
});
it("treats two empty sets as 0 (no overlap to report)", () => {
expect(jaccard(new Set(), new Set())).toBe(0);
});
});

View file

@ -0,0 +1,151 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import type { AddressInfo } from "node:net";
import type { Server } from "node:http";
import app from "../app";
import { db, pool, scansTable, scanFilesTable } from "@workspace/db";
import { inArray } from "drizzle-orm";
import { hashText, computeFingerprint } from "../lib/skillFingerprint";
const emptyCounts = {
critical: 0,
high: 0,
medium: 0,
low: 0,
info: 0,
security: 0,
privacy: 0,
total: 0,
};
type FixtureFile = { path: string; content: string };
const createdScanIds: number[] = [];
let server: Server;
let baseUrl: string;
beforeAll(async () => {
await new Promise<void>((resolve) => {
server = app.listen(0, () => resolve());
});
const { port } = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${port}`;
});
afterAll(async () => {
if (createdScanIds.length > 0) {
await db
.delete(scansTable)
.where(inArray(scansTable.id, createdScanIds.splice(0)));
}
await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
await pool.end();
});
async function insertScan(
name: string,
files: FixtureFile[],
): Promise<number> {
const withHash = files.map((f) => ({ ...f, hash: hashText(f.content) }));
const fingerprint = computeFingerprint(
withHash.map((f) => ({ path: f.path, hash: f.hash })),
);
const [scan] = await db
.insert(scansTable)
.values({
name,
source: "zip",
status: "completed",
verdict: "pass",
riskScore: 0,
fileCount: files.length,
aiUsed: false,
findingCounts: emptyCounts,
fingerprint,
relation: "new",
})
.returning();
createdScanIds.push(scan.id);
await db.insert(scanFilesTable).values(
withHash.map((f) => ({
scanId: scan.id,
path: f.path,
kind: f.path.toLowerCase().endsWith(".md") ? "instruction" : "resource",
language: null,
size: Buffer.byteLength(f.content, "utf-8"),
hash: f.hash,
content: f.content,
})),
);
return scan.id;
}
describe("GET /api/scans/:id/compare/:otherId", () => {
it("returns correct per-file statuses and a line diff for the modified file", async () => {
const previousId = await insertScan("v1", [
{ path: "SKILL.md", content: "# Skill\nstep one\nstep two\nstep three" },
{ path: "removed.md", content: "this file goes away" },
{ path: "stable.md", content: "never changes" },
]);
const currentId = await insertScan("v2", [
{
path: "SKILL.md",
content: "# Skill\nstep one\nstep TWO\nstep three",
},
{ path: "added.md", content: "brand new file" },
{ path: "stable.md", content: "never changes" },
]);
const res = await fetch(
`${baseUrl}/api/scans/${currentId}/compare/${previousId}`,
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
current: { id: number };
previous: { id: number };
files: {
path: string;
status: string;
lineDiff: { type: string; text: string }[] | null;
}[];
};
expect(body.current.id).toBe(currentId);
expect(body.previous.id).toBe(previousId);
const byPath: Record<string, (typeof body.files)[number]> =
Object.fromEntries(body.files.map((f) => [f.path, f]));
expect(byPath["SKILL.md"].status).toBe("modified");
expect(byPath["stable.md"].status).toBe("unchanged");
expect(byPath["added.md"].status).toBe("added");
expect(byPath["removed.md"].status).toBe("removed");
// Files are returned sorted by path.
expect(body.files.map((f: { path: string }) => f.path)).toEqual(
["SKILL.md", "added.md", "removed.md", "stable.md"],
);
// The modified text file carries a line diff with the changed line.
const diff = byPath["SKILL.md"].lineDiff as
| { type: string; text: string }[]
| null;
expect(diff).not.toBeNull();
expect(diff!.find((d) => d.type === "remove")?.text).toBe("step two");
expect(diff!.find((d) => d.type === "add")?.text).toBe("step TWO");
expect(diff!.filter((d) => d.type === "context").map((d) => d.text)).toEqual(
["# Skill", "step one", "step three"],
);
// Unchanged / added / removed files do not get a line diff.
expect(byPath["stable.md"].lineDiff).toBeNull();
expect(byPath["added.md"].lineDiff).toBeNull();
expect(byPath["removed.md"].lineDiff).toBeNull();
});
it("returns 404 when a scan does not exist", async () => {
const res = await fetch(`${baseUrl}/api/scans/999999999/compare/999999998`);
expect(res.status).toBe(404);
});
});

View file

@ -0,0 +1,195 @@
import { describe, it, expect, afterEach, afterAll } from "vitest";
import { db, pool, scansTable, scanFilesTable } from "@workspace/db";
import { eq, inArray } from "drizzle-orm";
import { hashText, computeFingerprint } from "../lib/skillFingerprint";
import type { ParsedFile } from "../lib/ruleCatalog";
import {
computeRelation,
computeContentSimilarity,
countFingerprint,
} from "./scans";
const emptyCounts = {
critical: 0,
high: 0,
medium: 0,
low: 0,
info: 0,
security: 0,
privacy: 0,
total: 0,
};
const createdScanIds: number[] = [];
function file(path: string, content: string): ParsedFile {
return {
path,
kind: path.toLowerCase().endsWith(".md") ? "instruction" : "resource",
language: null,
content,
size: Buffer.byteLength(content, "utf-8"),
hash: hashText(content),
isBinary: false,
};
}
async function insertScan(name: string, files: ParsedFile[]): Promise<number> {
const fingerprint = computeFingerprint(
files.map((f) => ({ path: f.path, hash: f.hash })),
);
const [scan] = await db
.insert(scansTable)
.values({
name,
source: "text",
status: "completed",
verdict: "pass",
riskScore: 0,
fileCount: files.length,
aiUsed: false,
findingCounts: emptyCounts,
fingerprint,
relation: "new",
})
.returning();
createdScanIds.push(scan.id);
await db.insert(scanFilesTable).values(
files.map((f) => ({
scanId: scan.id,
path: f.path,
kind: f.kind,
language: f.language,
size: f.size,
hash: f.hash,
content: f.isBinary ? null : f.content,
})),
);
return scan.id;
}
afterEach(async () => {
if (createdScanIds.length > 0) {
await db
.delete(scansTable)
.where(inArray(scansTable.id, createdScanIds.splice(0)));
}
});
afterAll(async () => {
await pool.end();
});
describe("computeRelation", () => {
it("flags byte-identical content (with a different name) as identical and bumps the check counter", async () => {
// Use highly unique content so the fingerprint cannot collide with any
// pre-existing scans in the shared dev database.
const body =
"# Unique relation-test skill " +
Math.random().toString(36).slice(2) +
"\nDo a very specific thing.\n";
const original = [file("SKILL.md", body)];
const baselineId = await insertScan("Original Name", original);
const fingerprint = computeFingerprint(
original.map((f) => ({ path: f.path, hash: f.hash })),
);
// A re-upload of the exact same bytes under a different display name.
const relation = await computeRelation(fingerprint, original);
expect(relation.relation).toBe("identical");
expect(relation.similarity).toBe(100);
expect(relation.comparedScanId).toBe(baselineId);
// Check counter: only the baseline exists so far -> 1.
expect(await countFingerprint(fingerprint)).toBe(1);
// Storing the identical re-upload increments the counter to 2.
await insertScan("Different Name", original);
expect(await countFingerprint(fingerprint)).toBe(2);
});
it("flags a one-line edit to a single-file skill as modified with a sensible similarity", async () => {
const tag = Math.random().toString(36).slice(2);
const baseLines = [
`# Modified-test skill ${tag}`,
"Step one: gather inputs.",
"Step two: process them.",
"Step three: report results.",
"Step four: clean up.",
];
const original = [file("SKILL.md", baseLines.join("\n"))];
const baselineId = await insertScan("Edited Skill", original);
// Change exactly one line.
const editedLines = [...baseLines];
editedLines[2] = "Step two: process them carefully.";
const edited = [file("SKILL.md", editedLines.join("\n"))];
const fingerprint = computeFingerprint(
edited.map((f) => ({ path: f.path, hash: f.hash })),
);
const relation = await computeRelation(fingerprint, edited);
expect(relation.relation).toBe("modified");
expect(relation.comparedScanId).toBe(baselineId);
expect(relation.similarity).not.toBeNull();
// One of five lines changed -> high but not perfect similarity.
expect(relation.similarity!).toBeGreaterThanOrEqual(60);
expect(relation.similarity!).toBeLessThan(100);
});
it("flags an unrelated single-file skill as new", async () => {
const tag = Math.random().toString(36).slice(2);
// Establish a baseline skill.
await insertScan("Baseline Skill", [
file(
"SKILL.md",
`# Baseline ${tag}\nalpha bravo charlie\ndelta echo foxtrot\ngolf hotel india`,
),
]);
// A completely different single-file skill. It shares the common path
// "SKILL.md" but has no content overlap, so it must be "new".
const unrelated = [
file(
"SKILL.md",
`# Totally different ${tag}\nzulu yankee xray\nwhiskey victor uniform\ntango sierra romeo`,
),
];
const fingerprint = computeFingerprint(
unrelated.map((f) => ({ path: f.path, hash: f.hash })),
);
const relation = await computeRelation(fingerprint, unrelated);
expect(relation.relation).toBe("new");
expect(relation.similarity).toBeNull();
expect(relation.comparedScanId).toBeNull();
});
});
describe("computeContentSimilarity", () => {
it("is 100 when every file is byte-identical", () => {
const files = [file("SKILL.md", "same content\nline two")];
const prior = new Map(
files.map((f) => [f.path, { hash: f.hash, content: f.content }]),
);
expect(computeContentSimilarity(files, prior)).toBe(100);
});
it("uses line-level similarity for a changed text file", () => {
const current = [file("SKILL.md", "a\nB\nc\nd")];
const prior = new Map([
["SKILL.md", { hash: hashText("a\nb\nc\nd"), content: "a\nb\nc\nd" }],
]);
// 3 of 4 lines shared -> 0.75 -> 75 (single shared path).
expect(computeContentSimilarity(current, prior)).toBe(75);
});
it("counts added or removed files as zero contribution", () => {
const current = [file("SKILL.md", "shared"), file("extra.md", "new file")];
const prior = new Map([
["SKILL.md", { hash: hashText("shared"), content: "shared" }],
]);
// SKILL.md identical (1.0), extra.md added (0). Union of paths = 2 -> 50.
expect(computeContentSimilarity(current, prior)).toBe(50);
});
});

View file

@ -90,7 +90,7 @@ async function resolveComparedScan(
};
}
async function countFingerprint(fingerprint: string): Promise<number> {
export async function countFingerprint(fingerprint: string): Promise<number> {
if (!fingerprint) return 1;
const [row] = await db
.select({ c: count() })
@ -156,7 +156,7 @@ type RelationInfo = {
* skill (when it overlaps enough or shares a byte-identical file) -> modified;
* nothing meaningfully in common -> new.
*/
async function computeRelation(
export async function computeRelation(
fingerprint: string,
files: ParsedFile[],
): Promise<RelationInfo> {
@ -261,7 +261,7 @@ const MODIFIED_SIMILARITY_THRESHOLD = 40;
* scan. Identical files (same hash) count fully; changed text files use the
* line-level similarity; added/removed or changed binary files count as 0.
*/
function computeContentSimilarity(
export function computeContentSimilarity(
newFiles: ParsedFile[],
prior: Map<string, { hash: string; content: string | null }>,
): number {

View file

@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
conditions: ["workspace"],
},
test: {
include: ["src/**/*.test.ts"],
environment: "node",
},
});

246
pnpm-lock.yaml generated
View file

@ -218,6 +218,9 @@ importers:
thread-stream:
specifier: 3.1.0
version: 3.1.0
vitest:
specifier: ^4.1.8
version: 4.1.8(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4)
artifacts/mockup-sandbox:
devDependencies:
@ -1568,6 +1571,9 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@tabby_ai/hijri-converter@1.0.5':
resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==}
engines: {node: '>=16.0.0'}
@ -1631,6 +1637,9 @@ packages:
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
@ -1669,6 +1678,9 @@ packages:
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@ -1719,6 +1731,35 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
'@vitest/expect@4.1.8':
resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==}
'@vitest/mocker@4.1.8':
resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@4.1.8':
resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==}
'@vitest/runner@4.1.8':
resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==}
'@vitest/snapshot@4.1.8':
resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==}
'@vitest/spy@4.1.8':
resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==}
'@vitest/utils@4.1.8':
resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@ -1757,6 +1798,10 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
@ -1802,6 +1847,10 @@ packages:
caniuse-lite@1.0.30001792:
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
@ -2109,6 +2158,9 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-module-lexer@2.1.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
@ -2138,6 +2190,9 @@ packages:
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
@ -2153,6 +2208,10 @@ packages:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'}
@ -2527,6 +2586,10 @@ packages:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
obug@2.1.2:
resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==}
engines: {node: '>=12.20.0'}
on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
@ -2900,6 +2963,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
@ -2925,10 +2991,16 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
string-argv@0.3.2:
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
engines: {node: '>=0.6.19'}
@ -2966,10 +3038,21 @@ packages:
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@1.2.4:
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
engines: {node: '>=18'}
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@ -3136,11 +3219,56 @@ packages:
yaml:
optional: true
vitest@4.1.8:
resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.8
'@vitest/browser-preview': 4.1.8
'@vitest/browser-webdriverio': 4.1.8
'@vitest/coverage-istanbul': 4.1.8
'@vitest/coverage-v8': 4.1.8
'@vitest/ui': 4.1.8
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
wouter@3.10.0:
resolution: {integrity: sha512-zTfddD80zc2/J5l8JKcdvzOK6AwP0kpyHEI3DxRN2bn8U1oJPnrSVm8v+X3WwDamvLAOxTO7ZvkxkpRWlyeJ1Q==}
peerDependencies:
@ -4245,6 +4373,8 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
'@standard-schema/spec@1.1.0': {}
'@tabby_ai/hijri-converter@1.0.5': {}
'@tailwindcss/node@4.3.0':
@ -4313,6 +4443,11 @@ snapshots:
'@types/connect': 3.4.38
'@types/node': 25.6.2
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/connect@3.4.38':
dependencies:
'@types/node': 25.6.2
@ -4349,6 +4484,8 @@ snapshots:
'@types/d3-timer@3.0.2': {}
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {}
'@types/express-serve-static-core@5.1.1':
@ -4415,6 +4552,47 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/expect@4.1.8':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.8
'@vitest/utils': 4.1.8
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.8(vite@7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4))':
dependencies:
'@vitest/spy': 4.1.8
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4)
'@vitest/pretty-format@4.1.8':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@4.1.8':
dependencies:
'@vitest/utils': 4.1.8
pathe: 2.0.3
'@vitest/snapshot@4.1.8':
dependencies:
'@vitest/pretty-format': 4.1.8
'@vitest/utils': 4.1.8
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.8': {}
'@vitest/utils@4.1.8':
dependencies:
'@vitest/pretty-format': 4.1.8
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@ -4447,6 +4625,8 @@ snapshots:
dependencies:
tslib: 2.8.1
assertion-error@2.0.1: {}
atomic-sleep@1.0.0: {}
balanced-match@4.0.4: {}
@ -4497,6 +4677,8 @@ snapshots:
caniuse-lite@1.0.30001792: {}
chai@6.2.2: {}
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
@ -4685,6 +4867,8 @@ snapshots:
es-errors@1.3.0: {}
es-module-lexer@2.1.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
@ -4705,6 +4889,10 @@ snapshots:
escape-html@1.0.3: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
esutils@2.0.3: {}
etag@1.8.1: {}
@ -4726,6 +4914,8 @@ snapshots:
strip-final-newline: 4.0.0
yoctocolors: 2.1.2
expect-type@1.3.0: {}
express@5.2.1:
dependencies:
accepts: 2.0.0
@ -5071,6 +5261,8 @@ snapshots:
object-inspect@1.13.4: {}
obug@2.1.2: {}
on-exit-leak-free@2.1.2: {}
on-finished@2.4.1:
@ -5494,6 +5686,8 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
signal-exit@4.1.0: {}
slash@5.1.0: {}
@ -5511,8 +5705,12 @@ snapshots:
split2@4.2.0: {}
stackback@0.0.2: {}
statuses@2.0.2: {}
std-env@4.1.0: {}
string-argv@0.3.2: {}
strip-ansi@6.0.1:
@ -5539,11 +5737,17 @@ snapshots:
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
tinyexec@1.2.4: {}
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinyrainbow@3.1.0: {}
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
@ -5673,10 +5877,52 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.4
vitest@4.1.8(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4):
dependencies:
'@vitest/expect': 4.1.8
'@vitest/mocker': 4.1.8(vite@7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4))
'@vitest/pretty-format': 4.1.8
'@vitest/runner': 4.1.8
'@vitest/snapshot': 4.1.8
'@vitest/spy': 4.1.8
'@vitest/utils': 4.1.8
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.2
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.2.4
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.6.2
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- terser
- tsx
- yaml
which@2.0.2:
dependencies:
isexe: 2.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
wouter@3.10.0(react@19.1.0):
dependencies:
mitt: 3.0.1