| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Exit from "effect/Exit"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Path from "effect/Path"; |
| 5 | import * as YAML from "yaml"; |
| 6 | |
| 7 | /** |
| 8 | * Resolve `catalog:` dependency specifiers in a cloned fixture's |
| 9 | * `package.json` to the concrete versions pinned in the workspace's |
| 10 | * `pnpm-workspace.yaml`. Fixtures keep `catalog:` refs so versions stay |
| 11 | * single-sourced, but a clone that installs OUTSIDE the workspace (e.g. |
| 12 | * the Nextjs fixture, cloned to the OS temp dir so OpenNext's output |
| 13 | * tracing sees a plain node_modules tree) has no workspace context — |
| 14 | * `bun install` fails on every `catalog:` ref without this rewrite. |
| 15 | */ |
| 16 | const resolveCatalogSpecifiers = Effect.fn(function* (dir: string) { |
| 17 | const fs = yield* FileSystem.FileSystem; |
| 18 | const path = yield* Path.Path; |
| 19 | const manifestPath = path.join(dir, "package.json"); |
| 20 | if (!(yield* fs.exists(manifestPath))) { |
| 21 | return; |
| 22 | } |
| 23 | // Fixture.ts lives at packages/alchemy/test/Cloudflare/Utils/ — the |
| 24 | // workspace root (and its pnpm-workspace.yaml) is five levels up. |
| 25 | const workspaceYaml = path.join( |
| 26 | import.meta.dirname, |
| 27 | "../../../../..", |
| 28 | "pnpm-workspace.yaml", |
| 29 | ); |
| 30 | const workspace = YAML.parse(yield* fs.readFileString(workspaceYaml)) as { |
| 31 | catalog?: Record<string, string>; |
| 32 | catalogs?: Record<string, Record<string, string>>; |
| 33 | }; |
| 34 | const manifest = JSON.parse(yield* fs.readFileString(manifestPath)) as { |
| 35 | [section: string]: unknown; |
| 36 | }; |
| 37 | let changed = false; |
| 38 | for (const section of [ |
| 39 | "dependencies", |
| 40 | "devDependencies", |
| 41 | "peerDependencies", |
| 42 | "optionalDependencies", |
| 43 | ]) { |
| 44 | const deps = manifest[section] as Record<string, string> | undefined; |
| 45 | if (!deps) continue; |
| 46 | for (const [name, spec] of Object.entries(deps)) { |
| 47 | if (!spec.startsWith("catalog:")) continue; |
| 48 | const catalogName = spec.slice("catalog:".length); |
| 49 | const version = |
| 50 | catalogName === "" |
| 51 | ? workspace.catalog?.[name] |
| 52 | : workspace.catalogs?.[catalogName]?.[name]; |
| 53 | if (version === undefined) { |
| 54 | return yield* Effect.fail( |
| 55 | new Error( |
| 56 | `cloneFixture: ${name} uses "${spec}" but pnpm-workspace.yaml has no such catalog entry`, |
| 57 | ), |
| 58 | ); |
| 59 | } |
| 60 | deps[name] = version; |
| 61 | changed = true; |
| 62 | } |
| 63 | } |
| 64 | if (changed) { |
| 65 | yield* fs.writeFileString( |
| 66 | manifestPath, |
| 67 | `${JSON.stringify(manifest, null, 2)}\n`, |
| 68 | ); |
| 69 | } |
| 70 | }); |
| 71 | |
| 72 | /** |
| 73 | * Recursively copy `sourceDir` into a fresh `fs.makeTempDirectory` |
| 74 | * (with `prefix`), preserving file modes. Returns the absolute path of |
| 75 | * the new temp directory. |
| 76 | * |
| 77 | * Used by tests that want to mutate a fixture's `src/` files without |
| 78 | * polluting the source-controlled fixture or racing with other tests |
| 79 | * sharing the same fixture path. |
| 80 | * |
| 81 | * - `tempRoot`, when provided, places the temp dir under a specific |
| 82 | * parent directory instead of the OS temp root. This matters for |
| 83 | * Vite tests because Vite's `vite:build-html` plugin can't express |
| 84 | * project roots that sit outside the current working directory; an |
| 85 | * under-workspace temp dir keeps the relative path representable. |
| 86 | * - `entries`, when provided, restricts the copy to a specific subset |
| 87 | * of top-level entries. Defaults to copying everything in |
| 88 | * `sourceDir`. |
| 89 | */ |
| 90 | export const cloneFixture = Effect.fn(function* ( |
| 91 | sourceDir: string, |
| 92 | options: { |
| 93 | prefix: string; |
| 94 | tempRoot?: string; |
| 95 | entries?: string[]; |
| 96 | }, |
| 97 | ) { |
| 98 | const fs = yield* FileSystem.FileSystem; |
| 99 | const path = yield* Path.Path; |
| 100 | |
| 101 | if (options.tempRoot) { |
| 102 | yield* fs.makeDirectory(options.tempRoot, { recursive: true }); |
| 103 | } |
| 104 | // Realpath the temp dir: macOS temp roots are symlinks (/tmp, /var/folders |
| 105 | // → /private/...), and file watchers (Turbopack HMR) report events under |
| 106 | // the real path — a symlinked project root never sees invalidations. |
| 107 | const dir = yield* fs.realPath( |
| 108 | yield* fs.makeTempDirectory({ |
| 109 | prefix: options.prefix, |
| 110 | directory: options.tempRoot, |
| 111 | }), |
| 112 | ); |
| 113 | |
| 114 | const entries = options.entries ?? (yield* fs.readDirectory(sourceDir)); |
| 115 | |
| 116 | const copyTree = (relativePath: string): Effect.Effect<void, any, any> => |
| 117 | Effect.gen(function* () { |
| 118 | const from = path.join(sourceDir, relativePath); |
| 119 | const to = path.join(dir, relativePath); |
| 120 | const stat = yield* fs.stat(from); |
| 121 | if (stat.type === "Directory") { |
| 122 | yield* fs.makeDirectory(to, { recursive: true }); |
| 123 | const children = yield* fs.readDirectory(from); |
| 124 | for (const child of children) { |
| 125 | yield* copyTree(path.join(relativePath, child)); |
| 126 | } |
| 127 | } else { |
| 128 | const contents = yield* fs.readFile(from); |
| 129 | yield* fs.writeFile(to, contents); |
| 130 | // Preserve executable bit so copied build scripts still run. |
| 131 | const mode = Number(stat.mode); |
| 132 | if (mode & 0o111) { |
| 133 | yield* fs.chmod(to, mode); |
| 134 | } |
| 135 | } |
| 136 | }); |
| 137 | |
| 138 | for (const entry of entries) { |
| 139 | yield* copyTree(entry); |
| 140 | } |
| 141 | |
| 142 | yield* resolveCatalogSpecifiers(dir); |
| 143 | |
| 144 | yield* Effect.addFinalizer( |
| 145 | Exit.match({ |
| 146 | onSuccess: () => Effect.ignore(fs.remove(dir, { recursive: true })), |
| 147 | onFailure: () => Effect.void, |
| 148 | }), |
| 149 | ); |
| 150 | |
| 151 | return dir; |
| 152 | }); |