Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
180 lines
7.3 KiB
JavaScript
180 lines
7.3 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Failing-first property-based tests for src/mcp-catalog.cts (compiled to
|
|
* msd-core/bin/lib/mcp-catalog.cjs) — issue #3072 (epic #1671 Phase B),
|
|
* `.msd/phase/feat-3072-mcp-served-catalog/40-design.md`.
|
|
*
|
|
* Covers 50-test-matrix.md rows 53-55:
|
|
* (a) every indexed uri round-trips through readResource
|
|
* (b) no generated traversal-shaped string ever resolves to a read
|
|
* (c) pagination partitions the resource list for any page size
|
|
*
|
|
* Uses the shared `fast-check` global config (`tests/helpers/
|
|
* fast-check-setup.cjs`: numRuns 200, seed 42, overridable via
|
|
* `MSD_FC_SEED`) — same pattern as `tests/adr-parser.property.test.cjs`.
|
|
* Fixtures are built over the SAME injected `readFile`/`readDir` seam used
|
|
* throughout `tests/mcp-catalog.test.cjs` — never real filesystem
|
|
* permission tricks.
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fc = require('./helpers/fast-check-setup.cjs');
|
|
|
|
const { buildCatalog, readResource, listResources, getPrompt } = require('../msd-core/bin/lib/mcp-catalog.cjs');
|
|
|
|
// ─── shared fixture: a small, fixed catalog over injected seams ────────────
|
|
|
|
function dirEntry(name, isDir) {
|
|
return { name, isDirectory: () => isDir };
|
|
}
|
|
|
|
function makeFakeFs(root, files) {
|
|
const abs = (rel) => (rel ? `${root}/${rel}` : root);
|
|
const fileMap = new Map(Object.entries(files).map(([rel, content]) => [abs(rel), content]));
|
|
const dirMap = new Map();
|
|
for (const rel of Object.keys(files)) {
|
|
const parts = rel.split('/');
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const dirRel = parts.slice(0, i).join('/');
|
|
const dirAbs = abs(dirRel);
|
|
const isLast = i === parts.length - 1;
|
|
if (!dirMap.has(dirAbs)) dirMap.set(dirAbs, new Map());
|
|
dirMap.get(dirAbs).set(parts[i], dirEntry(parts[i], !isLast));
|
|
}
|
|
}
|
|
// Production (`buildCatalog`) resolves paths via `path.join`, which is
|
|
// backslash-separated on Windows; this fake's maps are keyed POSIX. A real
|
|
// `fs` accepts both separators, so the fake must too — normalize the
|
|
// incoming lookup key unconditionally (never process.platform-gated).
|
|
// Caught by CI on windows-latest: every lookup missed, the fake indexed
|
|
// zero entries, and the anti-vacuity guards correctly flagged it.
|
|
const norm = (p) => String(p).replace(/\\/g, '/');
|
|
const readDir = (absPath) => {
|
|
const m = dirMap.get(norm(absPath));
|
|
if (!m) throw new Error(`ENOENT (fake fs): ${absPath}`);
|
|
return [...m.values()];
|
|
};
|
|
const readFile = (absPath) => {
|
|
const key = norm(absPath);
|
|
if (!fileMap.has(key)) throw new Error(`ENOENT (fake fs): ${absPath}`);
|
|
return fileMap.get(key);
|
|
};
|
|
return { root, readFile, readDir };
|
|
}
|
|
|
|
const FIXTURE_RESOURCE_COUNT = 9;
|
|
const FIXTURE_PROMPT_COUNT = 4;
|
|
|
|
function buildFixtureCatalog() {
|
|
const files = {};
|
|
for (let i = 0; i < FIXTURE_RESOURCE_COUNT; i++) {
|
|
const segment = i % 2 === 0 ? 'workflows' : 'references';
|
|
files[`msd-core/${segment}/entry-${i}.md`] = `# entry ${i}\n\nbody text for entry ${i}\n`;
|
|
}
|
|
for (let i = 0; i < FIXTURE_PROMPT_COUNT; i++) {
|
|
files[`commands/msd/cmd-${i}.md`] = `# cmd ${i}\n\nprompt body ${i}\n`;
|
|
}
|
|
const fake = makeFakeFs('/fake-root-property', files);
|
|
return buildCatalog({ root: fake.root, readFile: fake.readFile, readDir: fake.readDir });
|
|
}
|
|
|
|
// ─── row 53: every indexed uri round-trips ──────────────────────────────────
|
|
|
|
describe('property: every indexed uri round-trips (row 53)', () => {
|
|
test('readResource succeeds and returns the same uri for every indexed resource', () => {
|
|
const catalog = buildFixtureCatalog();
|
|
const uris = [...catalog.resources.keys()];
|
|
assert.ok(uris.length > 0, 'fixture catalog must actually index resources for this property to mean anything');
|
|
|
|
fc.assert(
|
|
fc.property(fc.constantFrom(...uris), (uri) => {
|
|
const result = readResource(catalog, uri);
|
|
assert.equal(result.uri, uri);
|
|
assert.equal(typeof result.text, 'string');
|
|
})
|
|
);
|
|
});
|
|
|
|
test('getPrompt succeeds and returns the same name for every indexed prompt', () => {
|
|
const catalog = buildFixtureCatalog();
|
|
const names = [...catalog.prompts.keys()];
|
|
assert.ok(names.length > 0, 'fixture catalog must actually index prompts for this property to mean anything');
|
|
|
|
fc.assert(
|
|
fc.property(fc.constantFrom(...names), (name) => {
|
|
const result = getPrompt(catalog, name);
|
|
assert.equal(typeof result.description, 'string');
|
|
assert.ok(Array.isArray(result.messages) && result.messages.length >= 1);
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── row 54: no generated traversal string ever resolves ───────────────────
|
|
|
|
describe('property: generated traversal strings never resolve (row 54)', () => {
|
|
test('readResource always refuses arbitrary traversal-shaped uris', () => {
|
|
const catalog = buildFixtureCatalog();
|
|
|
|
const traversalSegment = fc.constantFrom('..', '..%2f', '%2e%2e', '..\\', '%252e%252e%252f');
|
|
const rootSegment = fc.constantFrom('workflows', 'references');
|
|
const suffix = fc.stringMatching(/^[a-z0-9-]{0,12}$/);
|
|
|
|
const traversalUri = fc
|
|
.tuple(rootSegment, fc.array(traversalSegment, { minLength: 1, maxLength: 6 }), suffix)
|
|
.map(([root, segments, tail]) => `msd://${root}/${segments.join('/')}${tail}.md`);
|
|
|
|
fc.assert(
|
|
fc.property(traversalUri, (uri) => {
|
|
assert.throws(
|
|
() => readResource(catalog, uri),
|
|
undefined,
|
|
`a traversal-shaped uri must never resolve to a successful read: ${uri}`
|
|
);
|
|
})
|
|
);
|
|
});
|
|
|
|
test('readResource always refuses arbitrary absolute-path-shaped uris', () => {
|
|
const catalog = buildFixtureCatalog();
|
|
|
|
const absolutePath = fc.oneof(
|
|
fc.stringMatching(/^\/[a-z0-9/_-]{1,40}$/),
|
|
fc.stringMatching(/^[A-Z]:\\[A-Za-z0-9\\_-]{1,40}$/)
|
|
);
|
|
|
|
fc.assert(
|
|
fc.property(absolutePath, (p) => {
|
|
assert.throws(() => readResource(catalog, p), undefined, `an absolute-path-shaped uri must never resolve to a successful read: ${p}`);
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── row 55: pagination is a partition ──────────────────────────────────────
|
|
|
|
describe('property: pagination partitions the list for any page size (row 55)', () => {
|
|
test('pages concatenate to the full list with no dupes and no gaps, for any page size >= 1', () => {
|
|
const catalog = buildFixtureCatalog();
|
|
const full = listResources(catalog, { pageSize: 10_000 }).resources.map((r) => r.uri);
|
|
assert.ok(full.length > 0);
|
|
|
|
fc.assert(
|
|
fc.property(fc.integer({ min: 1, max: full.length + 3 }), (pageSize) => {
|
|
const collected = [];
|
|
let cursor;
|
|
for (let guard = 0; guard < full.length + 5; guard++) {
|
|
const page = listResources(catalog, { pageSize, cursor });
|
|
assert.ok(page.resources.length <= pageSize, `a page must never exceed the requested pageSize ${pageSize}`);
|
|
collected.push(...page.resources.map((r) => r.uri));
|
|
if (page.nextCursor === undefined) break;
|
|
cursor = page.nextCursor;
|
|
}
|
|
assert.deepEqual(collected, full, `pages for pageSize=${pageSize} must reassemble the full list with no dupes/gaps`);
|
|
})
|
|
);
|
|
});
|
|
});
|