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: 5 additions & 0 deletions .changeset/wet-hornets-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@webiny/stdlib": patch
---

feat: add HashFolderTool — deterministic SHA-256 folder hashing with sync and async (parallel I/O) methods, replacing the unmaintained folder-hash library
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ The package is ESM-only and ships three subpath exports. Because each is a separ
| `NdJsonReaderTool` / `NdJsonReaderToolFeature` | Parse NDJSON from files, streams, or in-memory lines with checkpoint support — [docs](src/node/features/NdJsonReaderTool/README.md) |
| `ReadStreamFactory` / `ReadStreamFactoryFeature` | Disposable `node:fs` read streams via `AsyncDisposable` — [docs](src/node/features/ReadStreamFactory/README.md) |
| `PackageJsonFileTool` / `PackageJsonFileToolFeature` | Read, validate, mutate, and write `package.json` files — [docs](src/node/features/PackageJsonFileTool/README.md) |
| `HashFolderTool` / `HashFolderToolFeature` | Deterministic SHA-256 hash of a folder's contents — [docs](src/node/features/HashFolderTool/README.md) |

---

Expand Down
327 changes: 327 additions & 0 deletions __tests__/node/HashFolderTool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Container } from "@webiny/di";
import {
HashFolderTool,
HashFolderToolFeature,
createHashFolderTool,
hashFolder,
hashFolderAsync
} from "../../src/node/features/HashFolderTool/index.js";

function makeContainer(): Container {
const container = new Container();
HashFolderToolFeature.register(container);
return container;
}

