Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(fs): mock stubs current working directory by default #12

Merged
merged 3 commits into from
Jun 22, 2024
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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@ assert(Deno.readTextFile !== original.readTextFile);
assert(Deno.readTextFileSync !== original.readTextFileSync);
```

Stub the current working directory by default:

```typescript
fs.mock();
await fs.use(() => Deno.writeTextFile("./test.txt", "amber"));
assertRejects(() => Deno.readTextFile("./test.txt"));
```

#### `use`

Replace file system functions within the callback:
Expand Down Expand Up @@ -188,6 +196,14 @@ assertSpyCalls(cwd.readTextFile, 1);
assertSpyCalls(src.readTextFile, 0);
```

Accept a URL:

```typescript
const spy = fs.spy(new URL("..", import.meta.url));
await fs.use(() => Deno.readTextFile("./README.md"));
assertSpyCalls(spy.readTextFile, 1);
```

#### `stub`

Not write to the original path:
Expand Down
43 changes: 29 additions & 14 deletions src/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
*/

import { mapValues, pick } from "@std/collections";
import { join } from "@std/path";
import { dirname, fromFileUrl, join, resolve } from "@std/path";
import type { Spy } from "@std/testing/mock";
import * as std from "@std/testing/mock";
import { isUnder, relative, tryCatch, tryFinally } from "./internal.ts";
import { relative, tryCatch, tryFinally } from "./internal.ts";

