940 lines
35 KiB
TypeScript
940 lines
35 KiB
TypeScript
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
|
|
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
|
|
import {
|
|
afterEach,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
} from "@suaveplan/testing/runner";
|
|
import {
|
|
getMultiFileFsBackendMetricsState,
|
|
resetMultiFileFsBackendMetricsCache,
|
|
resetMultiFileFsBackendMetricsState,
|
|
} from "../observability/observability.js";
|
|
import { MultiFileFsBackend } from "./backend.js";
|
|
|
|
let testRoot: string;
|
|
|
|
beforeEach(async () => {
|
|
testRoot = await mkdtemp(join(tmpdir(), "i18n-mfs-"));
|
|
resetMultiFileFsBackendMetricsCache();
|
|
resetMultiFileFsBackendMetricsState();
|
|
});
|
|
afterEach(async () => {
|
|
if (testRoot) await rm(testRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Universal Backend conformance suite (real tmpdir; nested layout)
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
runBackendConformance("MultiFileFsBackend", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "i18n-mfs-conf-"));
|
|
const backend = new MultiFileFsBackend({ root });
|
|
return {
|
|
backend,
|
|
async seed(locale, namespace, record) {
|
|
await backend.save(locale, namespace, record as never);
|
|
},
|
|
};
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// MultiFileFsBackend specifics — nested layout
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe("MultiFileFsBackend nested layout", () => {
|
|
it("loads <root>/<locale>/<namespace>.json", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
await mkdir(join(testRoot, "en"), { recursive: true });
|
|
await writeFile(
|
|
join(testRoot, "en", "auth.json"),
|
|
JSON.stringify({ login: { title: "Sign in" } })
|
|
);
|
|
const backend = new MultiFileFsBackend({ root: testRoot });
|
|
const record = await backend.read("en", "auth");
|
|
expect(record).toEqual({ login: { title: "Sign in" } });
|
|
});
|
|
|
|
it("nested layout reads expected path", { timeout: 5000 }, async () => {
|
|
const seen: string[] = [];
|
|
const fs = {
|
|
readFile: async (p: string) => {
|
|
seen.push(p);
|
|
return new TextEncoder().encode(
|
|
JSON.stringify({ hello: "Hi" })
|
|
);
|
|
},
|
|
writeFile: async () => {},
|
|
mkdir: async () => {},
|
|
stat: async () => ({ mtimeMs: 0 }),
|
|
};
|
|
const backend = new MultiFileFsBackend({
|
|
root: "/tmp/i18n",
|
|
layout: "nested",
|
|
fs,
|
|
});
|
|
const record = await backend.read("en", "common");
|
|
expect(record).toEqual({ hello: "Hi" });
|
|
expect(seen[0]).toBe("/tmp/i18n/en/common.json");
|
|
});
|
|
|
|
it("ENOENT maps to BACKEND_LOCALE_NOT_AVAILABLE", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const backend = new MultiFileFsBackend({ root: testRoot });
|
|
try {
|
|
await backend.read("de", "auth");
|
|
expect.fail("expected throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(I18nError);
|
|
expect((err as I18nError).code).toBe(
|
|
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE
|
|
);
|
|
}
|
|
});
|
|
|
|
it("invalid JSON maps to BACKEND_READ_FAILED", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
await mkdir(join(testRoot, "en"), { recursive: true });
|
|
await writeFile(join(testRoot, "en", "auth.json"), "not-json");
|
|
const backend = new MultiFileFsBackend({ root: testRoot });
|
|
try {
|
|
await backend.read("en", "auth");
|
|
expect.fail("expected throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(I18nError);
|
|
expect((err as I18nError).code).toBe(
|
|
I18N_CODES.BACKEND_READ_FAILED
|
|
);
|
|
}
|
|
});
|
|
|
|
it("array JSON rejects as BACKEND_READ_FAILED", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
await mkdir(join(testRoot, "en"), { recursive: true });
|
|
await writeFile(join(testRoot, "en", "auth.json"), "[]");
|
|
const backend = new MultiFileFsBackend({ root: testRoot });
|
|
await expect(backend.read("en", "auth")).rejects.toBeInstanceOf(
|
|
I18nError
|
|
);
|
|
});
|
|
|
|
it("save creates the locale directory and round-trips", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const backend = new MultiFileFsBackend({ root: testRoot });
|
|
await backend.save("fr", "common", { ok: "Ok" });
|
|
const record = await backend.read("fr", "common");
|
|
expect(record).toEqual({ ok: "Ok" });
|
|
});
|
|
|
|
it("save creates parent directory", { timeout: 5000 }, async () => {
|
|
const mkdirCalls: Array<{ path: string; recursive?: boolean }> = [];
|
|
const fs = {
|
|
readFile: async () => new Uint8Array(),
|
|
writeFile: async () => {},
|
|
mkdir: async (p: string, opts?: { recursive?: boolean }) => {
|
|
mkdirCalls.push({ path: p, recursive: opts?.recursive });
|
|
},
|
|
stat: async () => ({ mtimeMs: 0 }),
|
|
};
|
|
const backend = new MultiFileFsBackend({ root: "/tmp/i18n", fs });
|
|
await backend.save("en", "common", { x: "y" });
|
|
expect(mkdirCalls).toEqual([{ path: "/tmp/i18n/en", recursive: true }]);
|
|
});
|
|
|
|
it("writeFile failure throws BACKEND_WRITE_FAILED", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const fs = {
|
|
readFile: async () => new Uint8Array(),
|
|
writeFile: async () => {
|
|
throw new Error("disk full");
|
|
},
|
|
mkdir: async () => {},
|
|
stat: async () => ({ mtimeMs: 0 }),
|
|
};
|
|
const backend = new MultiFileFsBackend({ root: "/tmp/i18n", fs });
|
|
try {
|
|
await backend.save("en", "common", { x: "y" });
|
|
expect.fail("expected throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(I18nError);
|
|
expect((err as I18nError).code).toBe(
|
|
I18N_CODES.BACKEND_WRITE_FAILED
|
|
);
|
|
expect(((err as I18nError).cause as Error).message).toBe(
|
|
"disk full"
|
|
);
|
|
}
|
|
});
|
|
|
|
it("save preserves I18nError thrown by encoder", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const backend = new MultiFileFsBackend({
|
|
root: testRoot,
|
|
extension: "yaml",
|
|
});
|
|
try {
|
|
await backend.save("en", "auth", { ok: "Ok" });
|
|
expect.fail("expected throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(I18nError);
|
|
expect((err as I18nError).code).toBe(
|
|
I18N_CODES.BACKEND_WRITE_FAILED
|
|
);
|
|
}
|
|
});
|
|
|
|
it("lastModified returns mtime-ms after save", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const backend = new MultiFileFsBackend({ root: testRoot });
|
|
await backend.save("en", "auth", { ok: "Ok" });
|
|
const t = await backend.lastModified("en", "auth");
|
|
expect(typeof t).toBe("number");
|
|
expect(t).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("lastModified returns mtime", { timeout: 5000 }, async () => {
|
|
const fs = {
|
|
readFile: async () => new Uint8Array(),
|
|
writeFile: async () => {},
|
|
mkdir: async () => {},
|
|
stat: async () => ({ mtimeMs: 4242 }),
|
|
};
|
|
const backend = new MultiFileFsBackend({ root: "/tmp", fs });
|
|
expect(await backend.lastModified("en", "common")).toBe(4242);
|
|
});
|
|
|
|
it("lastModified swallows stat failure", { timeout: 5000 }, async () => {
|
|
const fs = {
|
|
readFile: async () => new Uint8Array(),
|
|
writeFile: async () => {},
|
|
mkdir: async () => {},
|
|
stat: async () => {
|
|
throw new Error("stat failed");
|
|
},
|
|
};
|
|
const backend = new MultiFileFsBackend({ root: "/tmp", fs });
|
|
expect(await backend.lastModified("en", "auth")).toBeUndefined();
|
|
});
|
|
|
|
it("lastModified returns undefined for missing file", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const backend = new MultiFileFsBackend({ root: testRoot });
|
|
expect(await backend.lastModified("xx", "auth")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// MultiFileFsBackend specifics — flat layout
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe("MultiFileFsBackend flat layout", () => {
|
|
it("loads <root>/<locale>.<namespace>.json", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
await writeFile(
|
|
join(testRoot, "en.auth.json"),
|
|
JSON.stringify({ ok: "Ok" })
|
|
);
|
|
const backend = new MultiFileFsBackend({
|
|
root: testRoot,
|
|
layout: "flat",
|
|
});
|
|
expect(await backend.read("en", "auth")).toEqual({ ok: "Ok" });
|
|
});
|
|
|
|
it("flat layout reads expected path", { timeout: 5000 }, async () => {
|
|
const seen: string[] = [];
|
|
const fs = {
|
|
readFile: async (p: string) => {
|
|
seen.push(p);
|
|
return new TextEncoder().encode(JSON.stringify({ ok: "Ok" }));
|
|
},
|
|
writeFile: async () => {},
|
|
mkdir: async () => {},
|
|
stat: async () => ({ mtimeMs: 0 }),
|
|
};
|
|
const backend = new MultiFileFsBackend({
|
|
root: "/tmp/i18n",
|
|
layout: "flat",
|
|
fs,
|
|
});
|
|
await backend.read("en", "common");
|
|
expect(seen[0]).toBe("/tmp/i18n/en.common.json");
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// MultiFileFsBackend specifics — glob layout
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe("MultiFileFsBackend glob layout", () => {
|
|
it("substitutes {locale} and {namespace} in pattern", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
await mkdir(join(testRoot, "fr"), { recursive: true });
|
|
await writeFile(
|
|
join(testRoot, "fr", "auth.fr.json"),
|
|
JSON.stringify({ ok: "Ok-fr" })
|
|
);
|
|
const backend = new MultiFileFsBackend({
|
|
root: testRoot,
|
|
layout: "glob",
|
|
globPattern: "{locale}/{namespace}.{locale}.json",
|
|
});
|
|
expect(await backend.read("fr", "auth")).toEqual({ ok: "Ok-fr" });
|
|
});
|
|
|
|
it("glob layout requires globPattern", { timeout: 5000 }, () => {
|
|
try {
|
|
new MultiFileFsBackend({ root: testRoot, layout: "glob" });
|
|
expect.fail("expected throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(I18nError);
|
|
expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS);
|
|
}
|
|
});
|
|
|
|
it("glob layout substitutes placeholders", { timeout: 5000 }, async () => {
|
|
const seen: string[] = [];
|
|
const fs = {
|
|
readFile: async (p: string) => {
|
|
seen.push(p);
|
|
return new TextEncoder().encode(JSON.stringify({ ok: "Ok" }));
|
|
},
|
|
writeFile: async () => {},
|
|
mkdir: async () => {},
|
|
stat: async () => ({ mtimeMs: 0 }),
|
|
};
|
|
const backend = new MultiFileFsBackend({
|
|
root: "/tmp",
|
|
layout: "glob",
|
|
globPattern: "i18n/{namespace}.{locale}.json",
|
|
fs,
|
|
});
|
|
await backend.read("en", "common");
|
|
expect(seen[0]).toBe("/tmp/i18n/common.en.json");
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Capabilities + extension support
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe("MultiFileFsBackend capabilities", () => {
|
|
it("capabilities matrix follows watch option", { timeout: 5000 }, () => {
|
|
const a = new MultiFileFsBackend({ root: testRoot, watch: true });
|
|
expect(a.capabilities.canWatch).toBe(true);
|
|
const b = new MultiFileFsBackend({ root: testRoot, watch: false });
|
|
expect(b.capabilities.canWatch).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("MultiFileFsBackend extension support", () => {
|
|
it("rejects unknown extension as BACKEND_READ_FAILED", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
await mkdir(join(testRoot, "en"), { recursive: true });
|
|
await writeFile(join(testRoot, "en", "auth.toml"), "ok = 'Ok'");
|
|
const backend = new MultiFileFsBackend({
|
|
root: testRoot,
|
|
extension: "toml",
|
|
});
|
|
try {
|
|
await backend.read("en", "auth");
|
|
expect.fail("expected throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(I18nError);
|
|
expect((err as I18nError).code).toBe(
|
|
I18N_CODES.BACKEND_READ_FAILED
|
|
);
|
|
}
|
|
});
|
|
|
|
it("yaml extension parses via the yaml peer when present", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
await mkdir(join(testRoot, "en"), { recursive: true });
|
|
await writeFile(join(testRoot, "en", "auth.yaml"), "ok: Ok\n");
|
|
const backend = new MultiFileFsBackend({
|
|
root: testRoot,
|
|
extension: "yaml",
|
|
});
|
|
try {
|
|
const record = await backend.read("en", "auth");
|
|
expect(record).toEqual({ ok: "Ok" });
|
|
} catch (err) {
|
|
// If yaml peer is not installed, the surface error must
|
|
// identify the missing peer.
|
|
expect(err).toBeInstanceOf(I18nError);
|
|
expect((err as I18nError).code).toBe(
|
|
I18N_CODES.BACKEND_READ_FAILED
|
|
);
|
|
expect(String((err as I18nError).message)).toContain("yaml");
|
|
}
|
|
});
|
|
|
|
it("yml extension routes through yaml peer", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
await mkdir(join(testRoot, "en"), { recursive: true });
|
|
await writeFile(join(testRoot, "en", "auth.yml"), "ok: Ok\n");
|
|
const backend = new MultiFileFsBackend({
|
|
root: testRoot,
|
|
extension: "yml",
|
|
});
|
|
try {
|
|
await backend.read("en", "auth");
|
|
} catch (err) {
|
|
expect((err as I18nError).message).toContain("yaml");
|
|
}
|
|
});
|
|
|
|
it("yaml peer returning non-record payload surfaces BACKEND_READ_FAILED", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
await mkdir(join(testRoot, "en"), { recursive: true });
|
|
await writeFile(join(testRoot, "en", "auth.yaml"), '"just a string"\n');
|
|
const backend = new MultiFileFsBackend({
|
|
root: testRoot,
|
|
extension: "yaml",
|
|
});
|
|
try {
|
|
await backend.read("en", "auth");
|
|
expect.fail("expected throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(I18nError);
|
|
expect((err as I18nError).code).toBe(
|
|
I18N_CODES.BACKEND_READ_FAILED
|
|
);
|
|
// Either "non-record" (peer present) or "yaml" (peer missing)
|
|
// — both are valid spec outcomes.
|
|
const msg = String((err as I18nError).message);
|
|
expect(/non-record|yaml/.test(msg)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("json5 extension routes through the parseWithPeer dispatch", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
await mkdir(join(testRoot, "en"), { recursive: true });
|
|
await writeFile(join(testRoot, "en", "auth.json5"), "{ ok: 'Ok', }\n");
|
|
const backend = new MultiFileFsBackend({
|
|
root: testRoot,
|
|
extension: "json5",
|
|
});
|
|
try {
|
|
await backend.read("en", "auth");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(I18nError);
|
|
expect((err as I18nError).code).toBe(
|
|
I18N_CODES.BACKEND_READ_FAILED
|
|
);
|
|
expect(String((err as I18nError).message)).toContain("json5");
|
|
}
|
|
});
|
|
|
|
it("save rejects non-JSON extension as BACKEND_WRITE_FAILED", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const backend = new MultiFileFsBackend({
|
|
root: testRoot,
|
|
extension: "yaml",
|
|
});
|
|
try {
|
|
await backend.save("en", "auth", { ok: "Ok" });
|
|
expect.fail("expected throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(I18nError);
|
|
expect((err as I18nError).code).toBe(
|
|
I18N_CODES.BACKEND_WRITE_FAILED
|
|
);
|
|
}
|
|
});
|
|
|
|
it("save accepts json5 extension (encodes as JSON)", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const writes: Array<{ p: string; data: Uint8Array | string }> = [];
|
|
const fs = {
|
|
readFile: async () => new Uint8Array(),
|
|
writeFile: async (p: string, data: Uint8Array | string) => {
|
|
writes.push({ p, data });
|
|
},
|
|
mkdir: async () => {},
|
|
stat: async () => ({ mtimeMs: 0 }),
|
|
};
|
|
const backend = new MultiFileFsBackend({
|
|
root: "/tmp",
|
|
extension: "json5",
|
|
fs,
|
|
});
|
|
await backend.save("en", "auth", { ok: "Ok" });
|
|
expect(writes.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Watch lifecycle
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe("MultiFileFsBackend watch mode", () => {
|
|
it("init starts watcher when watch=true", { timeout: 5000 }, async () => {
|
|
const events: Array<{
|
|
locale: string;
|
|
namespace: string;
|
|
ctx: unknown;
|
|
}> = [];
|
|
const iter = (async function* () {
|
|
yield { filename: "en/common.json" };
|
|
})();
|
|
const fs = {
|
|
readFile: async () =>
|
|
new TextEncoder().encode(JSON.stringify({ ok: "Ok" })),
|
|
writeFile: async () => {},
|
|
mkdir: async () => {},
|
|
stat: async () => ({ mtimeMs: 1 }),
|
|
watch: () => iter,
|
|
};
|
|
const backend = new MultiFileFsBackend({
|
|
root: "/virtual",
|
|
watch: true,
|
|
fs,
|
|
});
|
|
await backend.init({
|
|
catalogName: "test",
|
|
emitLoaded: (locale, namespace, ctx) => {
|
|
events.push({ locale, namespace, ctx });
|
|
},
|
|
});
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
await backend.dispose();
|
|
expect(events).toEqual([
|
|
{
|
|
locale: "en",
|
|
namespace: "common",
|
|
ctx: { backend: "multi-file-fs" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("emits loaded event when a watched file changes", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const events: Array<{ locale: string; namespace: string }> = [];
|
|
const fakeIter = (async function* () {
|
|
yield { filename: "en/auth.json" };
|
|
})();
|
|
|
|
const fs = {
|
|
readFile: async () =>
|
|
new TextEncoder().encode(JSON.stringify({ ok: "Ok" })),
|
|
writeFile: async () => {},
|
|
mkdir: async () => {},
|
|
stat: async () => ({ mtimeMs: 1 }),
|
|
watch: () => fakeIter,
|
|
};
|
|
|
|
const backend = new MultiFileFsBackend({
|
|
root: "/virtual",
|
|
watch: true,
|
|
fs,
|
|
});
|
|
await backend.init({
|
|
catalogName: "test",
|
|
emitLoaded: (locale, namespace) => {
|
|
events.push({ locale, namespace });
|
|
},
|
|
});
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
await backend.dispose();
|
|
expect(events).toEqual([{ locale: "en", namespace: "auth" }]);
|
|
});
|
|
|
|
it("watch ignores null filenames and unmatchable patterns", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const events: unknown[] = [];
|
|
const iter = (async function* () {
|
|
yield { filename: null };
|
|
yield { filename: "noslash.json" };
|
|
})();
|
|
const fs = {
|
|
readFile: async () => new Uint8Array(),
|
|
writeFile: async () => {},
|
|
mkdir: async () => {},
|
|
stat: async () => ({ mtimeMs: 0 }),
|
|
watch: () => iter,
|
|
};
|
|
const backend = new MultiFileFsBackend({
|
|
root: "/virtual",
|
|
watch: true,
|
|
fs,
|
|
});
|
|
await backend.init({
|
|
catalogName: "x",
|
|
emitLoaded: () => events.push({}),
|
|
});
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
await backend.dispose();
|
|
expect(events).toEqual([]);
|
|
});
|
|
|
|
it("watch parses flat-layout filenames", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const events: Array<{ locale: string; namespace: string }> = [];
|
|
const iter = (async function* () {
|
|
yield { filename: "en.auth.json" };
|
|
yield { filename: "no-extension" };
|
|
})();
|
|
const fs = {
|
|
readFile: async () =>
|
|
new TextEncoder().encode(JSON.stringify({ ok: "Ok" })),
|
|
writeFile: async () => {},
|
|
mkdir: async () => {},
|
|
stat: async () => ({ mtimeMs: 0 }),
|
|
watch: () => iter,
|
|
};
|
|
const backend = new MultiFileFsBackend({
|
|
root: "/virtual",
|
|
layout: "flat",
|
|
watch: true,
|
|
fs,
|
|
});
|
|
await backend.init({
|
|
catalogName: "test",
|
|
emitLoaded: (locale, namespace) => {
|
|
events.push({ locale, namespace });
|
|
},
|
|
});
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
await backend.dispose();
|
|
expect(events).toEqual([{ locale: "en", namespace: "auth" }]);
|
|
});
|
|
|
|
it("watch parser ignores unrelated filenames in nested layout", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const events: unknown[] = [];
|
|
const iter = (async function* () {
|
|
yield { filename: "unrelated.txt" };
|
|
})();
|
|
const fs = {
|
|
readFile: async () => new Uint8Array(),
|
|
writeFile: async () => {},
|
|
mkdir: async () => {},
|
|
stat: async () => ({ mtimeMs: 0 }),
|
|
watch: () => iter,
|
|
};
|
|
const backend = new MultiFileFsBackend({
|
|
root: "/virtual",
|
|
watch: true,
|
|
fs,
|
|
});
|
|
await backend.init({
|
|
catalogName: "test",
|
|
emitLoaded: (...args) => events.push(args),
|
|
});
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
await backend.dispose();
|
|
expect(events).toEqual([]);
|
|
});
|
|
|
|
it("dispose is idempotent", { timeout: 5000 }, async () => {
|
|
const backend = new MultiFileFsBackend({ root: testRoot });
|
|
await backend.dispose();
|
|
await backend.dispose();
|
|
expect(true).toBe(true);
|
|
});
|
|
|
|
it("init with watch=false does not start a watcher", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const backend = new MultiFileFsBackend({ root: testRoot });
|
|
await backend.init({ catalogName: "x", emitLoaded: () => {} });
|
|
await backend.dispose();
|
|
expect(true).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// FS error classification
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe("MultiFileFsBackend filesystem error classification", () => {
|
|
it("non-ENOENT errno (e.g. EACCES) maps to BACKEND_READ_FAILED", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const fs = {
|
|
readFile: async () => {
|
|
const err = new Error("permission denied") as Error & {
|
|
code: string;
|
|
};
|
|
err.code = "EACCES";
|
|
throw err;
|
|
},
|
|
writeFile: async () => {},
|
|
mkdir: async () => {},
|
|
stat: async () => ({ mtimeMs: 0 }),
|
|
};
|
|
const backend = new MultiFileFsBackend({ root: "/virtual", fs });
|
|
try {
|
|
await backend.read("en", "auth");
|
|
expect.fail("expected throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(I18nError);
|
|
expect((err as I18nError).code).toBe(
|
|
I18N_CODES.BACKEND_READ_FAILED
|
|
);
|
|
}
|
|
});
|
|
|
|
it("ENOTDIR maps to BACKEND_LOCALE_NOT_AVAILABLE", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const fs = {
|
|
readFile: async () => {
|
|
const err = new Error("not a directory") as Error & {
|
|
code: string;
|
|
};
|
|
err.code = "ENOTDIR";
|
|
throw err;
|
|
},
|
|
writeFile: async () => {},
|
|
mkdir: async () => {},
|
|
stat: async () => ({ mtimeMs: 0 }),
|
|
};
|
|
const backend = new MultiFileFsBackend({ root: "/virtual", fs });
|
|
try {
|
|
await backend.read("en", "auth");
|
|
expect.fail("expected throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(I18nError);
|
|
expect((err as I18nError).code).toBe(
|
|
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Constructor option validation
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe("MultiFileFsBackend constructor", () => {
|
|
it("rejects malformed options shape via Zod (e.g. layout: bogus)", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
try {
|
|
const bad: unknown = "bogus";
|
|
new MultiFileFsBackend({
|
|
root: testRoot,
|
|
layout: bad as "nested",
|
|
});
|
|
expect.fail("expected throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(I18nError);
|
|
expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS);
|
|
}
|
|
});
|
|
|
|
it("rejects empty name with INVALID_OPTIONS", { timeout: 5000 }, () => {
|
|
try {
|
|
new MultiFileFsBackend({ root: testRoot, name: "" });
|
|
expect.fail("expected throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(I18nError);
|
|
expect((err as I18nError).code).toBe(I18N_CODES.INVALID_OPTIONS);
|
|
}
|
|
});
|
|
|
|
it("default name is multi-file-fs", { timeout: 5000 }, () => {
|
|
const backend = new MultiFileFsBackend({ root: testRoot });
|
|
expect(backend.name).toBe("multi-file-fs");
|
|
});
|
|
|
|
it("accepts empty root (paths relative to cwd)", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
let mkdirCalls = 0;
|
|
let writePath: string | undefined;
|
|
const fs = {
|
|
readFile: async () => new Uint8Array(),
|
|
writeFile: async (p: string) => {
|
|
writePath = p;
|
|
},
|
|
mkdir: async () => {
|
|
mkdirCalls++;
|
|
},
|
|
stat: async () => ({ mtimeMs: 0 }),
|
|
};
|
|
const backend = new MultiFileFsBackend({
|
|
root: "",
|
|
layout: "glob",
|
|
globPattern: "{namespace}.{locale}.json",
|
|
fs,
|
|
});
|
|
await backend.save("en", "auth", { ok: "Ok" });
|
|
expect(writePath).toBe("auth.en.json");
|
|
expect(mkdirCalls).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// AbortSignal cancellation
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe("MultiFileFsBackend AbortSignal threading", () => {
|
|
it("pre-aborted signal aborts read with BACKEND_TIMEOUT", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
let touched = false;
|
|
const fs = {
|
|
readFile: async () => {
|
|
touched = true;
|
|
return new Uint8Array();
|
|
},
|
|
writeFile: async () => {},
|
|
mkdir: async () => {},
|
|
stat: async () => ({ mtimeMs: 0 }),
|
|
};
|
|
const backend = new MultiFileFsBackend({ root: "/virtual", fs });
|
|
const ctrl = new AbortController();
|
|
ctrl.abort();
|
|
await expect(
|
|
backend.read("en", "auth", ctrl.signal)
|
|
).rejects.toMatchObject({ code: I18N_CODES.BACKEND_TIMEOUT });
|
|
expect(touched).toBe(false);
|
|
});
|
|
|
|
it("pre-aborted signal aborts save with BACKEND_TIMEOUT", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
let touched = false;
|
|
const fs = {
|
|
readFile: async () => new Uint8Array(),
|
|
writeFile: async () => {
|
|
touched = true;
|
|
},
|
|
mkdir: async () => {},
|
|
stat: async () => ({ mtimeMs: 0 }),
|
|
};
|
|
const backend = new MultiFileFsBackend({ root: "/virtual", fs });
|
|
const ctrl = new AbortController();
|
|
ctrl.abort();
|
|
await expect(
|
|
backend.save("en", "auth", { ok: "Ok" }, ctrl.signal)
|
|
).rejects.toMatchObject({ code: I18N_CODES.BACKEND_TIMEOUT });
|
|
expect(touched).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Observability — layout attribute on metrics
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe("MultiFileFsBackend observability", () => {
|
|
it("read emits layout attribute", { timeout: 5000 }, async () => {
|
|
await writeFile(
|
|
join(testRoot, "en.common.json"),
|
|
JSON.stringify({ ok: "Ok" })
|
|
);
|
|
const backend = new MultiFileFsBackend({
|
|
root: testRoot,
|
|
layout: "flat",
|
|
});
|
|
await backend.read("en", "common");
|
|
const state = getMultiFileFsBackendMetricsState();
|
|
const flat = state.readTotal.find(
|
|
(s) => s.attributes["layout"] === "flat"
|
|
);
|
|
expect(flat).toBeDefined();
|
|
expect(flat?.attributes["outcome"]).toBe("hit");
|
|
});
|
|
|
|
it("write emits layout attribute on success", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const backend = new MultiFileFsBackend({ root: testRoot });
|
|
await backend.save("en", "auth", { ok: "Ok" });
|
|
const state = getMultiFileFsBackendMetricsState();
|
|
const okWrite = state.writeTotal.find(
|
|
(s) =>
|
|
s.attributes["layout"] === "nested" &&
|
|
s.attributes["outcome"] === "ok"
|
|
);
|
|
expect(okWrite).toBeDefined();
|
|
});
|
|
|
|
it("write emits layout attribute on error", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const fs = {
|
|
readFile: async () => new Uint8Array(),
|
|
writeFile: async () => {
|
|
throw new Error("disk full");
|
|
},
|
|
mkdir: async () => {},
|
|
stat: async () => ({ mtimeMs: 0 }),
|
|
};
|
|
const backend = new MultiFileFsBackend({ root: "/tmp/i", fs });
|
|
await expect(
|
|
backend.save("en", "auth", { ok: "Ok" })
|
|
).rejects.toBeInstanceOf(I18nError);
|
|
const state = getMultiFileFsBackendMetricsState();
|
|
const errWrite = state.writeTotal.find(
|
|
(s) => s.attributes["outcome"] === "error"
|
|
);
|
|
expect(errWrite).toBeDefined();
|
|
expect(errWrite?.attributes["layout"]).toBe("nested");
|
|
});
|
|
|
|
it("read miss records BACKEND_LOCALE_NOT_AVAILABLE error code", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const backend = new MultiFileFsBackend({ root: testRoot });
|
|
await expect(backend.read("xx", "auth")).rejects.toBeInstanceOf(
|
|
I18nError
|
|
);
|
|
const state = getMultiFileFsBackendMetricsState();
|
|
const codes = state.readErrorsTotal.map((s) => s.attributes["code"]);
|
|
expect(codes).toContain(I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE);
|
|
});
|
|
|
|
it("read error records BACKEND_READ_FAILED code", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const fs = {
|
|
readFile: async () => {
|
|
const err = new Error("perm") as Error & { code: string };
|
|
err.code = "EACCES";
|
|
throw err;
|
|
},
|
|
writeFile: async () => {},
|
|
mkdir: async () => {},
|
|
stat: async () => ({ mtimeMs: 0 }),
|
|
};
|
|
const backend = new MultiFileFsBackend({ root: "/virtual", fs });
|
|
await expect(backend.read("en", "auth")).rejects.toBeInstanceOf(
|
|
I18nError
|
|
);
|
|
const state = getMultiFileFsBackendMetricsState();
|
|
const codes = state.readErrorsTotal.map((s) => s.attributes["code"]);
|
|
expect(codes).toContain(I18N_CODES.BACKEND_READ_FAILED);
|
|
});
|
|
});
|