Files
outline/server/utils/fs.ts
T
Tom Moor 091346dfe8 chore: Migrate to vitest (#12272)
* wip

* Remove obsolete snapshots

* simplify

* chore(test): Convert mocks to TypeScript and tighten fetch mock types

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Remove unneccessary patches

* Migrate to msw instead of custom fetch mock

* Address PR review comments

- Split chained vi.useFakeTimers().setSystemTime() into separate calls.
- Switch test setup to dynamic imports so EventEmitter.defaultMaxListeners
  assignment runs before module init (static imports were hoisted above it).
- Drop redundant NODE_ENV guard in monkeyPatchSequelizeErrorsForJest; its
  sole caller already gates on env.isTest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 21:10:51 -04:00

164 lines
4.8 KiB
TypeScript

import path from "node:path";
import fs from "fs-extra";
/**
* Serialize a file name for inclusion in a ZIP.
*
* @param text The file name to serialize.
* @returns The serialized file name.
*/
export function serializeFilename(text: string): string {
return text.replace(/\//g, "%2F").replace(/\\/g, "%5C");
}
/**
* Deserialize a file name serialized with `serializeFilename`.
*
* @param text The file name to deserialize.
* @returns The deserialized file name.
*/
export function deserializeFilename(text: string): string {
return text.replace(/%2F/g, "/").replace(/%5C/g, "\\");
}
/**
* Get the UTF8 byte length of a string.
*
* @param str The string to measure.
* @returns The byte length of the string.
*/
export function stringByteLength(str: string): number {
return Buffer.byteLength(str, "utf8");
}
/**
* Safely slice a string to a maximum byte length without breaking UTF-8 characters.
*
* @param str The string to slice.
* @param maxBytes The maximum byte length.
* @returns The sliced string.
*/
function sliceStringToByteLength(str: string, maxBytes: number): string {
if (maxBytes <= 0) {
return "";
}
const buffer = Buffer.from(str, "utf8");
if (buffer.length <= maxBytes) {
return str;
}
// Work backwards from maxBytes to find valid UTF-8 boundary
for (let i = maxBytes; i > 0; i--) {
const slice = buffer.subarray(0, i);
const result = slice.toString("utf8");
// Check if the result round-trips correctly (no replacement characters)
if (Buffer.from(result, "utf8").equals(slice)) {
return result;
}
}
return "";
}
/**
* Trim a file name to a maximum length, retaining the extension. The input
* must be a filename only — passing a path (containing `/` or `\`) will throw.
*
* @param text The file name to trim.
* @param length The maximum length of the file name in bytes.
* @returns The trimmed file name.
* @throws If `text` contains a path separator.
*/
export function trimFilenameAndExt(text: string, length: number): string {
if (text.includes("/") || text.includes("\\")) {
throw new Error(
"trimFilenameAndExt expects a filename without path separators"
);
}
if (Buffer.byteLength(text, "utf8") > length) {
const ext = path.extname(text);
const name = path.basename(text, ext);
const extByteLength = Buffer.byteLength(ext, "utf8");
const availableBytesForName = length - extByteLength;
if (availableBytesForName <= 0) {
// If extension is too long, trim the whole filename
return sliceStringToByteLength(text, length);
}
const trimmedName = sliceStringToByteLength(name, availableBytesForName);
return trimmedName + ext;
}
return text;
}
/**
* Get a list of file names in a directory.
*
* @param dirName The directory to search.
* @returns A list of file names in the directory.
*/
export function getFilenamesInDirectory(dirName: string): string[] {
return fs
.readdirSync(dirName)
.filter(
(file) =>
file.indexOf(".") !== 0 &&
file.match(/\.[jt]s$/) &&
file !== path.basename(__filename) &&
!file.includes(".test")
);
}
// Optional cache used in tests, where Node's require() cannot resolve
// TypeScript files with aliased imports. Populated by the test setup with
// modules pre-loaded via Vite's import.meta.glob, keyed by directory suffix.
const requireDirectoryCache = new Map<string, Record<string, unknown>>();
/**
* Pre-populate requireDirectory's module cache. Intended for use only by the
* Vitest test setup; production code should not call this.
*
* @param suffix The directory path suffix to match against.
* @param modules The eagerly-loaded modules.
*/
export function __setRequireDirectoryCache(
suffix: string,
modules: Record<string, unknown>
): void {
requireDirectoryCache.set(suffix, modules);
}
/**
* Require all files in a directory and return them as an array of tuples.
*
* @param dirName The directory to search.
* @returns An array of tuples containing the required files and their names.
*/
export function requireDirectory<T>(dirName: string): [T, string][] {
for (const [suffix, modules] of requireDirectoryCache) {
if (dirName.endsWith(suffix)) {
return Object.entries(modules)
.filter(
([filePath]) =>
!filePath.endsWith("/index.ts") &&
!filePath.endsWith("/index.js") &&
!filePath.includes(".test.")
)
.map(([filePath, mod]) => {
const base = filePath.split("/").pop() ?? filePath;
const id = base.replace(/\.[jt]s$/, "");
return [mod as T, id];
});
}
}
return getFilenamesInDirectory(dirName).map((fileName) => {
const filePath = path.join(dirName, fileName);
const name = path.basename(filePath.replace(/\.[jt]s$/, ""));
return [require(filePath), name];
});
}