describe("HashFolderTool", () => {
let tmpDir: string;
let tool: HashFolderTool.Interface;

beforeEach(() => {
tmpDir = join(tmpdir(), `wby-hash-test-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
tool = makeContainer().resolve(HashFolderTool);
});

afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});

describe("hash (sync)", () => {
it("returns a result with a hex hash", () => {
writeFileSync(join(tmpDir, "a.txt"), "hello");
writeFileSync(join(tmpDir, "b.txt"), "world");

const result = tool.hash(tmpDir);
expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) });
});

it("returns a deterministic hash for the same content", () => {
writeFileSync(join(tmpDir, "file.txt"), "content");

const result1 = tool.hash(tmpDir);
const result2 = tool.hash(tmpDir);
expect(result1).toEqual(result2);
});

it("produces different hashes when file content changes", () => {
writeFileSync(join(tmpDir, "file.txt"), "version1");
const result1 = tool.hash(tmpDir);

writeFileSync(join(tmpDir, "file.txt"), "version2");
const result2 = tool.hash(tmpDir);

expect(result1).not.toEqual(result2);
});

it("includes files in nested subdirectories", () => {
mkdirSync(join(tmpDir, "sub"), { recursive: true });
writeFileSync(join(tmpDir, "sub", "nested.txt"), "deep");

const result1 = tool.hash(tmpDir);

writeFileSync(join(tmpDir, "sub", "nested.txt"), "changed");
const result2 = tool.hash(tmpDir);

expect(result1).not.toEqual(result2);
});

it("excludes specified folders", () => {
writeFileSync(join(tmpDir, "keep.txt"), "kept");
mkdirSync(join(tmpDir, "dist"), { recursive: true });
writeFileSync(join(tmpDir, "dist", "bundle.js"), "compiled");

const result1 = tool.hash(tmpDir, { excludeFolders: ["dist"] });

writeFileSync(join(tmpDir, "dist", "bundle.js"), "recompiled");
const result2 = tool.hash(tmpDir, { excludeFolders: ["dist"] });

expect(result1).toEqual(result2);
});

it("excludes specified files", () => {
writeFileSync(join(tmpDir, "source.ts"), "code");
writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "info");

const result1 = tool.hash(tmpDir, {
excludeFiles: ["tsconfig.build.tsbuildinfo"]
});

writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "updated-info");
const result2 = tool.hash(tmpDir, {
excludeFiles: ["tsconfig.build.tsbuildinfo"]
});

expect(result1).toEqual(result2);
});

it("excludes multiple folders and files together", () => {
writeFileSync(join(tmpDir, "source.ts"), "code");
mkdirSync(join(tmpDir, "dist"), { recursive: true });
mkdirSync(join(tmpDir, "node_modules"), { recursive: true });
writeFileSync(join(tmpDir, "dist", "out.js"), "compiled");
writeFileSync(join(tmpDir, "node_modules", "dep.js"), "dep");
writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "info");

const result1 = tool.hash(tmpDir, {
excludeFolders: ["dist", "node_modules"],
excludeFiles: ["tsconfig.build.tsbuildinfo"]
});

writeFileSync(join(tmpDir, "dist", "out.js"), "recompiled");
writeFileSync(join(tmpDir, "node_modules", "dep.js"), "updated-dep");
writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "new-info");

const result2 = tool.hash(tmpDir, {
excludeFolders: ["dist", "node_modules"],
excludeFiles: ["tsconfig.build.tsbuildinfo"]
});

expect(result1).toEqual(result2);
});

it("is order-independent — same files in different creation order produce same hash", () => {
const dir1 = join(tmpDir, "dir1");
const dir2 = join(tmpDir, "dir2");
mkdirSync(dir1, { recursive: true });
mkdirSync(dir2, { recursive: true });

writeFileSync(join(dir1, "a.txt"), "alpha");
writeFileSync(join(dir1, "b.txt"), "beta");

writeFileSync(join(dir2, "b.txt"), "beta");
writeFileSync(join(dir2, "a.txt"), "alpha");

const result1 = tool.hash(dir1);
const result2 = tool.hash(dir2);
expect(result1).toEqual(result2);
});

it("returns a hash for an empty folder", () => {
const result = tool.hash(tmpDir);
expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) });
});

it("includes relative path in the hash so renames are detected", () => {
writeFileSync(join(tmpDir, "original.txt"), "content");
const result1 = tool.hash(tmpDir);

rmSync(join(tmpDir, "original.txt"));
writeFileSync(join(tmpDir, "renamed.txt"), "content");
const result2 = tool.hash(tmpDir);

expect(result1).not.toEqual(result2);
});
});

describe("hashAsync (parallel)", () => {
it("returns a result with a hex hash", async () => {
writeFileSync(join(tmpDir, "a.txt"), "hello");
writeFileSync(join(tmpDir, "b.txt"), "world");

const result = await tool.hashAsync(tmpDir);
expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) });
});

it("returns a deterministic hash for the same content", async () => {
writeFileSync(join(tmpDir, "file.txt"), "content");

const result1 = await tool.hashAsync(tmpDir);
const result2 = await tool.hashAsync(tmpDir);
expect(result1).toEqual(result2);
});

it("excludes specified folders and files", async () => {
writeFileSync(join(tmpDir, "source.ts"), "code");
mkdirSync(join(tmpDir, "dist"), { recursive: true });
writeFileSync(join(tmpDir, "dist", "out.js"), "compiled");
writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "info");

const result1 = await tool.hashAsync(tmpDir, {
excludeFolders: ["dist"],
excludeFiles: ["tsconfig.build.tsbuildinfo"]
});

writeFileSync(join(tmpDir, "dist", "out.js"), "recompiled");
writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "new-info");

const result2 = await tool.hashAsync(tmpDir, {
excludeFolders: ["dist"],
excludeFiles: ["tsconfig.build.tsbuildinfo"]
});

expect(result1).toEqual(result2);
});

it("produces the same result as the sync method", async () => {
writeFileSync(join(tmpDir, "a.txt"), "alpha");
mkdirSync(join(tmpDir, "sub"), { recursive: true });
writeFileSync(join(tmpDir, "sub", "b.txt"), "beta");

const syncResult = tool.hash(tmpDir);
const asyncResult = await tool.hashAsync(tmpDir);
expect(asyncResult).toEqual(syncResult);
});

it("returns a hash for an empty folder", async () => {
const result = await tool.hashAsync(tmpDir);
expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) });
});
});
});

describe("createHashFolderTool", () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = join(tmpdir(), `wby-hash-factory-test-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
});

afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});

it("creates a working tool (sync)", () => {
const tool = createHashFolderTool();
writeFileSync(join(tmpDir, "file.txt"), "content");
const result = tool.hash(tmpDir);
expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) });
});

