Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
"version": "node version-bump.mjs && git add manifest.json versions.json",
"lint": "eslint .",
"test": "node --experimental-vm-modules node_modules/.bin/jest",
"test:release": "node --test scripts/__tests__/*.test.mjs",
"verify": "npm run lint && npm run build && npm test -- --runInBand && npm run test:release"
"test:release": "node --test scripts/__tests__/validate-release.test.mjs scripts/__tests__/workflow-actions.test.mjs",
"verify": "npm run lint && npm run build && npm test -- --runInBand && npm run test:release && npm run test:fixture",
"test:fixture": "node --test scripts/__tests__/prepare-large-vault-fixture.test.mjs"
},
"keywords": [
"obsidian",
Expand Down
107 changes: 107 additions & 0 deletions scripts/__tests__/prepare-large-vault-fixture.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, mkdir, writeFile, rm, readdir, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
validateOptions,
resolveFixturePath,
createFixture,
removeFixture,
} from "../prepare-large-vault-fixture.mjs";

function parsed(values) {
return { values };
}

function expectFailure(fn, messagePart) {
assert.throws(fn, (error) => {
if (messagePart) assert.match(String(error.message ?? ""), new RegExp(messagePart));
return true;
});
}

test("missing --vault fails", () => {
expectFailure(() => validateOptions(parsed({ files: "5000" })), "missing --vault");
});

test("missing --files fails without --remove", () => {
expectFailure(() => validateOptions(parsed({ vault: "/tmp/x" })), "missing --files");
});

test("--files outside 100-50000 fails", () => {
expectFailure(() => validateOptions(parsed({ vault: "/tmp/x", files: "99" })), "between 100 and 50000");
expectFailure(() => validateOptions(parsed({ vault: "/tmp/x", files: "50001" })), "between 100 and 50000");
expectFailure(() => validateOptions(parsed({ vault: "/tmp/x", files: "abc" })), "integer");
});

test("cleanup refuses an unmarked directory", async () => {
const vault = await mkdtemp(path.join(tmpdir(), "se-fixture-"));
const fixture = resolveFixturePath(vault);
await mkdir(fixture);
await writeFile(path.join(fixture, "note.md"), "# keep");

await assert.rejects(() => removeFixture(vault), /refusing to remove/);

// Unchanged.
await assert.ok((await stat(fixture)).isDirectory());
await rm(vault, { recursive: true });
});

test("cleanup never deletes the vault root", async () => {
const vault = await mkdtemp(path.join(tmpdir(), "se-fixture-"));
await writeFile(path.join(vault, "important.md"), "keep");

await assert.rejects(() => removeFixture(vault), /refusing to remove/);
await assert.equal((await readFileSafe(path.join(vault, "important.md"))), "keep");

await rm(vault, { recursive: true });
});

test("a temp-directory fixture creates and removes exactly its own subtree", async () => {
const vault = await mkdtemp(path.join(tmpdir(), "se-fixture-"));
await writeFile(path.join(vault, "untouched.md"), "keep");

await createFixture(vault, 100);

const fixture = resolveFixturePath(vault);
const entries = await readdir(vault);
assert.ok(entries.includes(".smart-explorer-large-vault-fixture"));
assert.ok(entries.includes("untouched.md"));

const fixtureEntries = await readdir(fixture);
assert.ok(fixtureEntries.includes(".smart-explorer-fixture-marker"));
const folders = fixtureEntries.filter((entry) => entry.startsWith("folder-"));
assert.equal(folders.length, 100);

await removeFixture(vault);

const after = await readdir(vault);
assert.deepEqual(after, ["untouched.md"]);

await rm(vault, { recursive: true });
});

test("a 5000-file fixture rotates through every attachment format", async () => {
const vault = await mkdtemp(path.join(tmpdir(), "se-fixture-"));
try {
await createFixture(vault, 5000);
const fixture = resolveFixturePath(vault);
const extensions = new Set();
for (const folder of await readdir(fixture)) {
if (!folder.startsWith("folder-")) continue;
for (const file of await readdir(path.join(fixture, folder))) {
extensions.add(path.extname(file).slice(1));
}
}

assert.deepEqual([...extensions].sort(), ["docx", "md", "pdf", "png"]);
} finally {
await rm(vault, { recursive: true });
}
});

async function readFileSafe(file) {
const { readFile } = await import("node:fs/promises");
return readFile(file, "utf8");
}
133 changes: 133 additions & 0 deletions scripts/prepare-large-vault-fixture.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#!/usr/bin/env node
/**
* Guarded synthetic-fixture generator for large-vault testing.
*
* May only create or delete `<vault>/.smart-explorer-large-vault-fixture`.
* A marker file is written before any file generation; removal refuses to
* run unless the directory name and marker both match, so an unmarked or
* mistyped path can never be deleted.
*/
import process from "node:process";
import { parseArgs } from "node:util";
import { mkdir, rm, writeFile, stat, readFile } from "node:fs/promises";
import path from "node:path";

const FIXTURE_DIR_NAME = ".smart-explorer-large-vault-fixture";
const MARKER_FILE_NAME = ".smart-explorer-fixture-marker";
const MIN_FILES = 100;
const MAX_FILES = 50000;
const FOLDER_COUNT = 100;

export function fail(message) {
throw new Error(message);
}

