expand shim coverage, additional fs shims, add light stream shim

This commit is contained in:
Nystik
2026-06-07 12:51:27 +02:00
parent 35348093a6
commit c3a9d511b2
16 changed files with 684 additions and 1 deletions

View File

@@ -0,0 +1,34 @@
const CALLBACK_METHODS = [
"stat",
"lstat",
"readdir",
"readFile",
"writeFile",
"appendFile",
"unlink",
"rename",
"mkdir",
"rmdir",
"rm",
"copyFile",
"access",
"utimes",
"chmod",
];
export function createFsCallbacks(fsPromises) {
const callbacks = {};
for (const name of CALLBACK_METHODS) {
callbacks[name] = function (...args) {
const callback = args.pop();
fsPromises[name](...args).then(
(result) => callback(null, result),
(err) => callback(err),
);
};
}
return callbacks;
}

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { createFsCallbacks } from "./callback.js";
describe("fs callbacks", () => {
it("resolves the promise result through the callback", async () => {
const fakePromises = { readFile: async (p) => `data:${p}` };
const cb = createFsCallbacks(fakePromises);
const result = await new Promise((resolve) =>
cb.readFile("/x", (err, data) => resolve([err, data])),
);
expect(result).toEqual([null, "data:/x"]);
});
it("passes a rejection to the callback as the error argument", async () => {
const boom = new Error("nope");
const fakePromises = {
stat: async () => {
throw boom;
},
};
const cb = createFsCallbacks(fakePromises);
const result = await new Promise((resolve) =>
cb.stat("/x", (err) => resolve(err)),
);
expect(result).toBe(boom);
});
it("forwards the arguments that precede the callback", async () => {
let received = null;
const fakePromises = {
mkdir: async (p, opts) => {
received = [p, opts];
},
};
const cb = createFsCallbacks(fakePromises);
await new Promise((resolve) =>
cb.mkdir("/d", { recursive: true }, () => resolve()),
);
expect(received).toEqual(["/d", { recursive: true }]);
});
});

View File

