mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
Merge branch 'testing'
This commit is contained in:
1230
package-lock.json
generated
1230
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"dev:server": "node server/index.js",
|
||||
"dev": "npm run build && npm run dev:server"
|
||||
"dev": "npm run build && npm run dev:server",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
@@ -22,6 +24,7 @@
|
||||
"esbuild-svelte": "^0.9.4",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"svelte": "^4.2.20"
|
||||
"svelte": "^4.2.20",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,3 +653,5 @@ router.get("/download-zip", async (req, res) => {
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.resolveVaultPath = resolveVaultPath;
|
||||
module.exports.encodeContentDispositionFilename = encodeContentDispositionFilename;
|
||||
|
||||
109
server/routes/fs.test.mjs
Normal file
109
server/routes/fs.test.mjs
Normal file
@@ -0,0 +1,109 @@
|
||||
import path from "path";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
resolveVaultPath,
|
||||
encodeContentDispositionFilename,
|
||||
} = require("./fs.js");
|
||||
|
||||
// -- encodeContentDispositionFilename --------------------------------
|
||||
|
||||
describe("encodeContentDispositionFilename", () => {
|
||||
it("handles a plain ASCII filename", () => {
|
||||
expect(encodeContentDispositionFilename("report.pdf")).toBe(
|
||||
'attachment; filename="report.pdf"',
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves spaces in quotes", () => {
|
||||
expect(encodeContentDispositionFilename("my report.pdf")).toBe(
|
||||
'attachment; filename="my report.pdf"',
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes double quotes", () => {
|
||||
const result = encodeContentDispositionFilename('file"name.txt');
|
||||
expect(result).toBe('attachment; filename="file\\"name.txt"');
|
||||
});
|
||||
|
||||
it("escapes backslashes", () => {
|
||||
const result = encodeContentDispositionFilename("path\\to\\file.txt");
|
||||
expect(result).toBe('attachment; filename="path\\\\to\\\\file.txt"');
|
||||
});
|
||||
|
||||
it("produces ASCII fallback and filename* for unicode", () => {
|
||||
const result = encodeContentDispositionFilename(
|
||||
"\u65E5\u672C\u8A9Enotes.md",
|
||||
);
|
||||
expect(result).toContain('filename="___notes.md"');
|
||||
expect(result).toContain("filename*=UTF-8''");
|
||||
expect(result).toContain("%E6%97%A5");
|
||||
});
|
||||
|
||||
it("replaces only non-ASCII in the fallback for mixed filenames", () => {
|
||||
const result = encodeContentDispositionFilename("report_2024\u5E74.pdf");
|
||||
expect(result).toContain('filename="report_2024_.pdf"');
|
||||
expect(result).toContain("filename*=UTF-8''");
|
||||
});
|
||||
|
||||
it("strips control characters", () => {
|
||||
const result = encodeContentDispositionFilename("bad\x00file\x1F.txt");
|
||||
expect(result).toBe('attachment; filename="badfile.txt"');
|
||||
});
|
||||
|
||||
it("does not crash on empty string", () => {
|
||||
const result = encodeContentDispositionFilename("");
|
||||
expect(result).toBe('attachment; filename=""');
|
||||
});
|
||||
});
|
||||
|
||||
// -- resolveVaultPath ------------------------------------------------
|
||||
|
||||
describe("resolveVaultPath", () => {
|
||||
const root = "/vaults/test";
|
||||
|
||||
it("resolves a simple relative path", () => {
|
||||
const result = resolveVaultPath(root, "notes/daily.md");
|
||||
expect(result).toBe(path.resolve(root, "notes/daily.md"));
|
||||
});
|
||||
|
||||
it("resolves empty string to vault root", () => {
|
||||
expect(resolveVaultPath(root, "")).toBe(path.resolve(root));
|
||||
});
|
||||
|
||||
it("allows a path that equals the vault root exactly", () => {
|
||||
expect(resolveVaultPath(root, "")).toBe(path.resolve(root));
|
||||
});
|
||||
|
||||
it("treats null input as vault root", () => {
|
||||
expect(resolveVaultPath(root, null)).toBe(path.resolve(root));
|
||||
});
|
||||
|
||||
it("treats undefined input as vault root", () => {
|
||||
expect(resolveVaultPath(root, undefined)).toBe(path.resolve(root));
|
||||
});
|
||||
|
||||
it("strips leading slashes", () => {
|
||||
const result = resolveVaultPath(root, "///notes/daily.md");
|
||||
expect(result).toBe(path.resolve(root, "notes/daily.md"));
|
||||
});
|
||||
|
||||
it("resolves ./ segments correctly", () => {
|
||||
const result = resolveVaultPath(root, "./notes/../notes/daily.md");
|
||||
expect(result).toBe(path.resolve(root, "notes/daily.md"));
|
||||
});
|
||||
|
||||
it("rejects ../ that escapes vault root", () => {
|
||||
expect(resolveVaultPath(root, "../")).toBe(null);
|
||||
});
|
||||
|
||||
it("rejects deep traversal", () => {
|
||||
expect(resolveVaultPath(root, "a/b/c/../../../../etc/passwd")).toBe(null);
|
||||
});
|
||||
|
||||
it("rejects traversal to a sibling vault with a shared prefix", () => {
|
||||
expect(resolveVaultPath(root, "../testing/foo")).toBe(null);
|
||||
});
|
||||
});
|
||||
92
src/shims/fs/content-cache.test.js
Normal file
92
src/shims/fs/content-cache.test.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ContentCache } from "./content-cache.js";
|
||||
|
||||
// -- Size accounting ---------------------------------------------------
|
||||
|
||||
describe("ContentCache size accounting", () => {
|
||||
it("set increases currentBytes by data length", () => {
|
||||
const cache = new ContentCache(1024);
|
||||
cache.set("a.md", "hello"); // 5 bytes
|
||||
expect(cache.currentBytes).toBe(5);
|
||||
});
|
||||
|
||||
it("delete returns currentBytes to 0", () => {
|
||||
const cache = new ContentCache(1024);
|
||||
cache.set("a.md", "hello");
|
||||
cache.delete("a.md");
|
||||
expect(cache.currentBytes).toBe(0);
|
||||
});
|
||||
|
||||
it("replacing an entry reflects the new size, not old + new", () => {
|
||||
const cache = new ContentCache(1024);
|
||||
cache.set("a.md", "short");
|
||||
cache.set("a.md", "a much longer string");
|
||||
expect(cache.currentBytes).toBe("a much longer string".length);
|
||||
});
|
||||
|
||||
it("deleting one of several entries leaves the sum of the rest", () => {
|
||||
const cache = new ContentCache(1024);
|
||||
cache.set("a.md", "aaa"); // 3
|
||||
cache.set("b.md", "bbbbb"); // 5
|
||||
cache.set("c.md", "cc"); // 2
|
||||
cache.delete("b.md");
|
||||
expect(cache.currentBytes).toBe(5); // 3 + 2
|
||||
});
|
||||
});
|
||||
|
||||
// -- LRU eviction ------------------------------------------------------
|
||||
|
||||
describe("ContentCache LRU eviction", () => {
|
||||
it("evicts the least-recently-accessed entry when full", () => {
|
||||
let now = 1000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now++);
|
||||
|
||||
const cache = new ContentCache(10);
|
||||
cache.set("a.md", "aaaa"); // 4
|
||||
cache.set("b.md", "bbbb"); // 4
|
||||
// At 8/10. Adding 4 more would exceed, so LRU (a.md) should be evicted.
|
||||
cache.set("c.md", "cccc"); // 4
|
||||
expect(cache.has("a.md")).toBe(false);
|
||||
expect(cache.has("b.md")).toBe(true);
|
||||
expect(cache.has("c.md")).toBe(true);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("accessing an entry refreshes it so it survives eviction", () => {
|
||||
let now = 1000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now++);
|
||||
|
||||
const cache = new ContentCache(10);
|
||||
cache.set("a.md", "aaaa"); // 4, accessedAt=1000
|
||||
cache.set("b.md", "bbbb"); // 4, accessedAt=1001
|
||||
// Touch a.md so b.md becomes the LRU
|
||||
cache.get("a.md"); // a.md accessedAt=1002
|
||||
cache.set("c.md", "cccc"); // 4 -- should evict b.md (1001), not a.md (1002)
|
||||
expect(cache.has("a.md")).toBe(true);
|
||||
expect(cache.has("b.md")).toBe(false);
|
||||
expect(cache.has("c.md")).toBe(true);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("entry larger than maxSize still gets stored", () => {
|
||||
const cache = new ContentCache(5);
|
||||
cache.set("small.md", "ab"); // 2
|
||||
cache.set("big.md", "abcdefghij"); // 10 -- larger than maxSize
|
||||
expect(cache.has("small.md")).toBe(false);
|
||||
expect(cache.has("big.md")).toBe(true);
|
||||
expect(cache.currentBytes).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
// -- Path normalization ------------------------------------------------
|
||||
|
||||
describe("ContentCache path normalization", () => {
|
||||
it("backslash and slash variants hit the same cache entry", () => {
|
||||
const cache = new ContentCache(1024);
|
||||
cache.set("foo\\bar\\baz.md", "content");
|
||||
expect(cache.has("foo/bar/baz.md")).toBe(true);
|
||||
expect(cache.get("foo/bar/baz.md")).toBe("content");
|
||||
});
|
||||
});
|
||||
275
src/shims/fs/fd.test.js
Normal file
275
src/shims/fs/fd.test.js
Normal file
@@ -0,0 +1,275 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createFdOps } from "./fd.js";
|
||||
|
||||
function makeStubs(files = {}) {
|
||||
const meta = {
|
||||
has(p) {
|
||||
return p in files;
|
||||
},
|
||||
toStat(p) {
|
||||
if (!(p in files)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
size: files[p].length,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const content = {
|
||||
_store: {},
|
||||
get(p) {
|
||||
return this._store[p] ?? null;
|
||||
},
|
||||
set(p, data) {
|
||||
this._store[p] = data;
|
||||
},
|
||||
};
|
||||
|
||||
const transport = {
|
||||
readFileSync(p) {
|
||||
return files[p] ?? null;
|
||||
},
|
||||
};
|
||||
|
||||
// Pre-populate content cache so ensureData doesn't hit transport
|
||||
for (const [p, data] of Object.entries(files)) {
|
||||
content.set(p, data);
|
||||
}
|
||||
|
||||
return { meta, content, transport };
|
||||
}
|
||||
|
||||
function makeOps(files = {}) {
|
||||
const { meta, content, transport } = makeStubs(files);
|
||||
return createFdOps(meta, content, transport);
|
||||
}
|
||||
|
||||
// -- openSync / closeSync lifecycle ------------------------------------
|
||||
|
||||
describe("fd openSync / closeSync lifecycle", () => {
|
||||
it("open returns an integer fd", () => {
|
||||
const ops = makeOps({ "a.md": new Uint8Array([1, 2, 3]) });
|
||||
const fd = ops.openSync("a.md", "r");
|
||||
expect(typeof fd).toBe("number");
|
||||
expect(Number.isInteger(fd)).toBe(true);
|
||||
});
|
||||
|
||||
it("multiple opens return distinct fds", () => {
|
||||
const ops = makeOps({ "a.md": new Uint8Array([1]) });
|
||||
const fd1 = ops.openSync("a.md", "r");
|
||||
const fd2 = ops.openSync("a.md", "r");
|
||||
expect(fd1).not.toBe(fd2);
|
||||
});
|
||||
|
||||
it("close removes the fd", () => {
|
||||
const ops = makeOps({ "a.md": new Uint8Array([1]) });
|
||||
const fd = ops.openSync("a.md", "r");
|
||||
ops.closeSync(fd);
|
||||
expect(() => ops.readSync(fd, new Uint8Array(1), 0, 1, 0)).toThrow(
|
||||
"EBADF",
|
||||
);
|
||||
});
|
||||
|
||||
it("open throws ENOENT for missing path", () => {
|
||||
const ops = makeOps({});
|
||||
expect(() => ops.openSync("nope.md", "r")).toThrow("ENOENT");
|
||||
});
|
||||
|
||||
it("accessing a closed fd throws EBADF", () => {
|
||||
const ops = makeOps({ "a.md": new Uint8Array([1]) });
|
||||
const fd = ops.openSync("a.md", "r");
|
||||
ops.closeSync(fd);
|
||||
expect(() => ops.fstatSync(fd)).toThrow("EBADF");
|
||||
});
|
||||
});
|
||||
|
||||
// -- readSync ----------------------------------------------------------
|
||||
|
||||
describe("fd readSync", () => {
|
||||
const data = new Uint8Array([10, 20, 30, 40, 50]);
|
||||
|
||||
function openData() {
|
||||
const ops = makeOps({ "f.bin": data });
|
||||
const fd = ops.openSync("f.bin", "r");
|
||||
return { ops, fd };
|
||||
}
|
||||
|
||||
it("read from position 0, full length", () => {
|
||||
const { ops, fd } = openData();
|
||||
const buf = new Uint8Array(5);
|
||||
const n = ops.readSync(fd, buf, 0, 5, 0);
|
||||
expect(n).toBe(5);
|
||||
expect(buf).toEqual(data);
|
||||
});
|
||||
|
||||
it("read from mid-file position", () => {
|
||||
const { ops, fd } = openData();
|
||||
const buf = new Uint8Array(3);
|
||||
const n = ops.readSync(fd, buf, 0, 3, 2);
|
||||
expect(n).toBe(3);
|
||||
expect(buf).toEqual(new Uint8Array([30, 40, 50]));
|
||||
});
|
||||
|
||||
it("read with length exceeding remaining data", () => {
|
||||
const { ops, fd } = openData();
|
||||
const buf = new Uint8Array(10);
|
||||
const n = ops.readSync(fd, buf, 0, 10, 3);
|
||||
expect(n).toBe(2);
|
||||
expect(buf[0]).toBe(40);
|
||||
expect(buf[1]).toBe(50);
|
||||
});
|
||||
|
||||
it("read at position === data.length returns 0 bytes", () => {
|
||||
const { ops, fd } = openData();
|
||||
const buf = new Uint8Array(5);
|
||||
const n = ops.readSync(fd, buf, 0, 5, 5);
|
||||
expect(n).toBe(0);
|
||||
});
|
||||
|
||||
it("read at position > data.length returns 0 bytes", () => {
|
||||
const { ops, fd } = openData();
|
||||
const buf = new Uint8Array(5);
|
||||
const n = ops.readSync(fd, buf, 0, 5, 100);
|
||||
expect(n).toBe(0);
|
||||
});
|
||||
|
||||
it("read with offset > 0 places data at correct position", () => {
|
||||
const { ops, fd } = openData();
|
||||
const buf = new Uint8Array(6);
|
||||
buf.fill(0);
|
||||
const n = ops.readSync(fd, buf, 3, 2, 0);
|
||||
expect(n).toBe(2);
|
||||
expect(buf).toEqual(new Uint8Array([0, 0, 0, 10, 20, 0]));
|
||||
});
|
||||
|
||||
it("target buffer is actually modified", () => {
|
||||
const { ops, fd } = openData();
|
||||
const buf = new Uint8Array(3);
|
||||
buf.fill(255);
|
||||
ops.readSync(fd, buf, 0, 2, 0);
|
||||
expect(buf[0]).toBe(10);
|
||||
expect(buf[1]).toBe(20);
|
||||
expect(buf[2]).toBe(255); // untouched
|
||||
});
|
||||
});
|
||||
|
||||
// -- fstatSync ---------------------------------------------------------
|
||||
|
||||
describe("fd fstatSync", () => {
|
||||
it("returns metadata from cache when available", () => {
|
||||
const ops = makeOps({ "a.md": new Uint8Array([1, 2, 3]) });
|
||||
const fd = ops.openSync("a.md", "r");
|
||||
const stat = ops.fstatSync(fd);
|
||||
expect(stat.size).toBe(3);
|
||||
expect(stat.isFile()).toBe(true);
|
||||
expect(stat.isDirectory()).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to buffer length when metadata is missing", () => {
|
||||
const { meta, content, transport } = makeStubs({
|
||||
"a.md": new Uint8Array([1, 2, 3, 4]),
|
||||
});
|
||||
|
||||
// Override toStat to return null, simulating missing metadata
|
||||
meta.toStat = () => null;
|
||||
|
||||
const ops = createFdOps(meta, content, transport);
|
||||
const fd = ops.openSync("a.md", "r");
|
||||
const stat = ops.fstatSync(fd);
|
||||
expect(stat.size).toBe(4);
|
||||
expect(stat.isFile()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// -- Async wrappers ----------------------------------------------------
|
||||
|
||||
describe("fd async wrappers", () => {
|
||||
it("open() calls callback asynchronously", async () => {
|
||||
const ops = makeOps({ "a.md": new Uint8Array([1]) });
|
||||
let called = false;
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
ops.open("a.md", "r", (err, fd) => {
|
||||
called = true;
|
||||
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(fd);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Callback should not have fired synchronously
|
||||
expect(called).toBe(false);
|
||||
const fd = await promise;
|
||||
expect(typeof fd).toBe("number");
|
||||
});
|
||||
|
||||
it("open() error path calls cb(err)", async () => {
|
||||
const ops = makeOps({});
|
||||
|
||||
const err = await new Promise((resolve) => {
|
||||
ops.open("nope.md", "r", (e) => resolve(e));
|
||||
});
|
||||
|
||||
expect(err).toBeTruthy();
|
||||
expect(err.code).toBe("ENOENT");
|
||||
});
|
||||
|
||||
it("read() delivers results via callback", async () => {
|
||||
const ops = makeOps({ "a.md": new Uint8Array([10, 20]) });
|
||||
|
||||
const fd = await new Promise((resolve, reject) => {
|
||||
ops.open("a.md", "r", (err, fd) => (err ? reject(err) : resolve(fd)));
|
||||
});
|
||||
|
||||
const buf = new Uint8Array(2);
|
||||
|
||||
const bytesRead = await new Promise((resolve, reject) => {
|
||||
ops.read(fd, buf, 0, 2, 0, (err, n) =>
|
||||
err ? reject(err) : resolve(n),
|
||||
);
|
||||
});
|
||||
|
||||
expect(bytesRead).toBe(2);
|
||||
expect(buf).toEqual(new Uint8Array([10, 20]));
|
||||
});
|
||||
|
||||
it("close() calls callback asynchronously", async () => {
|
||||
const ops = makeOps({ "a.md": new Uint8Array([1]) });
|
||||
const fd = ops.openSync("a.md", "r");
|
||||
|
||||
const result = await new Promise((resolve) => {
|
||||
ops.close(fd, (err) => resolve(err));
|
||||
});
|
||||
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it("fstat() calls callback asynchronously", async () => {
|
||||
const ops = makeOps({ "a.md": new Uint8Array([1, 2, 3]) });
|
||||
const fd = ops.openSync("a.md", "r");
|
||||
|
||||
const stat = await new Promise((resolve, reject) => {
|
||||
ops.fstat(fd, (err, s) => (err ? reject(err) : resolve(s)));
|
||||
});
|
||||
|
||||
expect(stat.size).toBe(3);
|
||||
});
|
||||
|
||||
it("fstat() error path calls cb(err) for bad fd", async () => {
|
||||
const ops = makeOps({});
|
||||
|
||||
const err = await new Promise((resolve) => {
|
||||
ops.fstat(99999, (e) => resolve(e));
|
||||
});
|
||||
|
||||
expect(err).toBeTruthy();
|
||||
expect(err.code).toBe("EBADF");
|
||||
});
|
||||
});
|
||||
213
src/shims/fs/metadata-cache.test.js
Normal file
213
src/shims/fs/metadata-cache.test.js
Normal file
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { MetadataCache } from "./metadata-cache.js";
|
||||
|
||||
// -- Path normalization ----------------------------------------------
|
||||
|
||||
describe("MetadataCache path normalization", () => {
|
||||
it("converts backslashes to forward slashes", () => {
|
||||
const cache = new MetadataCache();
|
||||
cache.set("foo\\bar\\baz.md", { type: "file", size: 10 });
|
||||
expect(cache.has("foo/bar/baz.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("strips leading and trailing slashes", () => {
|
||||
const cache = new MetadataCache();
|
||||
cache.set("/foo/bar/", { type: "file", size: 10 });
|
||||
expect(cache.has("foo/bar")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles null and undefined as empty string", () => {
|
||||
const cache = new MetadataCache();
|
||||
cache.set(null, { type: "directory", size: 0 });
|
||||
expect(cache.has("")).toBe(true);
|
||||
expect(cache.has(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes //foo\\\\bar// to foo/bar", () => {
|
||||
const cache = new MetadataCache();
|
||||
cache.set("//foo\\bar//", { type: "file", size: 5 });
|
||||
expect(cache.has("foo/bar")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// -- Operations ------------------------------------------------------
|
||||
|
||||
describe("MetadataCache populate and merge", () => {
|
||||
it("populate() clears existing entries", () => {
|
||||
const cache = new MetadataCache();
|
||||
cache.set("old.md", { type: "file", size: 1 });
|
||||
cache.populate({ "new.md": { type: "file", size: 2 } });
|
||||
expect(cache.has("old.md")).toBe(false);
|
||||
expect(cache.has("new.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("merge() preserves existing entries", () => {
|
||||
const cache = new MetadataCache();
|
||||
cache.set("existing.md", { type: "file", size: 1 });
|
||||
cache.merge({ "added.md": { type: "file", size: 2 } });
|
||||
expect(cache.has("existing.md")).toBe(true);
|
||||
expect(cache.has("added.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("populate then merge -- pre-existing entries survive merge", () => {
|
||||
const cache = new MetadataCache();
|
||||
cache.populate({
|
||||
"a.md": { type: "file", size: 1 },
|
||||
"b.md": { type: "file", size: 2 },
|
||||
});
|
||||
cache.merge({ "c.md": { type: "file", size: 3 } });
|
||||
expect(cache.has("a.md")).toBe(true);
|
||||
expect(cache.has("b.md")).toBe(true);
|
||||
expect(cache.has("c.md")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MetadataCache toStat", () => {
|
||||
it("returns correct shape with all expected fields and methods", () => {
|
||||
const cache = new MetadataCache();
|
||||
cache.set("file.md", { type: "file", size: 42, mtime: 1000, ctime: 2000 });
|
||||
const stat = cache.toStat("file.md");
|
||||
|
||||
expect(stat.size).toBe(42);
|
||||
expect(stat.mtimeMs).toBe(1000);
|
||||
expect(stat.ctimeMs).toBe(2000);
|
||||
expect(stat.atimeMs).toBe(1000);
|
||||
expect(stat.birthtimeMs).toBe(2000);
|
||||
expect(stat.mtime).toEqual(new Date(1000));
|
||||
expect(stat.ctime).toEqual(new Date(2000));
|
||||
expect(stat.atime).toEqual(new Date(1000));
|
||||
expect(stat.birthtime).toEqual(new Date(2000));
|
||||
expect(stat.isFile()).toBe(true);
|
||||
expect(stat.isDirectory()).toBe(false);
|
||||
expect(stat.isSymbolicLink()).toBe(false);
|
||||
expect(stat.isBlockDevice()).toBe(false);
|
||||
expect(stat.isCharacterDevice()).toBe(false);
|
||||
expect(stat.isFIFO()).toBe(false);
|
||||
expect(stat.isSocket()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns null for missing paths", () => {
|
||||
const cache = new MetadataCache();
|
||||
expect(cache.toStat("nonexistent.md")).toBe(null);
|
||||
});
|
||||
|
||||
it("constructs dates from zero when mtime/ctime are missing", () => {
|
||||
const cache = new MetadataCache();
|
||||
cache.set("bare.md", { type: "file", size: 1 });
|
||||
const stat = cache.toStat("bare.md");
|
||||
|
||||
expect(stat.mtimeMs).toBe(0);
|
||||
expect(stat.ctimeMs).toBe(0);
|
||||
expect(stat.mtime).toEqual(new Date(0));
|
||||
expect(stat.ctime).toEqual(new Date(0));
|
||||
});
|
||||
});
|
||||
|
||||
describe("MetadataCache readdir", () => {
|
||||
function populated() {
|
||||
const cache = new MetadataCache();
|
||||
cache.populate({
|
||||
"foo/bar.md": { type: "file", size: 1 },
|
||||
"foo/baz.md": { type: "file", size: 2 },
|
||||
"foo/sub/deep.md": { type: "file", size: 3 },
|
||||
"foobar/other.md": { type: "file", size: 4 },
|
||||
"root.md": { type: "file", size: 5 },
|
||||
"docs": { type: "directory", size: 0 },
|
||||
});
|
||||
return cache;
|
||||
}
|
||||
|
||||
it("root readdir returns top-level entries", () => {
|
||||
const cache = populated();
|
||||
const entries = cache.readdir("");
|
||||
const names = entries.map((e) => e.name).sort();
|
||||
expect(names).toEqual(["docs", "foo", "foobar", "root.md"]);
|
||||
});
|
||||
|
||||
it("nested dir returns only direct children, not grandchildren", () => {
|
||||
const cache = populated();
|
||||
const entries = cache.readdir("foo");
|
||||
const names = entries.map((e) => e.name).sort();
|
||||
expect(names).toEqual(["bar.md", "baz.md", "sub"]);
|
||||
expect(names).not.toContain("deep.md");
|
||||
});
|
||||
|
||||
it("readdir of foo does not include foobar entries (prefix false-match)", () => {
|
||||
const cache = populated();
|
||||
const entries = cache.readdir("foo");
|
||||
const names = entries.map((e) => e.name);
|
||||
expect(names).not.toContain("foobar");
|
||||
expect(names).not.toContain("other.md");
|
||||
});
|
||||
|
||||
it("infers directory type for paths with no direct map entry", () => {
|
||||
const cache = populated();
|
||||
const entries = cache.readdir("foo");
|
||||
const sub = entries.find((e) => e.name === "sub");
|
||||
expect(sub).toBeDefined();
|
||||
expect(sub.type).toBe("directory");
|
||||
});
|
||||
|
||||
it("returns empty array for path with no children", () => {
|
||||
const cache = populated();
|
||||
const entries = cache.readdir("docs");
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for nonexistent path", () => {
|
||||
const cache = populated();
|
||||
const entries = cache.readdir("nope/not/here");
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MetadataCache rename", () => {
|
||||
it("rename file: old path gone, new path present with same metadata", () => {
|
||||
const cache = new MetadataCache();
|
||||
const meta = { type: "file", size: 10, mtime: 100 };
|
||||
cache.set("a.md", meta);
|
||||
cache.rename("a.md", "b.md");
|
||||
|
||||
expect(cache.has("a.md")).toBe(false);
|
||||
expect(cache.has("b.md")).toBe(true);
|
||||
expect(cache.get("b.md")).toBe(meta);
|
||||
});
|
||||
|
||||
it("rename directory moves all children", () => {
|
||||
const cache = new MetadataCache();
|
||||
const dirMeta = { type: "directory", size: 0 };
|
||||
const fileMeta = { type: "file", size: 5 };
|
||||
const deepMeta = { type: "file", size: 8 };
|
||||
cache.set("a", dirMeta);
|
||||
cache.set("a/file.md", fileMeta);
|
||||
cache.set("a/sub/deep.md", deepMeta);
|
||||
cache.rename("a", "b");
|
||||
|
||||
expect(cache.has("a")).toBe(false);
|
||||
expect(cache.has("a/file.md")).toBe(false);
|
||||
expect(cache.has("a/sub/deep.md")).toBe(false);
|
||||
expect(cache.get("b")).toBe(dirMeta);
|
||||
expect(cache.get("b/file.md")).toBe(fileMeta);
|
||||
expect(cache.get("b/sub/deep.md")).toBe(deepMeta);
|
||||
});
|
||||
|
||||
it("rename where old and new share a common prefix", () => {
|
||||
const cache = new MetadataCache();
|
||||
const meta = { type: "file", size: 1 };
|
||||
cache.set("a/b", meta);
|
||||
cache.rename("a/b", "a/c");
|
||||
|
||||
expect(cache.has("a/b")).toBe(false);
|
||||
expect(cache.get("a/c")).toBe(meta);
|
||||
});
|
||||
|
||||
it("rename to a deeper nesting level", () => {
|
||||
const cache = new MetadataCache();
|
||||
const meta = { type: "file", size: 1 };
|
||||
cache.set("x", meta);
|
||||
cache.rename("x", "y/z");
|
||||
|
||||
expect(cache.has("x")).toBe(false);
|
||||
expect(cache.get("y/z")).toBe(meta);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user