diff --git a/README.md b/README.md index e85624a..7fe81d3 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,37 @@ Effect.runPromise( ) ``` +Snapshot a real directory tree into a fresh in-memory layer. Files, empty +directories, symlinks, file mode, owner, and access/modification times are +all replicated. The caller provides the `FileSystem.FileSystem` service used +for the walk, so the source can live on Node, Bun, Deno, or any other +Effect-TS `FileSystem` backend. + +```ts +import { NodeFileSystem } from "@effect/platform-node-shared" +import { Effect, FileSystem } from "effect" +import { layerFromPath } from "@lucas-bur/effect-memfs" + +const program = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + return yield* fs.readFileString("/data/fixtures/test.json") +}).pipe( + Effect.provide(layerFromPath("/data/fixtures")), + Effect.provide(NodeFileSystem.layer), +) +``` + +The source path is mounted at the same location in the in-memory +filesystem by default; pass `{ mountAt: "/some/other/path" }` to remap. + +## Roadmap + +- [ ] **1:1 parity with Node's filesystem** — `@lucas-bur/effect-memfs` must be a + drop-in replacement for `NodeFileSystem`: identical behavior, error tags, and + edge cases. Enforced by `test/file-system-suite.test.ts`, which runs the same + suite against both backends. Any divergence is a bug to fix, not a quirk to + document. Pinned via the `TODO(1:1-parity)` marker in `src/index.ts`. + ## License MIT diff --git a/playground/scratch.ts b/playground/scratch.ts new file mode 100644 index 0000000..2b532b0 --- /dev/null +++ b/playground/scratch.ts @@ -0,0 +1,52 @@ +import { NodeFileSystem, NodeRuntime } from "@effect/platform-node-shared" +import { Console, Effect, FileSystem } from "effect" + +import { layerFromPath } from "../src/index.js" + +// it.effect("scratch: write + read roundtrip", () => +// Effect.gen(function* () { +// const fs = yield* FileSystem.FileSystem + +// yield* fs.makeDirectory("/scratch") +// yield* fs.writeFileString("/scratch/hello.txt", "world") +// const content = yield* fs.readFileString("/scratch/hello.txt") +// yield* Console.log(`read back: ${content}`) + +// expect(content).toBe("world") +// }).pipe(Effect.provide(layer())), +// ) + +// it.effect("scratch: layer pre-populates files", () => +// Effect.gen(function* () { +// const fs = yield* FileSystem.FileSystem + +// const a = yield* fs.readFileString("/a.txt") +// const b = yield* fs.readFileString("/nested/b.txt") + +// expect(a).toBe("A") +// expect(b).toBe("B") +// }).pipe( +// Effect.provide( +// layer({ +// "/a.txt": "A", +// "/nested/b.txt": "B", +// }), +// ), +// ), +// ) + +const main = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const dir = yield* fs.readDirectory("/", { recursive: true }) + yield* Console.log(dir) + for (let i = 0; i < dir.length; i++) { + yield* Console.log(yield* fs.stat("/" + dir[i])) + } + + // yield* Console.log(fs) +}).pipe( + Effect.provide(layerFromPath("./test/../test", { mountAt: "app" })), + Effect.provide(NodeFileSystem.layer), +) + +NodeRuntime.runMain(main) diff --git a/src/helpers/volume.ts b/src/helpers/volume.ts index 28f87c8..46cc3be 100644 --- a/src/helpers/volume.ts +++ b/src/helpers/volume.ts @@ -1,8 +1,155 @@ +import { Effect, Option, type FileSystem, type PlatformError } from "effect" import { Volume } from "memfs" +import { fromSync } from "./promise.js" + export interface FileTree { [key: string]: string | null | FileTree } export const makeVol = (initialFiles: FileTree = {}): Volume => Volume.fromNestedJSON(initialFiles, "/") + +const copyMode = (vol: Volume, dst: string, info: FileSystem.File.Info, isLink: boolean): void => { + const mode = info.mode & 0o777 + if (isLink) { + vol.lchmodSync(dst, mode) + } else { + vol.chmodSync(dst, mode) + } +} + +const copyOwner = (vol: Volume, dst: string, info: FileSystem.File.Info, isLink: boolean): void => { + const uid = Option.getOrUndefined(info.uid) + const gid = Option.getOrUndefined(info.gid) + if (uid === undefined || gid === undefined) return + if (isLink) { + vol.lchownSync(dst, uid, gid) + } else { + vol.chownSync(dst, uid, gid) + } +} + +const copyTimestamps = ( + vol: Volume, + dst: string, + info: FileSystem.File.Info, + isLink: boolean, +): void => { + const atime = Option.getOrUndefined(info.atime) + const mtime = Option.getOrUndefined(info.mtime) + if (atime === undefined || mtime === undefined) return + if (isLink) { + vol.lutimesSync(dst, atime, mtime) + } else { + vol.utimesSync(dst, atime, mtime) + } +} + +const toUnix = (p: string): string => p.replace(/\\/g, "/") + +// memfs resolves relative paths against `process.cwd()`, which would land +// the in-memory tree under the OS working directory. Force a leading `/` +// so a bare relative path (e.g. `mountAt: "app"`) is treated as absolute +// inside the isolated Volume. Paths that are already absolute — either +// POSIX (`/foo`) or Windows with a drive letter (`C:/foo`) — are kept as-is +// because the extra `/` would corrupt the drive-letter form on Windows +// (`path.resolve("/C:/foo")` becomes `E:\C:\foo`). +const toVolumePath = (p: string): string => { + const normalized = toUnix(p) + if (normalized.startsWith("/")) return normalized + if (/^[A-Za-z]:/.test(normalized)) return normalized + return "/" + normalized +} + +const populate = ( + vol: Volume, + fs: FileSystem.FileSystem, + src: string, + dst: string, +): Effect.Effect => + Effect.gen(function* () { + // Detect symlinks first: readLink only succeeds on actual links, + // so we use that as the discriminator instead of stat (which follows). + const linkOpt = yield* Effect.option(fs.readLink(src)) + if (Option.isSome(linkOpt)) { + yield* fromSync(() => vol.symlinkSync(linkOpt.value, dst), "symlink", dst) + // Apply the link's own metadata via l* variants so we never follow. + const stat = yield* Effect.option(fs.stat(src)) + if (Option.isSome(stat)) { + copyMode(vol, dst, stat.value, true) + copyOwner(vol, dst, stat.value, true) + copyTimestamps(vol, dst, stat.value, true) + } + return + } + + const stat = yield* fs.stat(src) + + if (stat.type === "Directory") { + // Always grant the user write+execute bits so the recursive mkdir + // chain stays traversable AND children can be written. Some + // platforms (notably Windows) report `0o444`/`0o666` for read-only + // directories (e.g. `.git/objects/...`), which makes memfs reject + // child writes with EACCES without this. + const dirMode = (stat.mode & 0o777) | 0o300 + yield* fromSync(() => vol.mkdirSync(dst, { mode: dirMode, recursive: true }), "mkdir", dst) + copyOwner(vol, dst, stat, false) + copyTimestamps(vol, dst, stat, false) + const children = yield* fs.readDirectory(src) + for (const child of children) { + const childSrc = src.endsWith("/") ? src + child : src + "/" + child + const childDst = dst.endsWith("/") ? dst + child : dst + "/" + child + yield* populate(vol, fs, childSrc, childDst) + } + return + } + + if (stat.type === "File") { + const content = yield* fs.readFile(src) + // memfs rejects the post-create `open` for write on a file that was + // just created with a read-only mode (e.g. `0o444` for git objects). + // Write with a writable mode first, then restore the source mode. + yield* fromSync(() => vol.writeFileSync(dst, content, { mode: 0o666 }), "writeFile", dst) + yield* fromSync(() => vol.chmodSync(dst, stat.mode & 0o777), "chmod", dst) + copyOwner(vol, dst, stat, false) + copyTimestamps(vol, dst, stat, false) + return + } + + // BlockDevice, FIFO, Socket, etc. — memfs can represent them but + // replicating their content/behavior is rarely useful in tests. + // Silently skip rather than fail loudly; the user can layer in + // custom handling later if needed. + }) + +/** + * Build a `Volume` that exactly mirrors a path in another `FileSystem`. + * + * Walks the source recursively and reproduces every entry — files, empty directories, and symlinks + * — into the returned `Volume`. File mode, owner, and access/modification times are copied for + * every entry; symlinks are preserved (not followed) and their own metadata is set via the `l*` + * variants of the `Volume` API. + * + * The source path is mounted at the same path inside the Volume, so a source at `/data/fixtures` + * lands at `/data/fixtures` in the resulting in-memory filesystem. To remap, use the `mountAt` + * option. + * + * The `FileSystem` service is supplied by the caller, so this works with Node's filesystem, Bun's, + * an in-memory mock, or any other Effect-TS `FileSystem` implementation. + */ +export const makeVolFromFileSystem = ( + fs: FileSystem.FileSystem, + path: string, + options?: { readonly mountAt?: string }, +): Effect.Effect => + Effect.gen(function* () { + const vol = new Volume() + // Normalize to forward slashes so the source path (which may use + // OS-native separators, e.g. backslashes on Windows) lands at the + // same place inside the memfs Volume, which only speaks POSIX. + const src = toUnix(path) + const dst = toVolumePath(options?.mountAt ?? path) + yield* populate(vol, fs, src, dst) + return vol + }) diff --git a/src/index.ts b/src/index.ts index 1261112..067e5ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ -import { Effect, FileSystem, Layer } from "effect" +import { Effect, FileSystem, Layer, type PlatformError } from "effect" +import type { Volume } from "memfs" -import { makeVol, type FileTree } from "./helpers/volume.js" +import { makeVol, makeVolFromFileSystem, type FileTree } from "./helpers/volume.js" import { access } from "./operations/access.js" import { chmod } from "./operations/chmod.js" import { chown } from "./operations/chown.js" @@ -26,36 +27,75 @@ import { writeFile } from "./operations/write-file.js" export { type FileTree } -const makeFileSystem = (initialFiles: FileTree = {}): Effect.Effect => - Effect.sync(() => { - const vol = makeVol(initialFiles) - return FileSystem.make({ - access: access(vol), - chmod: chmod(vol), - chown: chown(vol), - copy: copy(vol), - copyFile: copyFile(vol), - link: link(vol), - makeDirectory: makeDirectory(vol), - makeTempDirectory: makeTempDirectory(vol), - makeTempDirectoryScoped: makeTempDirectoryScoped(vol), - makeTempFile: makeTempFile(vol), - makeTempFileScoped: makeTempFileScoped(vol), - open: open(vol), - readDirectory: readDirectory(vol), - readFile: readFile(vol), - readLink: readLink(vol), - realPath: realPath(vol), - remove: remove(vol), - rename: rename(vol), - stat: stat(vol), - symlink: symlink(vol), - truncate: truncate(vol), - utimes: utimes(vol), - watch: watch(vol), - writeFile: writeFile(vol), - }) +// TODO(1:1-parity): memfs must be 1:1 interchangeable with NodeFileSystem — same +// behavior, same error tags, same edge cases. Enforce this via the cross-validation +// suite in `test/file-system-suite.test.ts`, which runs every case against both +// backends. Any divergence is a bug, not a platform quirk; fix it and add a +// regression test that pins the corrected behavior. Tracked at the project level +// because every operation in this module depends on it. + +const makeFileSystem = (vol: Volume): FileSystem.FileSystem => + FileSystem.make({ + access: access(vol), + chmod: chmod(vol), + chown: chown(vol), + copy: copy(vol), + copyFile: copyFile(vol), + link: link(vol), + makeDirectory: makeDirectory(vol), + makeTempDirectory: makeTempDirectory(vol), + makeTempDirectoryScoped: makeTempDirectoryScoped(vol), + makeTempFile: makeTempFile(vol), + makeTempFileScoped: makeTempFileScoped(vol), + open: open(vol), + readDirectory: readDirectory(vol), + readFile: readFile(vol), + readLink: readLink(vol), + realPath: realPath(vol), + remove: remove(vol), + rename: rename(vol), + stat: stat(vol), + symlink: symlink(vol), + truncate: truncate(vol), + utimes: utimes(vol), + watch: watch(vol), + writeFile: writeFile(vol), }) export const layer = (initialFiles: FileTree = {}): Layer.Layer => - Layer.effect(FileSystem.FileSystem)(makeFileSystem(initialFiles)) + Layer.sync(FileSystem.FileSystem, () => makeFileSystem(makeVol(initialFiles))) + +/** + * Build a `FileSystem` layer pre-populated from a real directory. + * + * Walks `path` using the caller-provided `FileSystem.FileSystem` service (e.g. + * `NodeFileSystem.layer`) and copies the entire tree — files, empty directories, symlinks, plus + * mode, owner, and timestamps — into a fresh in-memory `Volume`. The in-memory `FileSystem` service + * is then built on top of that `Volume`. + * + * The source path is mounted at the same path inside the in-memory filesystem; pass `mountAt` to + * remap it. + * + * @example + * ```ts + * import { NodeFileSystem } from "@effect/platform-node-shared" + * import { Effect, FileSystem } from "effect" + * import { layerFromPath } from "@lucas-bur/effect-memfs" + * + * const program = Effect.gen(function* () { + * const fs = yield* FileSystem.FileSystem + * return yield* fs.readFileString("/data/fixtures/test.json") + * }).pipe(Effect.provide(layerFromPath("/data/fixtures")), Effect.provide(NodeFileSystem.layer)) + * ``` + */ +export const layerFromPath = ( + path: string, + options?: { readonly mountAt?: string }, +): Layer.Layer => + Layer.unwrap( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const vol = yield* makeVolFromFileSystem(fs, path, options) + return Layer.succeed(FileSystem.FileSystem, makeFileSystem(vol)) + }), + ) diff --git a/src/operations/read-directory.ts b/src/operations/read-directory.ts index 3a6e01c..df00be3 100644 --- a/src/operations/read-directory.ts +++ b/src/operations/read-directory.ts @@ -3,21 +3,23 @@ import type { Volume } from "memfs" import { fromPromise } from "../helpers/promise.js" -// NOTE: memfs returns paths with a leading "/" in recursive mode -// when the root directory is read, so we normalize them. +// Match the shape of `node:fs/promises.readdir(path, { recursive: true })`: +// paths are returned relative to the input, separated by forward slashes, and +// intermediate directories are included. memfs returns relative paths only +// when the input has no trailing slash, so we strip one (keeping the root `/`). +// For root itself, memfs returns absolute paths — we drop the leading `/`. +const stripTrailingSlash = (p: string): string => + p === "/" ? p : p.endsWith("/") ? p.slice(0, -1) : p export const readDirectory = (vol: Volume): FileSystem.FileSystem["readDirectory"] => (path, options) => fromPromise( () => { - const dir = vol.promises.readdir(path, { + const dir = vol.promises.readdir(stripTrailingSlash(path), { recursive: options?.recursive ?? false, }) as Promise - - return options?.recursive - ? dir.then((entries) => entries.map((e) => (e.startsWith("/") ? e.slice(1) : e))) - : dir + return dir.then((entries) => entries.map((e) => (e.startsWith("/") ? e.slice(1) : e))) }, "readDirectory", path, diff --git a/test/file-system-suite.ts b/test/file-system-suite.test.ts similarity index 78% rename from test/file-system-suite.ts rename to test/file-system-suite.test.ts index 00fda8c..b374d5d 100644 --- a/test/file-system-suite.ts +++ b/test/file-system-suite.test.ts @@ -83,6 +83,42 @@ const fileSystemSuite = (layer: Layer.Layer, name: string }).pipe(Effect.provide(layer)), ) + it.effect("readDirectory recursive returns relative paths (matches Node)", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const tmp = yield* fs.makeTempDirectoryScoped() + yield* fs.writeFileString(`${tmp}/a.txt`, "a") + yield* fs.makeDirectory(`${tmp}/sub`) + yield* fs.writeFileString(`${tmp}/sub/c.txt`, "c") + yield* fs.makeDirectory(`${tmp}/sub/deep`) + yield* fs.writeFileString(`${tmp}/sub/deep/d.txt`, "d") + yield* fs.makeDirectory(`${tmp}/empty`) + // 1:1 with node:fs — relative paths, includes directories, depth-first order. + const normalize = (e: string) => e.replace(/\\/g, "/") + const baseline = (yield* fs.readDirectory(tmp, { recursive: true })).map(normalize) + // Same call, with trailing slash — must produce the same output. + const withSlash = (yield* fs.readDirectory(`${tmp}/`, { recursive: true })).map(normalize) + expect(baseline).toStrictEqual(withSlash) + // No entry should start with `/` (these are relative paths). + for (const e of baseline) { + expect(e.startsWith("/")).toBe(false) + } + expect(baseline).toContain("a.txt") + expect(baseline).toContain("sub") + expect(baseline).toContain("sub/c.txt") + expect(baseline).toContain("sub/deep") + expect(baseline).toContain("sub/deep/d.txt") + // Recursive from a subdir is relative to that subdir. + const fromSub = (yield* fs.readDirectory(`${tmp}/sub`, { recursive: true })).map(normalize) + expect(fromSub).toContain("c.txt") + expect(fromSub).toContain("deep") + expect(fromSub).toContain("deep/d.txt") + expect(fromSub).not.toContain("sub/c.txt") + // Empty directory yields an empty list (not the dir name itself). + expect(yield* fs.readDirectory(`${tmp}/empty`, { recursive: true })).toStrictEqual([]) + }).pipe(Effect.provide(layer)), + ) + it.effect("remove deletes a file", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem diff --git a/test/from-file-system.test.ts b/test/from-file-system.test.ts new file mode 100644 index 0000000..5efcadb --- /dev/null +++ b/test/from-file-system.test.ts @@ -0,0 +1,127 @@ +import { NodeFileSystem } from "@effect/platform-node-shared" +import { it } from "@effect/vitest" +import { Effect, FileSystem } from "effect" + +import { makeVolFromFileSystem } from "../src/helpers/volume.js" +import { layerFromPath } from "../src/index.js" + +const provideNode = (effect: Effect.Effect) => + effect.pipe(Effect.provide(NodeFileSystem.layer)) + +const setupFixture = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const tmp = yield* fs.makeTempDirectoryScoped() + yield* fs.writeFileString(`${tmp}/hello.txt`, "world") + yield* fs.makeDirectory(`${tmp}/nested`) + yield* fs.writeFileString(`${tmp}/nested/data.json`, `{"x":1}`) + yield* fs.writeFileString(`${tmp}/nested/empty`, "") + return tmp +}) + +it.effect("replicates file contents", () => + Effect.gen(function* () { + const tmp = yield* setupFixture + const fs = yield* FileSystem.FileSystem + const vol = yield* makeVolFromFileSystem(fs, tmp) + expect(vol.readFileSync(`${tmp}/hello.txt`, "utf8")).toBe("world") + expect(vol.readFileSync(`${tmp}/nested/data.json`, "utf8")).toBe(`{"x":1}`) + }).pipe(provideNode), +) + +it.effect("replicates empty directories", () => + Effect.gen(function* () { + const tmp = yield* setupFixture + const fs = yield* FileSystem.FileSystem + const vol = yield* makeVolFromFileSystem(fs, tmp) + expect(vol.statSync(`${tmp}/nested`).isDirectory()).toBe(true) + expect(vol.readdirSync(`${tmp}/nested`)).toContain("data.json") + }).pipe(provideNode), +) + +it.effect("replicates symlinks without following them", () => + Effect.gen(function* () { + if (process.platform === "win32") return + const fs = yield* FileSystem.FileSystem + const tmp = yield* fs.makeTempDirectoryScoped() + yield* fs.writeFileString(`${tmp}/target.txt`, "real") + yield* fs.symlink(`${tmp}/target.txt`, `${tmp}/link.txt`) + const vol = yield* makeVolFromFileSystem(fs, tmp) + expect(vol.lstatSync(`${tmp}/link.txt`).isSymbolicLink()).toBe(true) + expect(vol.readlinkSync(`${tmp}/link.txt`)).toBe(`${tmp}/target.txt`) + }).pipe(provideNode), +) + +it.effect("replicates file mode", () => + Effect.gen(function* () { + if (process.platform === "win32") return + const fs = yield* FileSystem.FileSystem + const tmp = yield* fs.makeTempDirectoryScoped() + yield* fs.writeFileString(`${tmp}/script.sh`, "#!/bin/sh") + yield* fs.chmod(`${tmp}/script.sh`, 0o755) + const vol = yield* makeVolFromFileSystem(fs, tmp) + expect(vol.statSync(`${tmp}/script.sh`).mode & 0o777).toBe(0o755) + }).pipe(provideNode), +) + +it.effect("supports remapping with mountAt", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const tmp = yield* fs.makeTempDirectoryScoped() + yield* fs.writeFileString(`${tmp}/a.txt`, "A") + const vol = yield* makeVolFromFileSystem(fs, tmp, { mountAt: "/app" }) + expect(vol.readFileSync(`/app/a.txt`, "utf8")).toBe("A") + expect(vol.existsSync(`${tmp}/a.txt`)).toBe(false) + }).pipe(provideNode), +) + +it.effect("mountAt accepts a relative path (lands at /, not under CWD)", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const tmp = yield* fs.makeTempDirectoryScoped() + yield* fs.writeFileString(`${tmp}/a.txt`, "A") + const vol = yield* makeVolFromFileSystem(fs, tmp, { mountAt: "app" }) + expect(vol.readFileSync(`/app/a.txt`, "utf8")).toBe("A") + expect(vol.readdirSync(`/`).sort()).toStrictEqual(["app"]) + }).pipe(provideNode), +) + +it.effect("replicates into read-only source directories (e.g. .git/objects)", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const tmp = yield* fs.makeTempDirectoryScoped() + yield* fs.makeDirectory(`${tmp}/ro`) + yield* fs.writeFileString(`${tmp}/ro/loose-object`, "blob") + yield* fs.chmod(`${tmp}/ro`, 0o555) + const vol = yield* makeVolFromFileSystem(fs, tmp).pipe( + Effect.ensuring(Effect.ignore(fs.chmod(`${tmp}/ro`, 0o755))), + ) + expect(vol.readFileSync(`${tmp}/ro/loose-object`, "utf8")).toBe("blob") + }).pipe(provideNode), +) + +it.effect("replicates read-only source files (e.g. git object blobs at 0o444)", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const tmp = yield* fs.makeTempDirectoryScoped() + yield* fs.writeFileString(`${tmp}/blob`, "data") + yield* fs.chmod(`${tmp}/blob`, 0o444) + const vol = yield* makeVolFromFileSystem(fs, tmp) + expect(vol.readFileSync(`${tmp}/blob`, "utf8")).toBe("data") + if (process.platform !== "win32") { + expect(vol.statSync(`${tmp}/blob`).mode & 0o777).toBe(0o444) + } + }).pipe(provideNode), +) + +it.effect("layerFromPath exposes a working in-memory FileSystem", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const tmp = yield* fs.makeTempDirectoryScoped() + yield* fs.writeFileString(`${tmp}/greeting.txt`, "hi") + const content = yield* Effect.gen(function* () { + const memFs = yield* FileSystem.FileSystem + return yield* memFs.readFileString(`${tmp}/greeting.txt`) + }).pipe(Effect.provide(layerFromPath(tmp)), Effect.provide(NodeFileSystem.layer)) + expect(content).toBe("hi") + }).pipe(provideNode), +) diff --git a/tsconfig.json b/tsconfig.json index 8e7f14d..06f20f7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,5 +9,5 @@ "verbatimModuleSyntax": true, "types": ["vitest/globals"] }, - "include": ["src", "test"] + "include": ["src", "test", "playground"] } diff --git a/vitest.config.ts b/vitest.config.ts index 2db4675..fa0c166 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "vitest/config" export default defineConfig({ test: { - include: ["test/**/*.test.ts"], + include: ["test/**/*.test.ts", "playground/**/*.test.ts"], globals: true, coverage: { provider: "v8",