@@ -6,6 +6,8 @@ import { createFsSync } from "./sync.js";
import { createFsWatch } from "./watch.js";
import { createWatcherClient } from "./watcher-client.js";
import { createFdOps } from "./fd.js";
import { createFsCallbacks } from "./callback.js";
import { realpath, realpathSync } from "./realpath.js";
import { constants } from "./constants.js";
import { registerReadTransform, removeReadTransform, resolvePath } from "./transforms.js";
import { wsClient } from "../ws-client.js";
@@ -18,10 +20,13 @@ const fsSync = createFsSync(metadataCache, contentCache, transport);
const fsWatch = createFsWatch(transport);
const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch, wsClient);
const fdOps = createFdOps(metadataCache, contentCache, transport);
const fsCallbacks = createFsCallbacks(fsPromises);
export const fsShim = {
promises: fsPromises,
...fsCallbacks,
existsSync: fsSync.existsSync,
readFileSync: fsSync.readFileSync,
writeFileSync: fsSync.writeFileSync,
@@ -29,6 +34,18 @@ export const fsShim = {
accessSync: fsSync.accessSync,
statSync: fsSync.statSync,
readdirSync: fsSync.readdirSync,
lstatSync: fsSync.lstatSync,
mkdirSync: fsSync.mkdirSync,
rmdirSync: fsSync.rmdirSync,
rmSync: fsSync.rmSync,
renameSync: fsSync.renameSync,
copyFileSync: fsSync.copyFileSync,
appendFileSync: fsSync.appendFileSync,
utimesSync: fsSync.utimesSync,
chmodSync: fsSync.chmodSync,
realpath,
realpathSync,
open: fdOps.open,
openSync: fdOps.openSync,

View File

@@ -1,6 +1,10 @@
import { markLocalOp } from "./echo-guard.js";
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
import { applyReadTransform, applyWriteTransform, resolvePath } from "./transforms.js";
import {
applyReadTransform,
applyWriteTransform,
resolvePath,
} from "./transforms.js";
import { hasVirtualFile, getVirtualFile } from "./virtual-files.js";
export function createFsPromises(metadataCache, contentCache, transport) {
@@ -270,6 +274,10 @@ export function createFsPromises(metadataCache, contentCache, transport) {
}
},
async chmod() {
// No permission bits in the vault FS. No-op.
},
async open(path, flags) {
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null;
const resolved = resolvePath(path);

View File

@@ -0,0 +1,12 @@
export function realpathSync(path) {
return typeof path === "string" ? path : String(path);
}
export function realpath(path, options, callback) {
const cb = typeof options === "function" ? options : callback;
queueMicrotask(() => cb(null, realpathSync(path)));
}
realpath.native = realpath;
realpathSync.native = realpathSync;

View File

@@ -0,0 +1,20 @@
import { describe, it, expect } from "vitest";
import { realpath } from "./realpath.js";
describe("fs realpath shim", () => {
it("realpath invokes the callback with the path", async () => {
const result = await new Promise((resolve) =>
realpath("/a/b.md", (err, p) => resolve(p)),
);
expect(result).toBe("/a/b.md");
});
it("realpath accepts an options argument before the callback", async () => {
const result = await new Promise((resolve) =>
realpath("/a/b.md", "utf8", (err, p) => resolve(p)),
);
expect(result).toBe("/a/b.md");
});
});

View File

@@ -0,0 +1,128 @@
import { describe, it, expect, vi } from "vitest";
import { createFsSync } from "./sync.js";
import { resolvePath } from "./transforms.js";
function makeDeps() {
const store = new Map();
const metadataCache = {
has: (p) => store.has(p),
get: (p) => (store.has(p) ? store.get(p) : null),
set: (p, m) => store.set(p, m),
delete: (p) => store.delete(p),
rename: (a, b) => {
if (store.has(a)) {
store.set(b, store.get(a));
store.delete(a);
}
},
toStat: (p) =>
store.has(p)
? {
type: store.get(p).type,
isDirectory: () => store.get(p).type === "directory",
isFile: () => store.get(p).type === "file",
}
: null,
readdir: () => [],
};
const contentCache = {
get: () => null,
set: vi.fn(),
delete: vi.fn(),
invalidate: vi.fn(),
};
const transport = {
mkdir: vi.fn(async () => {}),
rmdir: vi.fn(async () => {}),
rm: vi.fn(async () => {}),
rename: vi.fn(async () => {}),
copyFile: vi.fn(async () => {}),
appendFile: vi.fn(async () => {}),
utimes: vi.fn(async () => {}),
stat: vi.fn(async () => ({ type: "file", size: 1 })),
};
return { metadataCache, contentCache, transport, store };
}
describe("sync fs mutations", () => {
it("lstatSync mirrors statSync", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
deps.store.set(resolvePath("dir"), { type: "directory" });
expect(fs.lstatSync("dir").isDirectory()).toBe(true);
});
it("mkdirSync updates the cache and fires the transport", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
fs.mkdirSync("newdir", { recursive: true });
expect(deps.store.get("newdir")).toEqual({ type: "directory" });
expect(deps.transport.mkdir).toHaveBeenCalledWith("newdir", true);
});
it("rmSync deletes from the cache and fires the transport", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
const key = resolvePath("gone.md");
deps.store.set(key, { type: "file" });
fs.rmSync("gone.md", { recursive: true });
expect(deps.store.has(key)).toBe(false);
expect(deps.transport.rm).toHaveBeenCalled();
});
it("renameSync moves cache metadata and fires the transport", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
const from = resolvePath("a.md");
const to = resolvePath("b.md");
deps.store.set(from, { type: "file", size: 2 });
fs.renameSync("a.md", "b.md");
expect(deps.store.has(from)).toBe(false);
expect(deps.store.get(to)).toEqual({ type: "file", size: 2 });
expect(deps.transport.rename).toHaveBeenCalled();
});
it("copyFileSync optimistically mirrors source metadata and fires the transport", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
const srcKey = resolvePath("src.md");
const destKey = resolvePath("dest.md");
deps.store.set(srcKey, { type: "file", size: 9 });
fs.copyFileSync("src.md", "dest.md");
expect(deps.store.get(destKey)).toEqual({ type: "file", size: 9 });
expect(deps.transport.copyFile).toHaveBeenCalled();
});
it("utimesSync sets mtime and fires the transport", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
const key = resolvePath("note.md");
deps.store.set(key, { type: "file", mtime: 0 });
fs.utimesSync("note.md", 111, 222);
expect(deps.store.get(key).mtime).toBe(222);
expect(deps.transport.utimes).toHaveBeenCalled();
});
it("chmodSync is a no-op that does not throw", () => {
const deps = makeDeps();
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
expect(() => fs.chmodSync("note.md", 0o644)).not.toThrow();
expect(fs.chmodSync("note.md", 0o644)).toBeUndefined();
});
});

View File

