mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-11-12 09:45:47 +00:00
🦠 test: Add Portable Favorites Contracts and Mutation Testing (#17690)
Co-authored-by: Lia <lia@librechat.ai>
This commit is contained in:
parent
e3e7dc6d7c
commit
e70cec8fd5
11 changed files with 2670 additions and 238 deletions
77
.github/workflows/favorites-quality.yml
vendored
Normal file
77
.github/workflows/favorites-quality.yml
vendored
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
name: Favorites Contract Quality
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [dev, canary]
|
||||
paths:
|
||||
- 'packages/data-schemas/src/contracts/favorite.ts'
|
||||
- 'packages/data-schemas/src/methods/favorite*.ts'
|
||||
- 'packages/data-schemas/src/methods/index.ts'
|
||||
- 'packages/data-schemas/src/models/favorite.ts'
|
||||
- 'packages/data-schemas/src/schema/favorite.ts'
|
||||
- 'packages/data-schemas/src/types/favorite.ts'
|
||||
- 'packages/data-schemas/src/models/plugins/tenantIsolation.ts'
|
||||
- 'packages/data-schemas/src/tenant/policy.ts'
|
||||
- 'packages/data-schemas/src/config/tenantContext.ts'
|
||||
- 'packages/data-schemas/src/index.ts'
|
||||
- 'packages/data-schemas/package.json'
|
||||
- 'packages/data-schemas/*config*'
|
||||
- 'packages/data-schemas/jest.globalSetup.mjs'
|
||||
- 'config/jest.*'
|
||||
- 'scripts/crap*.mts'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.github/workflows/favorites-quality.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
favorites:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '24.22.1'
|
||||
cache: npm
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
api/node_modules
|
||||
packages/api/node_modules
|
||||
packages/data-provider/node_modules
|
||||
packages/data-schemas/node_modules
|
||||
key: node-modules-backend-${{ runner.os }}-24.22.1-${{ hashFiles('package-lock.json') }}
|
||||
id: dependencies
|
||||
- name: Install dependencies
|
||||
if: steps.dependencies.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
- name: Build shared types
|
||||
run: npm run build:data-provider
|
||||
- name: Typecheck the advisory reporter
|
||||
run: >-
|
||||
npx --no-install tsc --noEmit --target ES2023 --module NodeNext
|
||||
--moduleResolution NodeNext --allowImportingTsExtensions --skipLibCheck
|
||||
--strict --types node scripts/crap.mts scripts/crap.test.mts
|
||||
- name: Verify the advisory reporter
|
||||
run: node --test scripts/crap.test.mts
|
||||
- name: Favorites contracts, compatibility, and CRAP report
|
||||
run: npm run test:quality:favorites --workspace packages/data-schemas
|
||||
- name: Mutate the real favorites implementation
|
||||
run: npm run test:mutations:favorites --workspace packages/data-schemas
|
||||
- name: Upload advisory reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: favorites-quality-${{ github.sha }}
|
||||
path: packages/data-schemas/coverage/favorites/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -196,3 +196,6 @@ scripts/activity-labels/results/
|
|||
# Chrome user-data directories a Lighthouse run leaves in the repository root when the browser
|
||||
# is handed a Windows profile path from WSL: the whole path arrives as one directory name.
|
||||
/*lighthouse.[0-9]*/
|
||||
|
||||
# Mutation testing sandboxes
|
||||
.stryker-tmp/
|
||||
|
|
|
|||
1772
package-lock.json
generated
1772
package-lock.json
generated
File diff suppressed because it is too large
Load diff
16
packages/data-schemas/jest.favorite.config.mjs
Normal file
16
packages/data-schemas/jest.favorite.config.mjs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { createRequire } from 'node:module';
|
||||
import config from './jest.config.mjs';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export default {
|
||||
...config,
|
||||
// Stryker must use Jest's own environment even when npm nests its dependencies.
|
||||
testEnvironment: require.resolve('jest-environment-node', { paths: [require.resolve('jest')] }),
|
||||
testMatch: ['<rootDir>/src/methods/favorite*.spec.ts'],
|
||||
collectCoverageFrom: ['src/methods/favorite.ts'],
|
||||
coverageDirectory: '<rootDir>/coverage/favorites',
|
||||
coverageReporters: ['text', 'json'],
|
||||
testResultsProcessor: undefined,
|
||||
maxWorkers: 1,
|
||||
};
|
||||
|
|
@ -41,7 +41,10 @@
|
|||
"test:ci": "jest --coverage --ci",
|
||||
"verify": "npm run test:ci",
|
||||
"b:clean": "bun run rimraf dist",
|
||||
"b:build": "bun run b:clean && bun run tsdown"
|
||||
"b:build": "bun run b:clean && bun run tsdown",
|
||||
"test:contracts:favorites": "jest --config jest.favorite.config.mjs --coverage=false",
|
||||
"test:mutations:favorites": "cd ../.. && stryker run packages/data-schemas/stryker.config.mjs",
|
||||
"test:quality:favorites": "jest --config jest.favorite.config.mjs --coverage && node ../../scripts/crap.mts coverage/favorites/coverage-final.json src/methods/favorite.ts"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -64,6 +67,8 @@
|
|||
"micromark-util-decode-string": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stryker-mutator/core": "10.1.1",
|
||||
"@stryker-mutator/jest-runner": "10.1.1",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/node": "^24.12.4",
|
||||
"jest": "^30.2.0",
|
||||
|
|
|
|||
211
packages/data-schemas/src/contracts/favorite.ts
Normal file
211
packages/data-schemas/src/contracts/favorite.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/* eslint-disable jest/no-export -- Shared behavioral suite, invoked by each engine's spec. */
|
||||
import type { ToolFavoriteMethods } from '~/methods/favorite';
|
||||
import type { ToolFavoriteParams } from '~/types/favorite';
|
||||
|
||||
type FavoriteParams = Omit<ToolFavoriteParams, 'userId'> & { userId: string };
|
||||
|
||||
/** The application-facing subset: no documents, queries, sessions, or engine IDs. */
|
||||
export interface FavoriteContract {
|
||||
getToolFavorites(userId: string): ReturnType<ToolFavoriteMethods['getToolFavorites']>;
|
||||
addToolFavorite(params: FavoriteParams): ReturnType<ToolFavoriteMethods['addToolFavorite']>;
|
||||
removeToolFavorite(params: FavoriteParams): ReturnType<ToolFavoriteMethods['removeToolFavorite']>;
|
||||
deleteUserToolFavorites(userId: string): Promise<number>;
|
||||
}
|
||||
|
||||
export interface FavoriteHarness {
|
||||
readonly name: string;
|
||||
readonly users: readonly [string, string];
|
||||
setup(): Promise<FavoriteContract>;
|
||||
teardown(): Promise<void>;
|
||||
reset(): Promise<void>;
|
||||
/** Seed timestamps without depending on the clock or the database's insertion order. */
|
||||
seed(favorites: readonly (FavoriteParams & { createdAt?: Date })[]): Promise<void>;
|
||||
inTenant<T>(tenantId: string, operation: () => Promise<T>): Promise<T>;
|
||||
/** Fail the next persistence operation, not the public method under test. */
|
||||
failNext(operation: 'read' | 'insert' | 'remove' | 'erase', error: Error): void;
|
||||
}
|
||||
|
||||
export function describeFavoriteConformance(harness: FavoriteHarness): void {
|
||||
describe(`favorites contract: ${harness.name}`, () => {
|
||||
let methods: FavoriteContract;
|
||||
const [userA, userB] = harness.users;
|
||||
const favorite: FavoriteParams = { userId: userA, itemType: 'tool', itemId: 'shared' };
|
||||
|
||||
beforeAll(async () => {
|
||||
methods = await harness.setup();
|
||||
});
|
||||
afterAll(() => harness.teardown());
|
||||
beforeEach(() => harness.reset());
|
||||
|
||||
it('returns an empty list for a user without favorites', async () => {
|
||||
expect(await methods.getToolFavorites(userA)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['builtin', 'tool', 'mcp', 'skill'] as const)(
|
||||
'persists a %s favorite and returns only its public fields',
|
||||
async (itemType) => {
|
||||
expect(await methods.addToolFavorite({ ...favorite, itemType })).toEqual({
|
||||
ok: true,
|
||||
added: true,
|
||||
});
|
||||
expect(await methods.getToolFavorites(userA)).toEqual([{ itemType, itemId: 'shared' }]);
|
||||
},
|
||||
);
|
||||
|
||||
it('re-adding a favorite is a no-op', async () => {
|
||||
await methods.addToolFavorite(favorite);
|
||||
expect(await methods.addToolFavorite(favorite)).toEqual({ ok: true, added: false });
|
||||
expect(await methods.getToolFavorites(userA)).toEqual([
|
||||
{ itemType: 'tool', itemId: 'shared' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('identifies favorites by user, kind, and item ID', async () => {
|
||||
await methods.addToolFavorite(favorite);
|
||||
await methods.addToolFavorite({ ...favorite, itemType: 'skill' });
|
||||
await methods.addToolFavorite({ ...favorite, itemId: 'other' });
|
||||
await methods.addToolFavorite({ ...favorite, userId: userB });
|
||||
|
||||
expect(await methods.getToolFavorites(userB)).toEqual([
|
||||
{ itemType: 'tool', itemId: 'shared' },
|
||||
]);
|
||||
const found = await methods.getToolFavorites(userA);
|
||||
expect(found).toHaveLength(3);
|
||||
expect(found).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ itemType: 'tool', itemId: 'shared' },
|
||||
{ itemType: 'skill', itemId: 'shared' },
|
||||
{ itemType: 'tool', itemId: 'other' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('lists by creation time, not insertion order, without reordering a re-add', async () => {
|
||||
await harness.seed([
|
||||
{ ...favorite, itemId: 'newer', createdAt: new Date('2026-02-02T00:00:00Z') },
|
||||
{ ...favorite, itemId: 'older', createdAt: new Date('2026-02-01T00:00:00Z') },
|
||||
]);
|
||||
await methods.addToolFavorite({ ...favorite, itemId: 'older' });
|
||||
expect(await methods.getToolFavorites(userA)).toEqual([
|
||||
{ itemType: 'tool', itemId: 'older' },
|
||||
{ itemType: 'tool', itemId: 'newer' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts an item ID at the maximum length', async () => {
|
||||
const itemId = 'x'.repeat(256);
|
||||
expect(await methods.addToolFavorite({ ...favorite, itemId })).toEqual({
|
||||
ok: true,
|
||||
added: true,
|
||||
});
|
||||
expect(await methods.getToolFavorites(userA)).toEqual([{ itemType: 'tool', itemId }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ itemType: 'agent' as never, itemId: 'invalid-kind' },
|
||||
{ itemType: 'tool' as const, itemId: '' },
|
||||
{ itemType: 'tool' as const, itemId: 'x'.repeat(257) },
|
||||
])('rejects invalid input without persisting it: %j', async (input) => {
|
||||
await expect(methods.addToolFavorite({ ...favorite, ...input })).rejects.toThrow();
|
||||
expect(await methods.getToolFavorites(userA)).toEqual([]);
|
||||
});
|
||||
|
||||
it('allows the last slot, rejects beyond 100, and permits re-adds at the cap', async () => {
|
||||
await harness.seed(
|
||||
Array.from({ length: 99 }, (_, i) => ({ ...favorite, itemId: `tool-${i}` })),
|
||||
);
|
||||
await harness.seed(
|
||||
Array.from({ length: 100 }, (_, i) => ({
|
||||
...favorite,
|
||||
userId: userB,
|
||||
itemId: `other-${i}`,
|
||||
})),
|
||||
);
|
||||
expect(await methods.addToolFavorite(favorite)).toEqual({ ok: true, added: true });
|
||||
await expect(
|
||||
methods.addToolFavorite({ ...favorite, itemId: 'overflow' }),
|
||||
).rejects.toMatchObject({
|
||||
code: 'MAX_FAVORITES_EXCEEDED',
|
||||
message: 'Maximum of 100 favorites reached',
|
||||
limit: 100,
|
||||
});
|
||||
expect(await methods.addToolFavorite(favorite)).toEqual({ ok: true, added: false });
|
||||
expect(await methods.getToolFavorites(userA)).toHaveLength(100);
|
||||
});
|
||||
|
||||
it('concurrent duplicate adds succeed and persist a single favorite', async () => {
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 3 }, () => methods.addToolFavorite(favorite)),
|
||||
);
|
||||
expect(results.every((result) => result.ok)).toBe(true);
|
||||
// Concurrent callers need not agree on `added`; only the persisted identity is unique.
|
||||
expect(await methods.getToolFavorites(userA)).toEqual([
|
||||
{ itemType: 'tool', itemId: 'shared' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes only the requested user, kind, and item, and reports a repeat as a no-op', async () => {
|
||||
await harness.seed([
|
||||
favorite,
|
||||
{ ...favorite, itemType: 'skill' },
|
||||
{ ...favorite, itemId: 'other' },
|
||||
{ ...favorite, userId: userB },
|
||||
]);
|
||||
expect(await methods.removeToolFavorite(favorite)).toEqual({ ok: true, removed: true });
|
||||
expect(await methods.removeToolFavorite(favorite)).toEqual({ ok: true, removed: false });
|
||||
expect(await methods.getToolFavorites(userB)).toEqual([
|
||||
{ itemType: 'tool', itemId: 'shared' },
|
||||
]);
|
||||
const remaining = await methods.getToolFavorites(userA);
|
||||
expect(remaining).toHaveLength(2);
|
||||
expect(remaining).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ itemType: 'skill', itemId: 'shared' },
|
||||
{ itemType: 'tool', itemId: 'other' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('erases all favorites for one user and returns the deletion count', async () => {
|
||||
await harness.seed([
|
||||
favorite,
|
||||
{ ...favorite, itemType: 'skill' },
|
||||
{ ...favorite, userId: userB },
|
||||
]);
|
||||
expect(await methods.deleteUserToolFavorites(userA)).toBe(2);
|
||||
expect(await methods.deleteUserToolFavorites(userA)).toBe(0);
|
||||
expect(await methods.getToolFavorites(userA)).toEqual([]);
|
||||
expect(await methods.getToolFavorites(userB)).toEqual([
|
||||
{ itemType: 'tool', itemId: 'shared' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('cannot read, remove, or erase another tenant’s favorites', async () => {
|
||||
await harness.inTenant('tenant-a', () => methods.addToolFavorite(favorite));
|
||||
await harness.inTenant('tenant-b', async () => {
|
||||
expect(await methods.getToolFavorites(userA)).toEqual([]);
|
||||
expect(await methods.removeToolFavorite(favorite)).toEqual({ ok: true, removed: false });
|
||||
expect(await methods.deleteUserToolFavorites(userA)).toBe(0);
|
||||
});
|
||||
expect(await harness.inTenant('tenant-a', () => methods.getToolFavorites(userA))).toEqual([
|
||||
{ itemType: 'tool', itemId: 'shared' },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['read', 'insert', 'remove', 'erase'] as const)(
|
||||
'propagates an operational %s failure instead of reporting success or absence',
|
||||
async (operation) => {
|
||||
const failure = new Error('persistence unavailable');
|
||||
harness.failNext(operation, failure);
|
||||
const action = {
|
||||
read: () => methods.getToolFavorites(userA),
|
||||
insert: () => methods.addToolFavorite(favorite),
|
||||
remove: () => methods.removeToolFavorite(favorite),
|
||||
erase: () => methods.deleteUserToolFavorites(userA),
|
||||
}[operation];
|
||||
await expect(action()).rejects.toBe(failure);
|
||||
expect(await methods.getToolFavorites(userA)).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
30
packages/data-schemas/src/methods/favorite.exports.spec.ts
Normal file
30
packages/data-schemas/src/methods/favorite.exports.spec.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import { createMethods, createModels, FAVORITE_ITEM_TYPES, MAX_TOOL_FAVORITES } from '..';
|
||||
|
||||
it('keeps favorites available through the existing package facade', async () => {
|
||||
const server = await MongoMemoryServer.create();
|
||||
const instance = new mongoose.Mongoose();
|
||||
instance.set('autoIndex', false);
|
||||
instance.set('autoCreate', false);
|
||||
try {
|
||||
await instance.connect(server.getUri());
|
||||
const models = createModels(instance);
|
||||
await models.ToolFavorite.createIndexes();
|
||||
const methods = createMethods(instance);
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const params = { userId, itemType: 'tool', itemId: 'compatibility' } as const;
|
||||
|
||||
expect(MAX_TOOL_FAVORITES).toBe(100);
|
||||
expect(FAVORITE_ITEM_TYPES).toEqual(['builtin', 'tool', 'mcp', 'skill']);
|
||||
expect(await methods.addToolFavorite(params)).toEqual({ ok: true, added: true });
|
||||
expect(await methods.getToolFavorites(userId)).toEqual([
|
||||
{ itemType: 'tool', itemId: 'compatibility' },
|
||||
]);
|
||||
expect(await methods.removeToolFavorite(params)).toEqual({ ok: true, removed: true });
|
||||
expect(await methods.deleteUserToolFavorites(userId)).toBe(0);
|
||||
} finally {
|
||||
await instance.disconnect();
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
|
@ -1,153 +1,86 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { logger, createModels } from '..';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import { createToolFavoriteMethods, MAX_TOOL_FAVORITES } from './favorite';
|
||||
import type { FavoriteHarness } from '~/contracts/favorite';
|
||||
import { describeFavoriteConformance } from '~/contracts/favorite';
|
||||
import { createToolFavoriteModel } from '~/models/favorite';
|
||||
import { createToolFavoriteMethods } from './favorite';
|
||||
import { tenantStorage } from '~/config/tenantContext';
|
||||
|
||||
logger.silent = true;
|
||||
const instance = new mongoose.Mongoose();
|
||||
const ToolFavorite = createToolFavoriteModel(instance);
|
||||
let server: MongoMemoryServer;
|
||||
|
||||
let ToolFavorite: mongoose.Model<unknown>;
|
||||
let methods: ReturnType<typeof createToolFavoriteMethods>;
|
||||
let mongoServer: MongoMemoryServer;
|
||||
|
||||
const userA = new mongoose.Types.ObjectId().toString();
|
||||
const userB = new mongoose.Types.ObjectId().toString();
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
await mongoose.connect(mongoServer.getUri());
|
||||
createModels(mongoose);
|
||||
ToolFavorite = mongoose.models.ToolFavorite;
|
||||
await ToolFavorite.syncIndexes();
|
||||
methods = createToolFavoriteMethods(mongoose);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await ToolFavorite.deleteMany({});
|
||||
});
|
||||
|
||||
describe('addToolFavorite', () => {
|
||||
test('persists a favorite and getToolFavorites returns the lean projection', async () => {
|
||||
const result = await methods.addToolFavorite({
|
||||
userId: userA,
|
||||
itemType: 'mcp',
|
||||
itemId: 'everything',
|
||||
});
|
||||
expect(result).toEqual({ ok: true, added: true });
|
||||
|
||||
const favorites = await methods.getToolFavorites(userA);
|
||||
expect(favorites).toEqual([{ itemType: 'mcp', itemId: 'everything' }]);
|
||||
});
|
||||
|
||||
test('is idempotent: double-add keeps one doc and reports added: false', async () => {
|
||||
await methods.addToolFavorite({ userId: userA, itemType: 'tool', itemId: 'dalle' });
|
||||
const second = await methods.addToolFavorite({
|
||||
userId: userA,
|
||||
itemType: 'tool',
|
||||
itemId: 'dalle',
|
||||
});
|
||||
expect(second).toEqual({ ok: true, added: false });
|
||||
expect(await ToolFavorite.countDocuments({ user: userA })).toBe(1);
|
||||
});
|
||||
|
||||
test('rejects an itemType outside the enum', async () => {
|
||||
await expect(
|
||||
methods.addToolFavorite({
|
||||
userId: userA,
|
||||
itemType: 'agent' as never,
|
||||
itemId: 'x',
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('rejects an itemId longer than 256 characters', async () => {
|
||||
await expect(
|
||||
methods.addToolFavorite({ userId: userA, itemType: 'tool', itemId: 'x'.repeat(257) }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('throws a coded error at the cap, but re-adding an existing favorite still succeeds', async () => {
|
||||
const docs = Array.from({ length: MAX_TOOL_FAVORITES }, (_, i) => ({
|
||||
user: userA,
|
||||
itemType: 'tool',
|
||||
itemId: `tool-${i}`,
|
||||
}));
|
||||
await ToolFavorite.insertMany(docs);
|
||||
|
||||
const rejection = methods.addToolFavorite({
|
||||
userId: userA,
|
||||
itemType: 'tool',
|
||||
itemId: 'one-too-many',
|
||||
});
|
||||
await expect(rejection).rejects.toMatchObject({
|
||||
code: 'MAX_FAVORITES_EXCEEDED',
|
||||
limit: MAX_TOOL_FAVORITES,
|
||||
});
|
||||
|
||||
const existing = await methods.addToolFavorite({
|
||||
userId: userA,
|
||||
itemType: 'tool',
|
||||
itemId: 'tool-0',
|
||||
});
|
||||
expect(existing).toEqual({ ok: true, added: false });
|
||||
});
|
||||
|
||||
test('concurrent duplicate adds do not throw (unique index backstop)', async () => {
|
||||
const params = { userId: userA, itemType: 'skill', itemId: 'abc123' } as const;
|
||||
const results = await Promise.all([
|
||||
methods.addToolFavorite(params),
|
||||
methods.addToolFavorite(params),
|
||||
methods.addToolFavorite(params),
|
||||
]);
|
||||
expect(results.every((r) => r.ok)).toBe(true);
|
||||
expect(await ToolFavorite.countDocuments({ user: userA })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeToolFavorite', () => {
|
||||
test('removes a favorite and is idempotent', async () => {
|
||||
await methods.addToolFavorite({ userId: userA, itemType: 'builtin', itemId: 'web_search' });
|
||||
|
||||
const removed = await methods.removeToolFavorite({
|
||||
userId: userA,
|
||||
itemType: 'builtin',
|
||||
itemId: 'web_search',
|
||||
});
|
||||
expect(removed).toEqual({ ok: true, removed: true });
|
||||
|
||||
const again = await methods.removeToolFavorite({
|
||||
userId: userA,
|
||||
itemType: 'builtin',
|
||||
itemId: 'web_search',
|
||||
});
|
||||
expect(again).toEqual({ ok: true, removed: false });
|
||||
expect(await methods.getToolFavorites(userA)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolFavorites', () => {
|
||||
test('is isolated per user', async () => {
|
||||
await methods.addToolFavorite({ userId: userA, itemType: 'tool', itemId: 'dalle' });
|
||||
await methods.addToolFavorite({ userId: userB, itemType: 'mcp', itemId: 'everything' });
|
||||
|
||||
expect(await methods.getToolFavorites(userA)).toEqual([{ itemType: 'tool', itemId: 'dalle' }]);
|
||||
expect(await methods.getToolFavorites(userB)).toEqual([
|
||||
{ itemType: 'mcp', itemId: 'everything' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not distinguish same itemId across kinds (compound identity)', async () => {
|
||||
await methods.addToolFavorite({ userId: userA, itemType: 'tool', itemId: 'shared-id' });
|
||||
await methods.addToolFavorite({ userId: userA, itemType: 'skill', itemId: 'shared-id' });
|
||||
|
||||
const favorites = await methods.getToolFavorites(userA);
|
||||
expect(favorites).toHaveLength(2);
|
||||
expect(new Set(favorites.map((f) => `${f.itemType}:${f.itemId}`))).toEqual(
|
||||
new Set(['tool:shared-id', 'skill:shared-id']),
|
||||
const harness: FavoriteHarness = {
|
||||
name: 'mongoose',
|
||||
users: [new mongoose.Types.ObjectId().toString(), new mongoose.Types.ObjectId().toString()],
|
||||
async setup() {
|
||||
server = await MongoMemoryServer.create();
|
||||
await instance.connect(server.getUri());
|
||||
await ToolFavorite.syncIndexes();
|
||||
return createToolFavoriteMethods(instance);
|
||||
},
|
||||
async teardown() {
|
||||
await instance.disconnect();
|
||||
await server?.stop();
|
||||
},
|
||||
async reset() {
|
||||
jest.restoreAllMocks();
|
||||
await ToolFavorite.deleteMany({});
|
||||
},
|
||||
async seed(favorites) {
|
||||
await ToolFavorite.insertMany(
|
||||
favorites.map(({ userId, ...fields }) => ({ user: userId, ...fields })),
|
||||
);
|
||||
},
|
||||
inTenant: (tenantId, operation) => tenantStorage.run({ tenantId }, operation),
|
||||
failNext(operation, error) {
|
||||
const method = {
|
||||
read: 'find',
|
||||
insert: 'updateOne',
|
||||
remove: 'deleteOne',
|
||||
erase: 'deleteMany',
|
||||
} as const;
|
||||
jest.spyOn(ToolFavorite, method[operation]).mockImplementationOnce(() => {
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
describeFavoriteConformance(harness);
|
||||
|
||||
describe('favorites Mongo adapter', () => {
|
||||
beforeAll(() => harness.setup());
|
||||
afterAll(() => harness.teardown());
|
||||
beforeEach(() => harness.reset());
|
||||
|
||||
it('enforces compound uniqueness in the database, not only in application checks', async () => {
|
||||
const record = { user: harness.users[0], itemType: 'tool', itemId: 'unique' };
|
||||
await ToolFavorite.create(record);
|
||||
await expect(ToolFavorite.create(record)).rejects.toMatchObject({ code: 11000 });
|
||||
});
|
||||
|
||||
it('retains ObjectId input compatibility', async () => {
|
||||
const methods = createToolFavoriteMethods(instance);
|
||||
const params = {
|
||||
userId: new mongoose.Types.ObjectId(harness.users[0]),
|
||||
itemType: 'mcp',
|
||||
itemId: 'server',
|
||||
} as const;
|
||||
expect(await methods.addToolFavorite(params)).toEqual({ ok: true, added: true });
|
||||
expect(await methods.removeToolFavorite(params)).toEqual({ ok: true, removed: true });
|
||||
});
|
||||
|
||||
it('treats a duplicate-key write race as success while keeping one record', async () => {
|
||||
const methods = createToolFavoriteMethods(instance);
|
||||
const params = { userId: harness.users[0], itemType: 'skill', itemId: 'race' } as const;
|
||||
const update = ToolFavorite.collection.updateOne.bind(ToolFavorite.collection);
|
||||
jest.spyOn(ToolFavorite.collection, 'updateOne').mockImplementationOnce(async (...args) => {
|
||||
await update(...args);
|
||||
throw new mongoose.mongo.MongoServerError({ code: 11000, message: 'duplicate key' });
|
||||
});
|
||||
expect(await methods.addToolFavorite(params)).toEqual({ ok: true, added: true });
|
||||
expect(await methods.getToolFavorites(harness.users[0])).toEqual([
|
||||
{ itemType: 'skill', itemId: 'race' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
35
packages/data-schemas/stryker.config.mjs
Normal file
35
packages/data-schemas/stryker.config.mjs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/** Run from the repository root so shared Jest configuration is copied into the sandbox. */
|
||||
export default {
|
||||
$schema: './node_modules/@stryker-mutator/core/schema/stryker-schema.json',
|
||||
plugins: ['@stryker-mutator/jest-runner'],
|
||||
testRunner: 'jest',
|
||||
jest: {
|
||||
projectType: 'custom',
|
||||
configFile: 'packages/data-schemas/jest.favorite.config.mjs',
|
||||
enableFindRelatedTests: true,
|
||||
config: { testMatch: ['<rootDir>/src/methods/favorite.spec.ts'] },
|
||||
},
|
||||
mutate: ['packages/data-schemas/src/methods/favorite.ts'],
|
||||
coverageAnalysis: 'perTest',
|
||||
concurrency: 2,
|
||||
incremental: !process.env.CI,
|
||||
incrementalFile: 'packages/data-schemas/coverage/favorites/incremental.json',
|
||||
reporters: ['clear-text', 'progress', 'json', 'html'],
|
||||
jsonReporter: { fileName: 'packages/data-schemas/coverage/favorites/mutation.json' },
|
||||
htmlReporter: { fileName: 'packages/data-schemas/coverage/favorites/mutation.html' },
|
||||
// Advisory during the pilot. Surviving mutants remain visible in both reports.
|
||||
thresholds: { high: 90, low: 70, break: 0 },
|
||||
ignorePatterns: [
|
||||
'/*',
|
||||
'!/package.json',
|
||||
'!/config/',
|
||||
'/config/*',
|
||||
'!/config/jest.*',
|
||||
'!/packages/',
|
||||
'/packages/*',
|
||||
'!/packages/data-schemas/',
|
||||
'!/packages/data-provider/',
|
||||
'**/coverage/',
|
||||
'**/misc/',
|
||||
],
|
||||
};
|
||||
296
scripts/crap.mts
Normal file
296
scripts/crap.mts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import { Linter } from 'eslint';
|
||||
import { parseArgs } from 'node:util';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import parser from '@typescript-eslint/parser';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
||||
|
||||
import type { Rule } from 'eslint';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('../', import.meta.url));
|
||||
const FAVORITES = join(ROOT, 'packages/data-schemas/src/methods/favorite.ts');
|
||||
const FUNCTION_TYPES = new Set([
|
||||
'FunctionDeclaration',
|
||||
'FunctionExpression',
|
||||
'ArrowFunctionExpression',
|
||||
]);
|
||||
|
||||
export interface FunctionReport {
|
||||
file: string;
|
||||
function: string;
|
||||
line: number;
|
||||
column: number;
|
||||
complexity: number;
|
||||
statements: number;
|
||||
coveredStatements: number;
|
||||
/** Fraction in [0, 1], excluding nested functions; null means no instrumentation. */
|
||||
statementCoverage: number | null;
|
||||
crap: number | null;
|
||||
}
|
||||
|
||||
type FunctionNode = Extract<
|
||||
Rule.Node,
|
||||
{ type: 'FunctionDeclaration' | 'FunctionExpression' | 'ArrowFunctionExpression' }
|
||||
>;
|
||||
|
||||
interface MeasuredFunction {
|
||||
head: number;
|
||||
start: number;
|
||||
end: number;
|
||||
report: FunctionReport;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
export function crapScore(complexity: number, coverage: number | null): number | null {
|
||||
if (!Number.isInteger(complexity) || complexity < 1) {
|
||||
throw new Error('Complexity must be a positive integer');
|
||||
}
|
||||
if (coverage === null) return null;
|
||||
if (!Number.isFinite(coverage) || coverage < 0 || coverage > 1) {
|
||||
throw new Error('Statement coverage must be a fraction in [0, 1]');
|
||||
}
|
||||
return complexity ** 2 * (1 - coverage) ** 3 + complexity;
|
||||
}
|
||||
|
||||
function functionName(node: FunctionNode, context: Rule.RuleContext): string {
|
||||
if ('id' in node && node.id?.type === 'Identifier') return node.id.name;
|
||||
const parent = node.parent;
|
||||
if (parent.type === 'VariableDeclarator' && parent.id.type === 'Identifier')
|
||||
return parent.id.name;
|
||||
if (
|
||||
parent.type === 'Property' ||
|
||||
parent.type === 'MethodDefinition' ||
|
||||
parent.type === 'PropertyDefinition'
|
||||
) {
|
||||
return context.sourceCode.getText(parent.key);
|
||||
}
|
||||
return '(anonymous)';
|
||||
}
|
||||
|
||||
/** Uses ESLint's real classic rule, not an approximation of its branch counting. */
|
||||
export function measureFunctions(
|
||||
file: string,
|
||||
source: string,
|
||||
coverage?: unknown,
|
||||
): FunctionReport[] {
|
||||
const absoluteFile = resolve(file);
|
||||
const functions: MeasuredFunction[] = [];
|
||||
const lines = source.split(/\r\n|[\n\r\u2028\u2029]/u);
|
||||
const offsets = [0];
|
||||
for (const match of source.matchAll(/\r\n|[\n\r\u2028\u2029]/gu)) {
|
||||
offsets.push(match.index + match[0].length);
|
||||
}
|
||||
const offset = (position: unknown): number => {
|
||||
if (!isRecord(position)) throw new Error(`${file}: invalid coverage location`);
|
||||
const { line, column } = position;
|
||||
if (
|
||||
typeof line !== 'number' ||
|
||||
typeof column !== 'number' ||
|
||||
!Number.isInteger(line) ||
|
||||
!Number.isInteger(column) ||
|
||||
line < 1 ||
|
||||
line > lines.length ||
|
||||
column < 0 ||
|
||||
column > lines[line - 1].length
|
||||
) {
|
||||
throw new Error(`${file}: coverage location is outside the source`);
|
||||
}
|
||||
return offsets[line - 1] + column;
|
||||
};
|
||||
const displayFile = relative(ROOT, absoluteFile);
|
||||
const name = displayFile.startsWith(`..${sep}`) ? absoluteFile : displayFile.split(sep).join('/');
|
||||
const linter = new Linter();
|
||||
const messages = linter.verify(
|
||||
source,
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx,mjs,mts,cjs,cts}'],
|
||||
languageOptions: { parser, ecmaVersion: 'latest', sourceType: 'module' },
|
||||
linterOptions: { noInlineConfig: true },
|
||||
plugins: {
|
||||
metrics: {
|
||||
rules: {
|
||||
functions: {
|
||||
create(context: Rule.RuleContext) {
|
||||
return {
|
||||
'FunctionDeclaration, FunctionExpression, ArrowFunctionExpression'(
|
||||
node: Rule.Node,
|
||||
) {
|
||||
if (
|
||||
node.type !== 'FunctionDeclaration' &&
|
||||
node.type !== 'FunctionExpression' &&
|
||||
node.type !== 'ArrowFunctionExpression'
|
||||
)
|
||||
return;
|
||||
const parent = node.parent;
|
||||
// ESLint reports methods at their property key and arrows at the => token.
|
||||
const isMethod =
|
||||
parent.type === 'Property' ||
|
||||
parent.type === 'MethodDefinition' ||
|
||||
parent.type === 'PropertyDefinition';
|
||||
const declaration = isMethod ? parent : node;
|
||||
const head =
|
||||
!isMethod && node.type === 'ArrowFunctionExpression'
|
||||
? context.sourceCode.getTokenBefore(
|
||||
node.body,
|
||||
(token) => token.value === '=>',
|
||||
)
|
||||
: declaration;
|
||||
if (!node.range || !node.loc || !head?.range)
|
||||
throw new Error('Parser did not provide ranges');
|
||||
functions.push({
|
||||
head: head.range[0],
|
||||
start: node.range[0],
|
||||
end: node.range[1],
|
||||
report: {
|
||||
file: name,
|
||||
function: functionName(node, context),
|
||||
line: node.loc.start.line,
|
||||
column: node.loc.start.column + 1,
|
||||
complexity: 0,
|
||||
statements: 0,
|
||||
coveredStatements: 0,
|
||||
statementCoverage: null,
|
||||
crap: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'metrics/functions': 'error',
|
||||
complexity: ['error', { max: 0, variant: 'classic' }],
|
||||
},
|
||||
},
|
||||
{ filename: basename(absoluteFile) },
|
||||
);
|
||||
for (const message of messages) {
|
||||
if (message.fatal || (message.severity === 2 && message.ruleId !== 'complexity')) {
|
||||
throw new Error(`${file}:${message.line}: ${message.message}`);
|
||||
}
|
||||
// ESLint also measures implicit class initializers, which are not source functions.
|
||||
if (
|
||||
message.ruleId !== 'complexity' ||
|
||||
!FUNCTION_TYPES.has(message.nodeType ?? '') ||
|
||||
/^Class (?:field initializer|static block)/u.test(message.message)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const point = offset({ line: message.line, column: message.column - 1 });
|
||||
const owner = functions.find((fn) => fn.head === point);
|
||||
const complexity = /has a complexity of (\d+)\./u.exec(message.message)?.[1];
|
||||
if (!owner || !complexity || owner.report.complexity !== 0) {
|
||||
throw new Error(`${file}: unrecognized ESLint complexity diagnostic: ${message.message}`);
|
||||
}
|
||||
owner.report.complexity = Number(complexity);
|
||||
}
|
||||
if (functions.some(({ report }) => report.complexity === 0)) {
|
||||
throw new Error(`${file}: ESLint did not measure every function`);
|
||||
}
|
||||
if (coverage !== undefined) {
|
||||
if (
|
||||
!isRecord(coverage) ||
|
||||
typeof coverage.path !== 'string' ||
|
||||
resolve(coverage.path) !== absoluteFile ||
|
||||
!isRecord(coverage.statementMap) ||
|
||||
!isRecord(coverage.s)
|
||||
) {
|
||||
throw new Error(`${file}: invalid coverage entry or source path mismatch`);
|
||||
}
|
||||
const statements = Object.entries(coverage.statementMap);
|
||||
if (statements.length !== Object.keys(coverage.s).length) {
|
||||
throw new Error(`${file}: statement map/counter mismatch`);
|
||||
}
|
||||
for (const [id, location] of statements) {
|
||||
const hits = coverage.s[id];
|
||||
if (
|
||||
!isRecord(location) ||
|
||||
typeof hits !== 'number' ||
|
||||
!Number.isSafeInteger(hits) ||
|
||||
hits < 0
|
||||
) {
|
||||
throw new Error(`${file}: invalid statement or counter ${id}`);
|
||||
}
|
||||
const start = offset(location.start);
|
||||
const end = offset(location.end);
|
||||
if (start >= end) throw new Error(`${file}: invalid statement range ${id}`);
|
||||
// The declaration of a nested function belongs to its parent; its body does not.
|
||||
const owner = functions.findLast((fn) => fn.start <= start && end <= fn.end);
|
||||
if (!owner) continue;
|
||||
owner.report.statements++;
|
||||
if (hits > 0) owner.report.coveredStatements++;
|
||||
}
|
||||
}
|
||||
return functions.map(({ report }) => {
|
||||
report.statementCoverage = report.statements
|
||||
? report.coveredStatements / report.statements
|
||||
: null;
|
||||
report.crap = crapScore(report.complexity, report.statementCoverage);
|
||||
return report;
|
||||
});
|
||||
}
|
||||
|
||||
export function createReport(coverageFile: string, sources: string[] = [FAVORITES]) {
|
||||
const coverage: unknown = JSON.parse(readFileSync(resolve(coverageFile), 'utf8'));
|
||||
if (!isRecord(coverage)) throw new Error('Expected an Istanbul coverage object');
|
||||
const entries = new Map<string, unknown>();
|
||||
for (const [file, entry] of Object.entries(coverage)) {
|
||||
const key = resolve(file);
|
||||
if (entries.has(key)) throw new Error(`Duplicate coverage path: ${file}`);
|
||||
entries.set(key, entry);
|
||||
}
|
||||
return {
|
||||
complexityMetric: 'eslint/classic',
|
||||
coverageMetric: 'per-function statement fraction (nested functions excluded)',
|
||||
functions: [...new Set(sources.map((file) => resolve(file)))].flatMap((file) =>
|
||||
measureFunctions(file, readFileSync(file, 'utf8'), entries.get(file)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
const { values, positionals } = parseArgs({
|
||||
allowPositionals: true,
|
||||
options: { output: { type: 'string', short: 'o' }, help: { type: 'boolean', short: 'h' } },
|
||||
});
|
||||
if (values.help) {
|
||||
console.log(
|
||||
'Usage: node scripts/crap.mts [--output path] <coverage-final.json> [source.ts ...]',
|
||||
);
|
||||
} else {
|
||||
const [coverageFile, ...sources] = positionals;
|
||||
if (!coverageFile) throw new Error('Expected coverage-final.json; use --help for usage');
|
||||
const files = sources.length ? sources : [FAVORITES];
|
||||
const output = resolve(values.output ?? join(dirname(resolve(coverageFile)), 'crap.json'));
|
||||
if ([coverageFile, ...files].some((file) => resolve(file) === output)) {
|
||||
throw new Error('Output must not overwrite coverage or source files');
|
||||
}
|
||||
const report = createReport(coverageFile, files);
|
||||
mkdirSync(dirname(output), { recursive: true });
|
||||
writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(
|
||||
'Advisory CRAP baseline | CC: ESLint classic | Coverage: statements, excluding nested functions',
|
||||
);
|
||||
console.log('File\tFunction\tLine:Column\tCC\tStatement coverage\tCRAP');
|
||||
for (const row of report.functions) {
|
||||
const coverage =
|
||||
row.statementCoverage === null
|
||||
? 'N/A'
|
||||
: `${(row.statementCoverage * 100).toFixed(1)}% (${row.coveredStatements}/${row.statements})`;
|
||||
console.log(
|
||||
`${row.file}\t${row.function}\t${row.line}:${row.column}\t${row.complexity}\t${coverage}\t${row.crap?.toFixed(2) ?? 'N/A'}`,
|
||||
);
|
||||
}
|
||||
console.log(`JSON: ${output}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`crap: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
236
scripts/crap.test.mts
Normal file
236
scripts/crap.test.mts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { test } from 'node:test';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
|
||||
import { crapScore, createReport, measureFunctions } from './crap.mts';
|
||||
|
||||
const FILE = resolve('sample.ts');
|
||||
const SCRIPT = fileURLToPath(new URL('./crap.mts', import.meta.url));
|
||||
|
||||
function coverageFor(source: string, statements: [string, number][], file = FILE) {
|
||||
const position = (index: number) => {
|
||||
const lines = source.slice(0, index).split('\n');
|
||||
return { line: lines.length, column: lines.at(-1)!.length };
|
||||
};
|
||||
return {
|
||||
path: file,
|
||||
statementMap: Object.fromEntries(
|
||||
statements.map(([text], id) => {
|
||||
const start = source.indexOf(text);
|
||||
assert.notEqual(start, -1, `Missing fixture statement: ${text}`);
|
||||
return [id, { start: position(start), end: position(start + text.length) }];
|
||||
}),
|
||||
),
|
||||
s: Object.fromEntries(statements.map(([, hits], id) => [id, hits])),
|
||||
};
|
||||
}
|
||||
|
||||
test('CRAP uses statement coverage fractions and preserves unknown coverage', () => {
|
||||
assert.equal(crapScore(4, 1), 4);
|
||||
assert.equal(crapScore(4, 0), 20);
|
||||
assert.equal(crapScore(3, 0.5), 4.125);
|
||||
assert.equal(crapScore(4, null), null);
|
||||
for (const coverage of [-1, 1.1, NaN, Infinity]) {
|
||||
assert.throws(() => crapScore(4, coverage), /fraction/);
|
||||
}
|
||||
for (const complexity of [0, -1, 1.5, Infinity]) {
|
||||
assert.throws(() => crapScore(complexity, 1), /positive integer/);
|
||||
}
|
||||
});
|
||||
|
||||
test('real ESLint counts classic switch cases, logical and conditional branches in TypeScript', () => {
|
||||
const source = `/* eslint complexity: off */
|
||||
function choose(value: number): number {
|
||||
switch (value) {
|
||||
case 0: return 0;
|
||||
case 1: return 1;
|
||||
default: return value > 2 && value < 5 ? 2 : -1;
|
||||
}
|
||||
}`;
|
||||
const [row] = measureFunctions(FILE, source);
|
||||
assert.equal(row.function, 'choose');
|
||||
assert.equal(row.line, 2);
|
||||
assert.equal(row.complexity, 5);
|
||||
assert.equal(row.statementCoverage, null);
|
||||
assert.equal(row.crap, null);
|
||||
});
|
||||
|
||||
test('nested functions own only their statements, including when sharing a line', () => {
|
||||
for (const separator of [' ', '\n']) {
|
||||
const source = `const outer = () => {${separator}const inner = () => 1;${separator}return inner(); };`;
|
||||
const coverage = coverageFor(source, [
|
||||
['const inner = () => 1;', 0],
|
||||
['1', 1],
|
||||
['return inner();', 0],
|
||||
]);
|
||||
const [outer, inner] = measureFunctions(FILE, source, coverage);
|
||||
assert.equal(outer.function, 'outer');
|
||||
assert.equal(outer.complexity, 1);
|
||||
assert.equal(outer.statements, 2);
|
||||
assert.equal(outer.coveredStatements, 0);
|
||||
assert.equal(outer.statementCoverage, 0);
|
||||
assert.equal(outer.crap, 2);
|
||||
assert.equal(inner.function, 'inner');
|
||||
assert.equal(inner.statements, 1);
|
||||
assert.equal(inner.statementCoverage, 1);
|
||||
assert.equal(inner.crap, inner.complexity);
|
||||
}
|
||||
});
|
||||
|
||||
test('nested branches and implicit class initializers do not inflate enclosing complexity', () => {
|
||||
const source = `function outer() {
|
||||
class Box {
|
||||
value = true ? 1 : 2;
|
||||
handler = () => false ? 1 : 2;
|
||||
}
|
||||
return Box;
|
||||
}`;
|
||||
const rows = measureFunctions(FILE, source);
|
||||
assert.deepEqual(
|
||||
rows.map((row) => [row.function, row.complexity]),
|
||||
[
|
||||
['outer', 1],
|
||||
['handler', 2],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('attributes method diagnostics reported before the function node itself', () => {
|
||||
const source = `const box = {
|
||||
read(value: number) { if (value) return 1; return 0; },
|
||||
write: (value: number) => value ? 1 : 0,
|
||||
};`;
|
||||
const rows = measureFunctions(FILE, source);
|
||||
assert.deepEqual(
|
||||
rows.map((row) => [row.function, row.complexity]),
|
||||
[
|
||||
['read', 2],
|
||||
['write', 2],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('missing or empty instrumentation is N/A, not 100%; explicit zero hits stays zero', () => {
|
||||
const source = 'const value = 1; function read() { return value; }';
|
||||
const missing = [
|
||||
undefined,
|
||||
coverageFor(source, []),
|
||||
coverageFor(source, [['const value = 1;', 1]]),
|
||||
];
|
||||
for (const coverage of missing) {
|
||||
const [row] = measureFunctions(FILE, source, coverage);
|
||||
assert.equal(row.statements, 0);
|
||||
assert.equal(row.statementCoverage, null);
|
||||
assert.equal(row.crap, null);
|
||||
}
|
||||
const [zero] = measureFunctions(FILE, source, coverageFor(source, [['return value;', 0]]));
|
||||
assert.equal(zero.statementCoverage, 0);
|
||||
assert.equal(zero.crap, 2);
|
||||
});
|
||||
|
||||
test('rejects malformed coverage, counter mismatches and stale out-of-source ranges', () => {
|
||||
const source = 'function read() { return 1; }';
|
||||
const valid = coverageFor(source, [['return 1;', 1]]);
|
||||
const bad = [
|
||||
null,
|
||||
[],
|
||||
{},
|
||||
{ ...valid, path: resolve('other.ts') },
|
||||
{ ...valid, s: {} },
|
||||
{ ...valid, s: { 1: 1 } },
|
||||
{ ...valid, s: { 0: -1 } },
|
||||
{ ...valid, s: { 0: 0.5 } },
|
||||
{ ...valid, s: { 0: '1' } },
|
||||
...[
|
||||
{ line: 0, column: 0 },
|
||||
{ line: 2, column: 0 },
|
||||
{ line: 1, column: source.length + 1 },
|
||||
{ line: 1, column: -1 },
|
||||
{ line: 1, column: 1.5 },
|
||||
{ line: 1, column: source.length },
|
||||
].map((start) => ({ ...valid, statementMap: { 0: { ...valid.statementMap[0], start } } })),
|
||||
];
|
||||
for (const coverage of bad) {
|
||||
assert.throws(() => measureFunctions(FILE, source, coverage), /invalid|mismatch|outside/i);
|
||||
}
|
||||
assert.throws(() => measureFunctions(FILE, 'function broken('), /Parsing error/);
|
||||
});
|
||||
|
||||
test('validates CRLF source locations', () => {
|
||||
const source = 'function read() {\r\n return 1;\r\n}';
|
||||
const coverage = {
|
||||
path: FILE,
|
||||
statementMap: { 0: { start: { line: 2, column: 2 }, end: { line: 2, column: 11 } } },
|
||||
s: { 0: 1 },
|
||||
};
|
||||
assert.equal(measureFunctions(FILE, source, coverage)[0].statementCoverage, 1);
|
||||
});
|
||||
|
||||
test('CLI resolves source/output paths from package cwd and writes advisory JSON', (t) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'crap-'));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const cwd = join(directory, 'package');
|
||||
const file = join(cwd, 'src', 'favorite.ts');
|
||||
const coverageFile = join(cwd, 'coverage', 'favorites', 'coverage-final.json');
|
||||
mkdirSync(join(cwd, 'src'), { recursive: true });
|
||||
mkdirSync(join(cwd, 'coverage', 'favorites'), { recursive: true });
|
||||
const source = 'export function read(): number { return 1; }';
|
||||
writeFileSync(file, source);
|
||||
writeFileSync(
|
||||
coverageFile,
|
||||
JSON.stringify({ [file]: coverageFor(source, [['return 1;', 0]], file) }),
|
||||
);
|
||||
const run = (...args: string[]) =>
|
||||
spawnSync(process.execPath, [SCRIPT, ...args], { cwd, encoding: 'utf8' });
|
||||
const result = run('coverage/favorites/coverage-final.json', 'src/favorite.ts');
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Advisory CRAP/);
|
||||
assert.match(result.stdout, /0\.0% \(0\/1\)\s+2\.00/);
|
||||
const report = JSON.parse(readFileSync(join(cwd, 'coverage', 'favorites', 'crap.json'), 'utf8'));
|
||||
assert.deepEqual(report, createReport(coverageFile, [file]));
|
||||
assert.equal(report.functions[0].file, file);
|
||||
assert.equal(run('--output', 'custom/crap.json', coverageFile, file).status, 0);
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(cwd, 'custom', 'crap.json'), 'utf8')), report);
|
||||
assert.equal(run('--output', file, coverageFile, file).status, 1);
|
||||
assert.equal(readFileSync(file, 'utf8'), source);
|
||||
assert.throws(() => createReport(join(directory, 'missing.json'), [file]), /ENOENT/);
|
||||
assert.throws(() => createReport(coverageFile, [join(directory, 'missing.ts')]), /ENOENT/);
|
||||
writeFileSync(coverageFile, '{}');
|
||||
const unknown = run(coverageFile, file);
|
||||
assert.equal(unknown.status, 0, unknown.stderr);
|
||||
assert.match(unknown.stdout, /N\/A\s+N\/A/);
|
||||
const defaults = run(coverageFile);
|
||||
assert.equal(defaults.status, 0, defaults.stderr);
|
||||
const defaultReport = JSON.parse(
|
||||
readFileSync(join(cwd, 'coverage', 'favorites', 'crap.json'), 'utf8'),
|
||||
);
|
||||
assert.ok(defaultReport.functions.length > 0);
|
||||
assert.ok(
|
||||
defaultReport.functions.every(
|
||||
(row: { file: string }) => row.file === 'packages/data-schemas/src/methods/favorite.ts',
|
||||
),
|
||||
);
|
||||
writeFileSync(coverageFile, '{invalid');
|
||||
assert.equal(run(coverageFile, file).status, 1);
|
||||
writeFileSync(coverageFile, '[]');
|
||||
assert.throws(() => createReport(coverageFile, [file]), /Istanbul coverage object/);
|
||||
});
|
||||
|
||||
test('importing the reporter does not execute its CLI', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'--input-type=module',
|
||||
'-e',
|
||||
`import ${JSON.stringify(new URL('./crap.mts', import.meta.url).href)}`,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.equal(result.stderr, '');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue