The qmd bin was a custom bash script that discovered node via hardcoded fallback paths (mise, asdf, nvm, homebrew). This was nonstandard and caused ABI mismatches when installed via bun (native modules compiled for bun but executed with node). Now uses the standard npm bin convention: dist/qmd.js with a node shebang, added by the build script. The isMain guard resolves symlinks so it works when npm/bun create symlinked bin entries. Also converts all dynamic require() calls in tests to ESM imports, and adds container-based smoke tests (test/smoke-install.sh) that verify install + run under both node and bun via mise in a Debian container.
144 lines
4.8 KiB
TypeScript
144 lines
4.8 KiB
TypeScript
/**
|
|
* Unit tests for multi-collection filter logic (PR #191).
|
|
*
|
|
* Tests the filterByCollections post-filter and the resolveCollectionFilter
|
|
* behavior for single-collection vs multi-collection search.
|
|
*/
|
|
|
|
import { describe, test, expect } from "vitest";
|
|
import { parseArgs } from "node:util";
|
|
|
|
// Reproduce the filterByCollections logic from qmd.ts for testing
|
|
// (the function is private in qmd.ts)
|
|
function filterByCollections<T extends { filepath?: string; file?: string }>(
|
|
results: T[],
|
|
collectionNames: string[],
|
|
): T[] {
|
|
if (collectionNames.length <= 1) return results;
|
|
const prefixes = collectionNames.map((n) => `qmd://${n}/`);
|
|
return results.filter((r) => {
|
|
const path = r.filepath || r.file || "";
|
|
return prefixes.some((p) => path.startsWith(p));
|
|
});
|
|
}
|
|
|
|
describe("filterByCollections", () => {
|
|
const results = [
|
|
{ filepath: "qmd://docs/readme.md", file: "qmd://docs/readme.md" },
|
|
{ filepath: "qmd://notes/todo.md", file: "qmd://notes/todo.md" },
|
|
{ filepath: "qmd://journals/2024/jan.md", file: "qmd://journals/2024/jan.md" },
|
|
{ filepath: "qmd://docs/api.md", file: "qmd://docs/api.md" },
|
|
];
|
|
|
|
test("returns all results when no collections specified", () => {
|
|
expect(filterByCollections(results, [])).toEqual(results);
|
|
});
|
|
|
|
test("returns all results for single collection (no-op, handled by SQL filter)", () => {
|
|
expect(filterByCollections(results, ["docs"])).toEqual(results);
|
|
});
|
|
|
|
test("filters to matching collections when multiple specified", () => {
|
|
const filtered = filterByCollections(results, ["docs", "journals"]);
|
|
expect(filtered).toHaveLength(3);
|
|
expect(filtered.map((r) => r.filepath)).toEqual([
|
|
"qmd://docs/readme.md",
|
|
"qmd://journals/2024/jan.md",
|
|
"qmd://docs/api.md",
|
|
]);
|
|
});
|
|
|
|
test("filters correctly with two collections", () => {
|
|
const filtered = filterByCollections(results, ["notes", "journals"]);
|
|
expect(filtered).toHaveLength(2);
|
|
expect(filtered.map((r) => r.filepath)).toEqual([
|
|
"qmd://notes/todo.md",
|
|
"qmd://journals/2024/jan.md",
|
|
]);
|
|
});
|
|
|
|
test("returns empty when no results match collections", () => {
|
|
const filtered = filterByCollections(results, ["archive", "trash"]);
|
|
expect(filtered).toHaveLength(0);
|
|
});
|
|
|
|
test("uses file field when filepath is missing", () => {
|
|
const fileOnlyResults = [
|
|
{ file: "qmd://docs/readme.md" },
|
|
{ file: "qmd://notes/todo.md" },
|
|
];
|
|
const filtered = filterByCollections(fileOnlyResults, ["docs", "notes"]);
|
|
expect(filtered).toHaveLength(2);
|
|
});
|
|
|
|
test("uses filepath over file when both present", () => {
|
|
const mixedResults = [
|
|
{ filepath: "qmd://docs/readme.md", file: "qmd://notes/todo.md" },
|
|
];
|
|
const filtered = filterByCollections(mixedResults, ["docs", "notes"]);
|
|
expect(filtered).toHaveLength(1);
|
|
// Should match via filepath (docs), not file (notes)
|
|
expect(filtered[0].filepath).toBe("qmd://docs/readme.md");
|
|
});
|
|
});
|
|
|
|
describe("resolveCollectionFilter input normalization", () => {
|
|
// Test the array normalization logic without the DB dependency
|
|
function normalizeCollectionInput(raw: string | string[] | undefined): string[] {
|
|
if (!raw) return [];
|
|
return Array.isArray(raw) ? raw : [raw];
|
|
}
|
|
|
|
test("undefined returns empty array", () => {
|
|
expect(normalizeCollectionInput(undefined)).toEqual([]);
|
|
});
|
|
|
|
test("single string returns single-element array", () => {
|
|
expect(normalizeCollectionInput("docs")).toEqual(["docs"]);
|
|
});
|
|
|
|
test("array passes through", () => {
|
|
expect(normalizeCollectionInput(["docs", "notes"])).toEqual(["docs", "notes"]);
|
|
});
|
|
|
|
test("empty string returns single-element array", () => {
|
|
expect(normalizeCollectionInput("")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("collection option type from parseArgs", () => {
|
|
// Verify that parseArgs with `multiple: true` produces string[]
|
|
test("parseArgs multiple:true produces array for repeated flags", () => {
|
|
const { values } = parseArgs({
|
|
args: ["-c", "docs", "-c", "notes"],
|
|
options: {
|
|
collection: { type: "string", short: "c", multiple: true },
|
|
},
|
|
strict: true,
|
|
});
|
|
expect(values.collection).toEqual(["docs", "notes"]);
|
|
});
|
|
|
|
test("parseArgs multiple:true produces array for single flag", () => {
|
|
const { values } = parseArgs({
|
|
args: ["-c", "docs"],
|
|
options: {
|
|
collection: { type: "string", short: "c", multiple: true },
|
|
},
|
|
strict: true,
|
|
});
|
|
expect(values.collection).toEqual(["docs"]);
|
|
});
|
|
|
|
test("parseArgs multiple:true produces undefined when flag absent", () => {
|
|
const { values } = parseArgs({
|
|
args: [],
|
|
options: {
|
|
collection: { type: "string", short: "c", multiple: true },
|
|
},
|
|
strict: true,
|
|
});
|
|
expect(values.collection).toBeUndefined();
|
|
});
|
|
});
|