@@ -180,5 +180,147 @@ export function createFsSync(metadataCache, contentCache, transport) {
const entries = metadataCache.readdir(path);
return entries.map((e) => e.name);
},
lstatSync(path) {
// No symlinks in our context.
return this.statSync(path);
},
mkdirSync(path, options) {
const recursive =
typeof options === "object" ? !!options.recursive : !!options;
markLocalOp(path);
metadataCache.set(path, { type: "directory" });
transport.mkdir(path, recursive).catch((e) => {
console.error("[shim:fs] mkdirSync background create failed:", path, e);
});
},
rmdirSync(path) {
markLocalOp(path);
metadataCache.delete(path);
transport.rmdir(path).catch((e) => {
console.error("[shim:fs] rmdirSync background remove failed:", path, e);
});
},
rmSync(path, options) {
const recursive =
typeof options === "object" ? !!options.recursive : false;
const resolved = resolvePath(path);
markLocalOp(resolved);
metadataCache.delete(resolved);
contentCache.delete(resolved);
transport.rm(resolved, recursive).catch((e) => {
console.error(
"[shim:fs] rmSync background remove failed:",
resolved,
e,
);
});
},
renameSync(oldPath, newPath) {
const resolvedOld = resolvePath(oldPath);
const resolvedNew = resolvePath(newPath);
markLocalOp(resolvedOld);
markLocalOp(resolvedNew);
const content = contentCache.get(resolvedOld);
if (content !== null) {
contentCache.set(resolvedNew, content);
contentCache.delete(resolvedOld);
}
metadataCache.rename(resolvedOld, resolvedNew);
transport.rename(resolvedOld, resolvedNew).catch((e) => {
console.error(
"[shim:fs] renameSync background rename failed:",
resolvedOld,
e,
);
});
},
copyFileSync(src, dest) {
const resolvedSrc = resolvePath(src);
const resolvedDest = resolvePath(dest);
markLocalOp(resolvedDest);
// Optimistically mirror the source so a sync read right after sees it.
const content = contentCache.get(resolvedSrc);
if (content !== null) {
contentCache.set(resolvedDest, content);
}
const srcMeta = metadataCache.get(resolvedSrc);
if (srcMeta) {
metadataCache.set(resolvedDest, { ...srcMeta });
}
transport
.copyFile(src, resolvedDest)
.then(() => transport.stat(resolvedDest))
.then((meta) => metadataCache.set(resolvedDest, meta))
.catch((e) => {
console.error(
"[shim:fs] copyFileSync background copy failed:",
resolvedDest,
e,
);
});
},
appendFileSync(path, data) {
const resolved = resolvePath(path);
markLocalOp(resolved);
contentCache.invalidate(resolved);
transport
.appendFile(resolved, data)
.then(() => transport.stat(resolved))
.then((meta) => metadataCache.set(resolved, meta))
.catch((e) => {
console.error(
"[shim:fs] appendFileSync background append failed:",
resolved,
e,
);
});
},
utimesSync(path, atime, mtime) {
const resolved = resolvePath(path);
const meta = metadataCache.get(resolved);
if (meta) {
meta.mtime = typeof mtime === "number" ? mtime : mtime.getTime();
metadataCache.set(resolved, meta);
}
transport.utimes(resolved, atime, mtime).catch((e) => {
console.error(
"[shim:fs] utimesSync background utimes failed:",
resolved,
e,
);
});
},
chmodSync() {
// The vault FS does not model permission bits. No-op.
},
};
}

View File

@@ -228,7 +228,12 @@ function installContextMenuFix() {
);
}
function installGlobalAlias() {
window.global = window;
}
export function installGlobals() {
installGlobalAlias();
installProcess();
installBuffer();
installFetchShim();

View File

@@ -0,0 +1,82 @@
class AssertionError extends Error {
constructor(message) {
super(message || "Assertion failed");
this.name = "AssertionError";
}
}
function assert(value, message) {
if (!value) {
throw new AssertionError(message);
}
}
assert.AssertionError = AssertionError;
assert.ok = assert;
assert.strict = assert;
assert.fail = function (message) {
throw new AssertionError(message || "Failed");
};
assert.equal = function (actual, expected, message) {
if (actual != expected) {
throw new AssertionError(message || `${actual} == ${expected}`);
}
};
assert.notEqual = function (actual, expected, message) {
if (actual == expected) {
throw new AssertionError(message || `${actual} != ${expected}`);
}
};
assert.strictEqual = function (actual, expected, message) {
if (actual !== expected) {
throw new AssertionError(message || `${actual} === ${expected}`);
}
};
assert.notStrictEqual = function (actual, expected, message) {
if (actual === expected) {
throw new AssertionError(message || `${actual} !== ${expected}`);
}
};
assert.deepEqual = function (actual, expected, message) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new AssertionError(message || "deepEqual");
}
};
assert.deepStrictEqual = assert.deepEqual;
assert.throws = function (fn, message) {
let threw = false;
try {
fn();
} catch {
threw = true;
}
if (!threw) {
throw new AssertionError(message || "Missing expected exception");
}
};
assert.doesNotThrow = function (fn, message) {
try {
fn();
} catch (e) {
throw new AssertionError(message || `Got unwanted exception: ${e.message}`);
}
};
assert.ifError = function (value) {
if (value) {
throw new AssertionError(`ifError got unwanted exception: ${value}`);
}
};
export const assertShim = assert;

View File