function parseArguments(argv) {
try {
return parseArgs({
args: argv,
options: {
vault: { type: "string" },
files: { type: "string" },
remove: { type: "boolean", default: false },
},
strict: true,
});
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
}

export function resolveFixturePath(vault) {
const vaultPath = path.resolve(vault);
return path.join(vaultPath, FIXTURE_DIR_NAME);
}

export function validateOptions({ values }) {
if (!values.vault) fail("missing --vault <path>");
if (values.remove) return { remove: true, vault: values.vault, files: null };
if (values.files === undefined) fail("missing --files <100-50000>");
const files = Number(values.files);
if (!Number.isInteger(files) || files < MIN_FILES || files > MAX_FILES) {
fail(`--files must be an integer between ${MIN_FILES} and ${MAX_FILES}`);
}
return { remove: false, vault: values.vault, files };
}

async function isMarkedFixtureDir(dir) {
let dirStat;
try {
dirStat = await stat(dir);
} catch {
return false;
}
if (!dirStat.isDirectory()) return false;
if (path.basename(dir) !== FIXTURE_DIR_NAME) return false;
try {
const marker = await readFile(path.join(dir, MARKER_FILE_NAME), "utf8");
return marker.trim() === "smart-explorer-large-vault-fixture";
} catch {
return false;
}
}

export async function createFixture(vault, files) {
const dir = resolveFixturePath(vault);
if (path.dirname(dir) === dir) fail("refusing to operate on a filesystem root");
await mkdir(dir, { recursive: false });
await writeFile(path.join(dir, MARKER_FILE_NAME), "smart-explorer-large-vault-fixture\n");

const perFolder = Math.ceil(files / FOLDER_COUNT);
const attachments = [
{ ext: "png", size: 0 },
{ ext: "pdf", size: 0 },
{ ext: "docx", size: 0 },
];
let created = 0;
let attachmentIndex = 0;
for (let folderIndex = 0; folderIndex < FOLDER_COUNT && created < files; folderIndex++) {
const folder = path.join(dir, `folder-${String(folderIndex).padStart(3, "0")}`);
await mkdir(folder);
for (let fileIndex = 0; fileIndex < perFolder && created < files; fileIndex++) {
const isAttachment = fileIndex % 50 === 49 && fileIndex > 0;
if (isAttachment) {
const attachment = attachments[attachmentIndex % attachments.length];
attachmentIndex++;
await writeFile(path.join(folder, `attachment-${fileIndex}.${attachment.ext}`), "");
} else {
await writeFile(path.join(folder, `note-${fileIndex}.md`), "# Fixture note\n");
}
created++;
}
}
console.log(`created ${created} fixture files in ${dir}`);
}

export async function removeFixture(vault) {
const dir = resolveFixturePath(vault);
if (!(await isMarkedFixtureDir(dir))) {
fail("refusing to remove: not a marker-protected fixture directory");
}
await rm(dir, { recursive: true, force: false });
console.log(`removed ${dir}`);
}

async function main() {
const parsed = parseArguments(process.argv.slice(2));
const options = validateOptions(parsed);
if (options.remove) {
await removeFixture(options.vault);
} else {
await createFixture(options.vault, options.files);
}
}

// Only run main when executed directly (tests import the helpers).
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)) {
try {
await main();
} catch (error) {
console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}
58 changes: 58 additions & 0 deletions src/__tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,62 @@ describe("SmartExplorerPlugin", () => {
});
expect(workspace.revealLeaf).toHaveBeenCalledWith(leftLeaf);
});
it("serializes immutable settings snapshots", async () => {
let resolveFirst!: () => void;
const firstWrite = new Promise<void>((resolve) => { resolveFirst = resolve; });
const plugin = new SmartExplorerPlugin({} as any, {} as any);
await plugin.loadSettings();
plugin.saveData = jest.fn()
.mockReturnValueOnce(firstWrite)
.mockResolvedValueOnce(undefined);

plugin.settings.hiddenExtensions = ["png"];
const first = plugin.saveSettings();
plugin.settings.hiddenExtensions = ["pdf"];
const second = plugin.saveSettings();

expect(plugin.saveData).toHaveBeenCalledTimes(1);
resolveFirst();
await first;
await second;
expect(plugin.saveData).toHaveBeenNthCalledWith(1, expect.objectContaining({ hiddenExtensions: ["png"] }));
expect(plugin.saveData).toHaveBeenNthCalledWith(2, expect.objectContaining({ hiddenExtensions: ["pdf"] }));
});

it("continues saving after one write rejects", async () => {
const plugin = new SmartExplorerPlugin({} as any, {} as any);
await plugin.loadSettings();
plugin.saveData = jest.fn()
.mockRejectedValueOnce(new Error("disk full"))
.mockResolvedValueOnce(undefined);

await expect(plugin.saveSettings()).rejects.toThrow("disk full");
await expect(plugin.saveSettings()).resolves.toBeUndefined();
expect(plugin.saveData).toHaveBeenCalledTimes(2);
});

it("notifies and recovers through saveSettingsWithNotice", async () => {
const plugin = new SmartExplorerPlugin({} as any, {} as any);
await plugin.loadSettings();
plugin.saveData = jest.fn()
.mockRejectedValueOnce(new Error("disk full"))
.mockResolvedValueOnce(undefined);

await expect(plugin.saveSettingsWithNotice("Could not save settings")).resolves.toBe(false);
await expect(plugin.saveSettingsWithNotice("Could not save settings")).resolves.toBe(true);
});

it("flushes pending writes", async () => {
let resolveWrite!: () => void;
const write = new Promise<void>((resolve) => { resolveWrite = resolve; });
const plugin = new SmartExplorerPlugin({} as any, {} as any);
await plugin.loadSettings();
plugin.saveData = jest.fn().mockReturnValueOnce(write);
const saving = plugin.saveSettings();
const flushed = plugin.flushSettings();
resolveWrite();
await Promise.all([saving, flushed]);
expect(plugin.saveData).toHaveBeenCalledTimes(1);
});

});
Loading