it("creates a working tool (async)", async () => {
const tool = createHashFolderTool();
writeFileSync(join(tmpDir, "file.txt"), "content");
const result = await tool.hashAsync(tmpDir);
expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) });
});
});

describe("hashFolder (sync standalone)", () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = join(tmpdir(), `wby-hash-standalone-test-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
});

afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});

it("returns a result object", () => {
writeFileSync(join(tmpDir, "file.txt"), "content");
const result = hashFolder(tmpDir);
expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) });
});

it("supports exclude options", () => {
writeFileSync(join(tmpDir, "source.ts"), "code");
mkdirSync(join(tmpDir, "dist"), { recursive: true });
writeFileSync(join(tmpDir, "dist", "out.js"), "compiled");

const result1 = hashFolder(tmpDir, { excludeFolders: ["dist"] });

writeFileSync(join(tmpDir, "dist", "out.js"), "recompiled");
const result2 = hashFolder(tmpDir, { excludeFolders: ["dist"] });

expect(result1).toEqual(result2);
});

it("produces the same result as the DI tool", () => {
writeFileSync(join(tmpDir, "file.txt"), "content");

const tool = createHashFolderTool();
const diResult = tool.hash(tmpDir);
const standaloneResult = hashFolder(tmpDir);

expect(standaloneResult).toEqual(diResult);
});
});

describe("hashFolderAsync (async standalone)", () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = join(tmpdir(), `wby-hash-async-standalone-test-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
});

afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});

it("returns a result object", async () => {
writeFileSync(join(tmpDir, "file.txt"), "content");
const result = await hashFolderAsync(tmpDir);
expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) });
});

it("supports exclude options", async () => {
writeFileSync(join(tmpDir, "source.ts"), "code");
mkdirSync(join(tmpDir, "dist"), { recursive: true });
writeFileSync(join(tmpDir, "dist", "out.js"), "compiled");

const result1 = await hashFolderAsync(tmpDir, { excludeFolders: ["dist"] });

writeFileSync(join(tmpDir, "dist", "out.js"), "recompiled");
const result2 = await hashFolderAsync(tmpDir, { excludeFolders: ["dist"] });

expect(result1).toEqual(result2);
});

it("produces the same result as sync standalone", async () => {
writeFileSync(join(tmpDir, "file.txt"), "content");
mkdirSync(join(tmpDir, "sub"), { recursive: true });
writeFileSync(join(tmpDir, "sub", "nested.txt"), "nested");

const syncResult = hashFolder(tmpDir);
const asyncResult = await hashFolderAsync(tmpDir);

expect(asyncResult).toEqual(syncResult);
});
});
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@
"devDependencies": {
"@changesets/cli": "^2.31.0",
"@types/node": ">=24",
"@typescript/native-preview": "^7.0.0-dev.20260519.1",
"@vitest/coverage-v8": "^4.1.6",
"@typescript/native-preview": "^7.0.0-dev.20260522.1",
"@vitest/coverage-v8": "^4.1.7",
"adio": "^3.0.0",
"happy-dom": "^20.9.0",
"oxfmt": "^0.51.0",
"oxlint": "^1.66.0",
"vitest": "^4.1.6"
"vitest": "^4.1.7"
},
"scripts": {
"clean": "rm -rf dist",
Expand Down
Loading
Loading