/**
* The base names of Deno's APIs related to file system operations that takes
Expand Down Expand Up @@ -117,7 +117,7 @@ export interface FileSystemStub extends FileSystemSpy {
* to a temporary directory.
*/
function createFsFake(
base: string | URL,
base: string,
temp: string,
readThrough: boolean,
): Fs {
Expand Down Expand Up @@ -156,12 +156,16 @@ function createFsFake(
*/
const spies = new class extends Map<string | URL, FileSystemSpy> {
/** Returns the spy for the path that is under the given path. */
override get(key: string | URL) {
for (const [path, spy] of this) {
if (isUnder(key, path)) {
return spy;
}
override get(path: string | URL): FileSystemSpy | undefined {
const spy = super.get(path);
if (spy) {
return spy;
}
path = path.toString();
if (path === "." || path === ".." || path === "/") {
return;
}
return this.get(dirname(path));
}
}();

Expand Down Expand Up @@ -193,6 +197,7 @@ export function stub(
path: string | URL,
fakeOrOptions?: Partial<Fs> | StubOptions,
): FileSystemStub {
path = normalize(path);
const temp = Deno.makeTempDirSync();

const fake = isStubOptions(fakeOrOptions)
Expand Down Expand Up @@ -221,9 +226,10 @@ export function spy(
}

export function mock(): Disposable {
for (const name of FsFnNames) {
mockFsFn(name);
if (spies.size === 0) {
stub(Deno.cwd());
}
FsFnNames.forEach((name) => mockFsFn(name));
return {
[Symbol.dispose]() {
dispose();
Expand All @@ -234,7 +240,8 @@ export function mock(): Disposable {
function mockFsFn<T extends FsFnName>(name: T) {
Deno[name] = new Proxy(Deno[name], {
apply(target, thisArg, args) {
const spy = spies.get(args[0])?.[name];
const path = normalize(args[0]);
const spy = spies.get(path)?.[name];
if (spy) {
return Reflect.apply(spy, thisArg, args);
}
Expand All @@ -249,9 +256,7 @@ export function use<T>(fn: () => T): T {
}

export function restore() {
for (const name of FsFnNames) {
restoreFsFn(name, fs[name]);
}
FsFnNames.forEach((name) => restoreFsFn(name, fs[name]));
}

export function dispose() {
Expand All @@ -265,3 +270,13 @@ function restoreFsFn<T extends FsFnName>(
) {
Deno[name] = fn;
}

/** Normalize a path or file URL to an absolute path */
function normalize(path: string | URL): string {
if (path instanceof URL) {
path = path.protocol === "file:" ? fromFileUrl(path) : path.href;
} else {
path = resolve(path);
}
return path.endsWith("/") ? path.slice(0, -1) : path;
}
25 changes: 17 additions & 8 deletions src/fs_test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { assert, assertEquals, assertThrows } from "@std/assert";
import { assert, assertEquals, assertRejects, assertThrows } from "@std/assert";
import { afterAll, afterEach, beforeAll, describe, it } from "@std/testing/bdd";
import { assertSpyCalls } from "@std/testing/mock";
import * as fs from "./fs.ts";

describe("mock", () => {
const original = { ...Deno };

afterEach(() => {
fs.restore();
});
afterEach(() => fs.dispose());

it("should return a disposable", () => {
assert(Symbol.dispose in fs.mock());
Expand All @@ -19,11 +17,19 @@ describe("mock", () => {
assert(Deno.readTextFile !== original.readTextFile);
assert(Deno.readTextFileSync !== original.readTextFileSync);
});

it("should stub the current working directory by default", async () => {
fs.mock();
await fs.use(() => Deno.writeTextFile("./test.txt", "amber"));
assertRejects(() => Deno.readTextFile("./test.txt"));
});
});

describe("use", () => {
const original = { ...Deno };

afterEach(() => fs.dispose());

it("should replace file system functions within the callback", () => {
fs.use(() => {
assert(Deno.readTextFile !== original.readTextFile);
Expand All @@ -33,14 +39,10 @@ describe("use", () => {
});

describe("spy", () => {
let cwd: string;

beforeAll(() => {
cwd = Deno.cwd();
Deno.chdir(new URL("../", import.meta.url));
});
afterEach(() => fs.dispose());
afterAll(() => Deno.chdir(cwd));

it("should spy file system functions", async () => {
const spy = fs.spy(".");
Expand All @@ -55,6 +57,12 @@ describe("spy", () => {
assertSpyCalls(cwd.readTextFile, 1);
assertSpyCalls(src.readTextFile, 0);
});

it("should accept a URL", async () => {
const spy = fs.spy(new URL("..", import.meta.url));
await fs.use(() => Deno.readTextFile("./README.md"));
assertSpyCalls(spy.readTextFile, 1);
});
});

describe("stub", () => {
Expand Down Expand Up @@ -117,6 +125,7 @@ describe("restore", () => {
cwd = Deno.cwd();
Deno.chdir(new URL("../", import.meta.url));
});
afterEach(() => fs.dispose());
afterAll(() => Deno.chdir(cwd));

it("should restore file system functions", () => {
Expand Down
10 changes: 0 additions & 10 deletions src/internal.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,6 @@
import { toPath } from "@molt/lib/path";
import * as std from "@std/path";

/**
* Check if a path is under a base path.
*/
export function isUnder(
path: string | URL,
base: string | URL,
): boolean {
return toPath(path).startsWith(toPath(base));
}

/**
* Return the relative path from the first path to the second path.
*/
Expand Down
71 changes: 2 additions & 69 deletions src/internal_test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { assert, assertEquals, assertFalse } from "@std/assert";
import { assert, assertEquals } from "@std/assert";
import { afterAll, beforeAll, describe, it } from "@std/testing/bdd";
import { isUnder, relative, tryCatchFinally } from "./internal.ts";
import { relative, tryCatchFinally } from "./internal.ts";

describe("tryCatchFinally", () => {
it("should execute the function and return the result", () => {
Expand Down Expand Up @@ -122,70 +122,3 @@ describe("relative", () => {
);
});
});

describe("isUnder", () => {
let cwd: string;

beforeAll(() => {
cwd = Deno.cwd();
Deno.chdir(new URL(".", import.meta.url));
});

afterAll(() => {
Deno.chdir(cwd);
});

it("should return true if the first path is under the second path", () => {
assert(
isUnder(
new URL("../README.md", import.meta.url),
new URL("..", import.meta.url),
),
);
assert(
isUnder(
"../README.md",
"..",
),
);
assert(
isUnder(
"../README.md",
new URL("..", import.meta.url),
),
);
assert(
isUnder(
new URL("../README.md", import.meta.url),
"..",
),
);
});

it("should return false if the first path is not under the second path", () => {
assertFalse(
isUnder(
new URL("../README.md", import.meta.url),
new URL(".", import.meta.url),
),
);
assertFalse(
isUnder(
"../README.md",
".",
),
);
assertFalse(
isUnder(
"../README.md",
new URL(".", import.meta.url),
),
);
assert(
isUnder(
new URL("../README.md", import.meta.url),
"..",
),
);
});
});