From 6fd43e6c3b47ce8476b8f152b85de06eee4bacf7 Mon Sep 17 00:00:00 2001 From: "Michael (Pear)" Date: Fri, 12 Jun 2026 00:23:32 -0700 Subject: [PATCH] chore(test): fix Windows test failures and eager Cloudflare credential resolution --- packages/alchemy/src/Bundle/PurePlugin.ts | 24 +++++++++++++ .../src/Cloudflare/Auth/AuthProvider.ts | 8 +++++ .../alchemy/src/Cloudflare/Gateway/Rule.ts | 11 ++++-- .../src/Cloudflare/Tunnel/Configuration.ts | 7 +++- packages/alchemy/src/Drizzle/Schema.ts | 14 +++++--- .../alchemy/test/AWS/Lambda/Function.test.ts | 8 +++-- packages/alchemy/test/Build/Build.test.ts | 4 ++- .../alchemy/test/Bundle/PurePlugin.test.ts | 10 +++++- packages/alchemy/test/Local/RpcServer.test.ts | 8 +++-- .../test/Local/fixtures/process-effect.ts | 35 +++++++++++++------ 10 files changed, 105 insertions(+), 24 deletions(-) diff --git a/packages/alchemy/src/Bundle/PurePlugin.ts b/packages/alchemy/src/Bundle/PurePlugin.ts index f1fa2b184..0a3bf4646 100644 --- a/packages/alchemy/src/Bundle/PurePlugin.ts +++ b/packages/alchemy/src/Bundle/PurePlugin.ts @@ -281,6 +281,21 @@ export function packageNameFromId(id: string): string | null { return parts[0]; } +/** + * Returns the directory of the `node_modules/` root that owns `id`, + * or `null` when the id has no `node_modules/` segment. Preserves the + * id's original separators so it compares cleanly against + * `path.dirname` results. + */ +function packageRootFromId(id: string): string | null { + const name = packageNameFromId(id); + if (name === null) return null; + const normalized = id.replace(/\\/g, "/"); + const idx = normalized.lastIndexOf("/node_modules/"); + // `replace` is char-for-char, so indices line up with the original id. + return id.slice(0, idx + "/node_modules/".length + name.length); +} + /** * Resolves the owning {@link PackageInfo} for a module id by walking up * the directory tree to the nearest `package.json`. This is what makes @@ -303,6 +318,12 @@ export async function resolvePackageInfo( // the file is unreadable (or this is a virtual id from a test) we fall // back to a minimal record using the path-derived name. const fastName = packageNameFromId(cleanId); + // For `node_modules//...` ids, never walk above the package's own + // root — any `package.json` above `node_modules` belongs to the + // consumer (or, on Windows, a stray drive-root manifest), not to this + // module's package. + const stopDir = packageRootFromId(cleanId); + const normalizedStop = stopDir?.replace(/\\/g, "/") ?? null; let dir = path.dirname(cleanId); // Remember dirs we visited for this lookup so we can backfill their @@ -343,6 +364,9 @@ export async function resolvePackageInfo( foundInfo = info; break; } + if (normalizedStop !== null && dir.replace(/\\/g, "/") === normalizedStop) { + break; + } const parent = path.dirname(dir); if (parent === dir) break; dir = parent; diff --git a/packages/alchemy/src/Cloudflare/Auth/AuthProvider.ts b/packages/alchemy/src/Cloudflare/Auth/AuthProvider.ts index 253c1c474..afbbb9efc 100644 --- a/packages/alchemy/src/Cloudflare/Auth/AuthProvider.ts +++ b/packages/alchemy/src/Cloudflare/Auth/AuthProvider.ts @@ -685,6 +685,14 @@ export const DEFAULT_SCOPES = [ "connectivity:admin", "containers:write", "d1:write", + // DnsRecord + Tunnel/Gateway (Zero Trust) resources are managed by + // alchemy; without these a default oauth login fails their API calls + // with Unauthorized. (Zone *create* has no oauth scope at all — only + // an API token can grant it.) + "dns_records:read", + "dns_records:edit", + "teams:read", + "teams:write", "pages:write", "pipelines:write", "queues:write", diff --git a/packages/alchemy/src/Cloudflare/Gateway/Rule.ts b/packages/alchemy/src/Cloudflare/Gateway/Rule.ts index 51a7f2bca..dcae64a86 100644 --- a/packages/alchemy/src/Cloudflare/Gateway/Rule.ts +++ b/packages/alchemy/src/Cloudflare/Gateway/Rule.ts @@ -208,7 +208,10 @@ export const GatewayRuleProvider = () => Provider.effect( GatewayRule, Effect.gen(function* () { - const { accountId } = yield* yield* CloudflareEnvironment; + // Resolve credentials lazily inside lifecycle operations — resolving + // at layer-build time would force auth (or an interactive login) + // just to construct the provider collection. + const environment = yield* CloudflareEnvironment; const createRule = yield* zeroTrust.createGatewayRule; const getRule = yield* zeroTrust.getGatewayRule; @@ -225,7 +228,9 @@ export const GatewayRuleProvider = () => // Locate an existing rule by name when no ruleId is cached — used for // adoption and as a recovery path after a create returns a conflict. const findRuleByName = (name: string) => - listRules.items({ accountId }).pipe( + environment.pipe( + Effect.map(({ accountId }) => listRules.items({ accountId })), + Stream.unwrap, Stream.runCollect, Effect.map((chunk) => Array.from(chunk).find( @@ -241,6 +246,7 @@ export const GatewayRuleProvider = () => const observeById = (ruleId: string) => Effect.gen(function* () { + const { accountId } = yield* environment; const r = yield* getRule({ accountId, ruleId }).pipe( // Distilled tags transport errors but not the live Cloudflare 404 // for a missing rule. Swallow generically so the reconcile flow @@ -266,6 +272,7 @@ export const GatewayRuleProvider = () => }), reconcile: Effect.fn(function* ({ id, news, output }) { + const { accountId } = yield* environment; const resolvedName = yield* resolveName(id, news.name); const body = buildMutableBody(news, resolvedName); diff --git a/packages/alchemy/src/Cloudflare/Tunnel/Configuration.ts b/packages/alchemy/src/Cloudflare/Tunnel/Configuration.ts index 2f4728979..9eecd1a9e 100644 --- a/packages/alchemy/src/Cloudflare/Tunnel/Configuration.ts +++ b/packages/alchemy/src/Cloudflare/Tunnel/Configuration.ts @@ -278,13 +278,17 @@ export const TunnelConfigurationProvider = () => Provider.effect( TunnelConfiguration, Effect.gen(function* () { - const { accountId } = yield* yield* CloudflareEnvironment; + // Resolve credentials lazily inside lifecycle operations — resolving + // at layer-build time would force auth (or an interactive login) + // just to construct the provider collection. + const environment = yield* CloudflareEnvironment; const getConfig = yield* zeroTrust.getTunnelCloudflaredConfiguration; const putConfig = yield* zeroTrust.putTunnelCloudflaredConfiguration; const observe = (tunnelId: string) => Effect.gen(function* () { + const { accountId } = yield* environment; const r = yield* getConfig({ accountId, tunnelId }).pipe( Effect.catch((err) => isConfigNotFound(err) @@ -315,6 +319,7 @@ export const TunnelConfigurationProvider = () => }), reconcile: Effect.fn(function* ({ news }) { + const { accountId } = yield* environment; // Inputs have been resolved to concrete strings by the Plan layer. const tunnelId = news.tunnelId as string; const catchAllService = diff --git a/packages/alchemy/src/Drizzle/Schema.ts b/packages/alchemy/src/Drizzle/Schema.ts index c93a5dd19..671bc70de 100644 --- a/packages/alchemy/src/Drizzle/Schema.ts +++ b/packages/alchemy/src/Drizzle/Schema.ts @@ -262,11 +262,15 @@ export const SchemaProvider = () => // otherwise downstream resources (e.g. Neon.Branch) would see // `schema.out` as an unresolved Output during plan and cascade // into spurious updates of their own. - const { sqlStatements } = yield* detectDrift(news); - // Originally `output.out` was an absolute path, which is not portable. - // So, we trigger an update to migrate existing resources. - // This is safe because `regenerate` is idempotent. - return sqlStatements.length > 0 || path.isAbsolute(output.out) + const { out, sqlStatements } = yield* detectDrift(news); + // Originally `output.out` was an absolute path, which is not + // portable, so flag legacy states for one migrating update (safe + // because `regenerate` is idempotent). Compare against today's + // canonical form rather than `isAbsolute` — on Windows an `out` + // on a different drive than cwd has no relative form, and + // treating its absolute representation as "legacy" would force + // an update on every deploy. + return sqlStatements.length > 0 || output.out !== relativeOut(out) ? { action: "update" } : undefined; }), diff --git a/packages/alchemy/test/AWS/Lambda/Function.test.ts b/packages/alchemy/test/AWS/Lambda/Function.test.ts index f442993f9..66037710f 100644 --- a/packages/alchemy/test/AWS/Lambda/Function.test.ts +++ b/packages/alchemy/test/AWS/Lambda/Function.test.ts @@ -7,9 +7,13 @@ import * as Effect from "effect/Effect"; import * as Schedule from "effect/Schedule"; import * as HttpClient from "effect/unstable/http/HttpClient"; import { TestFunction, TestFunctionLive } from "./handler.ts"; +import { fileURLToPath } from "node:url"; -const timeoutHandlerPath = new URL("./timeout-handler.ts", import.meta.url) - .pathname; +// `URL.pathname` yields `/D:/...` on Windows; fileURLToPath gives a real +// filesystem path on every platform. +const timeoutHandlerPath = fileURLToPath( + new URL("./timeout-handler.ts", import.meta.url), +); const { test } = Test.make({ providers: AWS.providers() }); diff --git a/packages/alchemy/test/Build/Build.test.ts b/packages/alchemy/test/Build/Build.test.ts index a3ae9ed83..8a8ba892d 100644 --- a/packages/alchemy/test/Build/Build.test.ts +++ b/packages/alchemy/test/Build/Build.test.ts @@ -35,7 +35,9 @@ test.provider( }), ); - expect(build1.outdir).toBe(distDir); + // `outdir` is resolved with the platform's separators; normalize so + // the comparison holds on Windows too. + expect(pathe.normalize(build1.outdir)).toBe(distDir); expect(build1.hash).toBeDefined(); expect(typeof build1.hash).toBe("string"); expect(build1.hash.length).toBeGreaterThan(0); diff --git a/packages/alchemy/test/Bundle/PurePlugin.test.ts b/packages/alchemy/test/Bundle/PurePlugin.test.ts index f03a22d7b..f32412b5b 100644 --- a/packages/alchemy/test/Bundle/PurePlugin.test.ts +++ b/packages/alchemy/test/Bundle/PurePlugin.test.ts @@ -472,9 +472,17 @@ describe("Bundle.build with purePlugin", () => { ); // Symlink the package under node_modules so rolldown can find it. + // Windows requires elevation for "dir" symlinks; junctions work + // unprivileged and the type argument is ignored on POSIX. const nm = path.join(root, "node_modules"); yield* fs.makeDirectory(nm, { recursive: true }); - nodeFs.symlinkSync(pkgDir, path.join(nm, "fake-ws"), "dir"); + yield* Effect.sync(() => + nodeFs.symlinkSync( + pkgDir, + path.join(nm, "fake-ws"), + process.platform === "win32" ? "junction" : "dir", + ), + ); const entry = path.join(root, "entry.ts"); yield* fs.writeFileString( diff --git a/packages/alchemy/test/Local/RpcServer.test.ts b/packages/alchemy/test/Local/RpcServer.test.ts index e0784a117..e73284904 100644 --- a/packages/alchemy/test/Local/RpcServer.test.ts +++ b/packages/alchemy/test/Local/RpcServer.test.ts @@ -10,9 +10,13 @@ import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { openWebSocket, waitForExit } from "./fixtures/process-effect.ts"; import { runtimes } from "./fixtures/runtimes.ts"; +import { fileURLToPath } from "node:url"; -const FIXTURE_TS = new URL("./fixtures/rpc-server-entry.ts", import.meta.url) - .pathname; +// `URL.pathname` yields `/D:/...` on Windows; fileURLToPath gives a real +// filesystem path on every platform. +const FIXTURE_TS = fileURLToPath( + new URL("./fixtures/rpc-server-entry.ts", import.meta.url), +); const ADDRESS_RE = /(.+?)<\/ALCHEMY_RPC_ADDRESS>/; diff --git a/packages/alchemy/test/Local/fixtures/process-effect.ts b/packages/alchemy/test/Local/fixtures/process-effect.ts index 995276697..1cea11f4a 100644 --- a/packages/alchemy/test/Local/fixtures/process-effect.ts +++ b/packages/alchemy/test/Local/fixtures/process-effect.ts @@ -57,23 +57,38 @@ export const isAlive = (pid: number): Effect.Effect => }); /** - * Resolves the pid currently LISTENing on the port of `wsUrl`. Uses an - * `lsof` invocation; we don't own a handle to whatever process is - * listening so there's no ChildProcessHandle equivalent. + * Resolves the pid currently LISTENing on the port of `wsUrl`. We don't + * own a handle to whatever process is listening so there's no + * ChildProcessHandle equivalent — shell out to `lsof` (POSIX) or + * `netstat -ano` (Windows, where lsof doesn't exist). */ -export const pidListeningOn = (wsUrl: string) => - ChildProcess.make( - "lsof", - ["-iTCP:" + new URL(wsUrl).port, "-sTCP:LISTEN", "-t"], - { +export const pidListeningOn = (wsUrl: string) => { + const port = new URL(wsUrl).port; + if (process.platform === "win32") { + return ChildProcess.make("netstat", ["-ano", "-p", "TCP"], { stdout: "pipe", - }, - ).pipe( + }).pipe( + Effect.flatMap((handle) => + handle.stdout.pipe(Stream.decodeText, Stream.mkString), + ), + Effect.map((stdout) => { + // e.g. ` TCP 127.0.0.1:58828 0.0.0.0:0 LISTENING 12345` + const line = stdout + .split(/\r?\n/) + .find((l) => l.includes(`:${port} `) && l.includes("LISTENING")); + return Number.parseInt(line?.trim().split(/\s+/).at(-1) ?? "", 10); + }), + ); + } + return ChildProcess.make("lsof", ["-iTCP:" + port, "-sTCP:LISTEN", "-t"], { + stdout: "pipe", + }).pipe( Effect.flatMap((handle) => handle.stdout.pipe(Stream.decodeText, Stream.mkString), ), Effect.map((stdout) => Number.parseInt(stdout.trim().split("\n")[0]!, 10)), ); +}; /** Send a signal to a pid we don't own a handle to. */ export const killPid = (