@@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest";
import { assertShim as assert } from "./assert.js";
describe("assert shim", () => {
it("is callable and throws on a falsy value", () => {
expect(() => assert(false)).toThrow();
expect(() => assert(true)).not.toThrow();
});
it("equal throws on mismatch and passes on loose match", () => {
expect(() => assert.equal(1, 2)).toThrow();
expect(() => assert.equal(1, 1)).not.toThrow();
expect(() => assert.equal(1, "1")).not.toThrow();
});
it("strictEqual distinguishes type", () => {
expect(() => assert.strictEqual(1, "1")).toThrow();
expect(() => assert.strictEqual(1, 1)).not.toThrow();
});
it("throws() verifies that a function threw", () => {
expect(() =>
assert.throws(() => {
throw new Error("x");
}),
).not.toThrow();
expect(() => assert.throws(() => {})).toThrow();
});
});

View File

@@ -0,0 +1,50 @@
// Linux constant values, to match the platform the process shim reports.
// O_SYMLINK and other macOS/BSD flags are omitted so feature checks treat platform as linux
export const constantsShim = {
// File access checks (fs.access mode).
F_OK: 0,
X_OK: 1,
W_OK: 2,
R_OK: 4,
// open() flags.
O_RDONLY: 0,
O_WRONLY: 1,
O_RDWR: 2,
O_CREAT: 64,
O_EXCL: 128,
O_NOCTTY: 256,
O_TRUNC: 512,
O_APPEND: 1024,
O_DIRECTORY: 65536,
O_NOATIME: 262144,
O_NOFOLLOW: 131072,
O_SYNC: 1052672,
O_DSYNC: 4096,
O_NONBLOCK: 2048,
// File type bits (st_mode & S_IFMT).
S_IFMT: 61440,
S_IFREG: 32768,
S_IFDIR: 16384,
S_IFCHR: 8192,
S_IFBLK: 24576,
S_IFIFO: 4096,
S_IFLNK: 40960,
S_IFSOCK: 49152,
// Permission bits.
S_IRWXU: 448,
S_IRUSR: 256,
S_IWUSR: 128,
S_IXUSR: 64,
S_IRWXG: 56,
S_IRGRP: 32,
S_IWGRP: 16,
S_IXGRP: 8,
S_IRWXO: 7,
S_IROTH: 4,
S_IWOTH: 2,
S_IXOTH: 1,
};

View File

@@ -0,0 +1,85 @@
import { EventEmitter } from "./events.js";
let warned = false;
function warnNoDataFlow(method) {
if (warned) {
return;
}
warned = true;
console.warn(
`[shim:stream] ${method}() called, but stream data flow is not implemented. ` +
"This plugin needs the full stream shim.",
);
}
export class Stream extends EventEmitter {
pipe(destination) {
warnNoDataFlow("pipe");
return destination;
}
}
export class Readable extends Stream {
constructor(options) {
super();
this.readable = true;
this._readableState = { options: options || {} };
}
read() {
warnNoDataFlow("read");
return null;
}
push() {
warnNoDataFlow("push");
return false;
}
_read() {}
}
export class Writable extends Stream {
constructor(options) {
super();
this.writable = true;
this._writableState = { options: options || {} };
}
write() {
warnNoDataFlow("write");
return false;
}
end() {
warnNoDataFlow("end");
return this;
}
_write() {}
}
export class Duplex extends Readable {
constructor(options) {
super(options);
this.writable = true;
}
write() {
warnNoDataFlow("write");
return false;
}
end() {
warnNoDataFlow("end");
return this;
}
}
export class Transform extends Duplex {
_transform() {}
}
export class PassThrough extends Transform {}

View File

@@ -0,0 +1,16 @@
import { describe, it, expect, vi } from "vitest";
import { Readable, Writable } from "./stream.js";
describe("stream shim", () => {
it("warns once when data-flow methods are used", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
new Readable().read();
new Writable().write("x");
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain("[shim:stream]");
warn.mockRestore();
});
});

View File

@@ -1,5 +1,6 @@
export const processShim = {
platform: "linux",
version: "v18.18.0",
versions: {
electron: "28.2.3",
node: "18.18.0",

View File

@@ -11,6 +11,9 @@ import * as netShim from "./node/net.js";
import * as httpShim from "./node/http.js";
import * as zlibShim from "./node/zlib.js";
import * as utilShim from "./node/util.js";
import { constantsShim } from "./node/constants.js";
import { assertShim } from "./node/assert.js";
import * as streamShim from "./node/stream.js";
import { wrapWithProxy, installDebugHelpers } from "./debug.js";
const rawRegistry = {
@@ -29,6 +32,9 @@ const rawRegistry = {
https: httpShim,
zlib: zlibShim,
util: utilShim,
constants: constantsShim,
assert: assertShim,
stream: streamShim,
};
const shimRegistry = {};