From ba5bf64d03fe03867d19ccb7953995ddc5b740be Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 26 Mar 2026 09:09:48 -0600 Subject: [PATCH 1/7] refactor(test): move stubs and integration harness to colocated paths Move test/stubs.ts to test/lib/stubs.ts so it lives alongside other test helpers. Move test/lib/setup.ts to test/integration/lib/harness.ts so the integration test harness is colocated with the integration tests that use it. Update all import paths in unit and integration tests accordingly. --- .../cli-core/src/commands/api/bapi.test.ts | 2 +- .../cli-core/src/commands/api/catalog.test.ts | 2 +- .../cli-core/src/commands/api/index.test.ts | 2 +- .../src/commands/api/interactive.test.ts | 2 +- packages/cli-core/src/commands/api/ls.test.ts | 2 +- .../cli-core/src/commands/auth/login.test.ts | 2 +- .../cli-core/src/commands/auth/logout.test.ts | 2 +- .../cli-core/src/commands/config/pull.test.ts | 2 +- .../cli-core/src/commands/config/push.test.ts | 2 +- .../src/commands/config/schema.test.ts | 2 +- .../src/commands/deploy/index.test.ts | 2 +- .../src/commands/doctor/context.test.ts | 2 +- .../src/commands/doctor/doctor.test.ts | 2 +- .../cli-core/src/commands/env/pull.test.ts | 2 +- .../cli-core/src/commands/link/index.test.ts | 2 +- .../src/commands/switch-env/index.test.ts | 2 +- .../src/commands/unlink/index.test.ts | 2 +- .../src/commands/whoami/index.test.ts | 2 +- packages/cli-core/src/lib/autolink.test.ts | 2 +- .../cli-core/src/lib/credential-store.test.ts | 3 +- packages/cli-core/src/lib/plapi.test.ts | 2 +- .../src/test/integration/agent-mode.test.ts | 2 +- .../src/test/integration/api-queries.test.ts | 2 +- .../test/integration/auth-lifecycle.test.ts | 2 +- .../integration/config-management.test.ts | 2 +- .../src/test/integration/config-put.test.ts | 2 +- .../test/integration/deploy-to-prod.test.ts | 2 +- .../src/test/integration/dry-run.test.ts | 2 +- .../src/test/integration/env-merge.test.ts | 2 +- .../test/integration/error-recovery.test.ts | 2 +- .../setup.ts => integration/lib/harness.ts} | 36 ++++++++++--------- .../src/test/integration/onboard.test.ts | 2 +- .../src/test/integration/switch-apps.test.ts | 2 +- packages/cli-core/src/test/{ => lib}/stubs.ts | 0 34 files changed, 51 insertions(+), 50 deletions(-) rename packages/cli-core/src/test/{lib/setup.ts => integration/lib/harness.ts} (94%) rename packages/cli-core/src/test/{ => lib}/stubs.ts (100%) diff --git a/packages/cli-core/src/commands/api/bapi.test.ts b/packages/cli-core/src/commands/api/bapi.test.ts index 7c6398d1..0500d702 100644 --- a/packages/cli-core/src/commands/api/bapi.test.ts +++ b/packages/cli-core/src/commands/api/bapi.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach } from "bun:test"; -import { stubFetch } from "../../test/stubs.ts"; +import { stubFetch } from "../../test/lib/stubs.ts"; import { bapiRequest } from "./bapi.ts"; import { BapiError } from "../../lib/errors.ts"; diff --git a/packages/cli-core/src/commands/api/catalog.test.ts b/packages/cli-core/src/commands/api/catalog.test.ts index 3ee74def..a7e1b2c7 100644 --- a/packages/cli-core/src/commands/api/catalog.test.ts +++ b/packages/cli-core/src/commands/api/catalog.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test"; -import { stubFetch } from "../../test/stubs.ts"; +import { stubFetch } from "../../test/lib/stubs.ts"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; diff --git a/packages/cli-core/src/commands/api/index.test.ts b/packages/cli-core/src/commands/api/index.test.ts index cbedde33..04bdcd94 100644 --- a/packages/cli-core/src/commands/api/index.test.ts +++ b/packages/cli-core/src/commands/api/index.test.ts @@ -8,7 +8,7 @@ import { configStubs, promptsStubs, stubFetch, -} from "../../test/stubs.ts"; +} from "../../test/lib/stubs.ts"; let mockStoredToken: string | null = null; mock.module("../../lib/credential-store.ts", () => ({ diff --git a/packages/cli-core/src/commands/api/interactive.test.ts b/packages/cli-core/src/commands/api/interactive.test.ts index ecb81305..9285d2a8 100644 --- a/packages/cli-core/src/commands/api/interactive.test.ts +++ b/packages/cli-core/src/commands/api/interactive.test.ts @@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, spyOn, mock } from "bun: import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { promptsStubs, stubFetch } from "../../test/stubs.ts"; +import { promptsStubs, stubFetch } from "../../test/lib/stubs.ts"; let _mode = "human"; mock.module("../../mode.ts", () => ({ diff --git a/packages/cli-core/src/commands/api/ls.test.ts b/packages/cli-core/src/commands/api/ls.test.ts index d2da3ef3..7838832a 100644 --- a/packages/cli-core/src/commands/api/ls.test.ts +++ b/packages/cli-core/src/commands/api/ls.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { parseSpec, _setCacheDir } from "./catalog.ts"; -import { stubFetch } from "../../test/stubs.ts"; +import { stubFetch } from "../../test/lib/stubs.ts"; import { apiLs } from "./ls.ts"; const MINIMAL_SPEC = ` diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index 6305c55a..5b2417cb 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, afterEach, mock, spyOn } from "bun:test"; -import { credentialStoreStubs, configStubs } from "../../test/stubs.ts"; +import { credentialStoreStubs, configStubs } from "../../test/lib/stubs.ts"; const mockGetToken = mock(); const mockStoreToken = mock(); diff --git a/packages/cli-core/src/commands/auth/logout.test.ts b/packages/cli-core/src/commands/auth/logout.test.ts index 4d97eb36..4615951d 100644 --- a/packages/cli-core/src/commands/auth/logout.test.ts +++ b/packages/cli-core/src/commands/auth/logout.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, afterEach, mock, spyOn } from "bun:test"; -import { credentialStoreStubs, configStubs } from "../../test/stubs.ts"; +import { credentialStoreStubs, configStubs } from "../../test/lib/stubs.ts"; const mockDeleteToken = mock(); const mockClearAuth = mock(); diff --git a/packages/cli-core/src/commands/config/pull.test.ts b/packages/cli-core/src/commands/config/pull.test.ts index fd1ef5df..ea6cd2de 100644 --- a/packages/cli-core/src/commands/config/pull.test.ts +++ b/packages/cli-core/src/commands/config/pull.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { _setConfigDir, setProfile } from "../../lib/config.ts"; -import { credentialStoreStubs, gitStubs, stubFetch } from "../../test/stubs.ts"; +import { credentialStoreStubs, gitStubs, stubFetch } from "../../test/lib/stubs.ts"; mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); diff --git a/packages/cli-core/src/commands/config/push.test.ts b/packages/cli-core/src/commands/config/push.test.ts index 39c5ed94..f811553a 100644 --- a/packages/cli-core/src/commands/config/push.test.ts +++ b/packages/cli-core/src/commands/config/push.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { _setConfigDir, setProfile } from "../../lib/config.ts"; -import { credentialStoreStubs, gitStubs, promptsStubs, stubFetch } from "../../test/stubs.ts"; +import { credentialStoreStubs, gitStubs, promptsStubs, stubFetch } from "../../test/lib/stubs.ts"; import { printDiff, hasConfigChanges } from "./push.ts"; mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); diff --git a/packages/cli-core/src/commands/config/schema.test.ts b/packages/cli-core/src/commands/config/schema.test.ts index 9a7bc6d2..f6ff4eba 100644 --- a/packages/cli-core/src/commands/config/schema.test.ts +++ b/packages/cli-core/src/commands/config/schema.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { _setConfigDir, setProfile } from "../../lib/config.ts"; -import { credentialStoreStubs, gitStubs, stubFetch } from "../../test/stubs.ts"; +import { credentialStoreStubs, gitStubs, stubFetch } from "../../test/lib/stubs.ts"; mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 4d7ab155..ed9e619f 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, afterEach, mock, spyOn } from "bun:test"; -import { capturedOutput, promptsStubs } from "../../test/stubs.ts"; +import { capturedOutput, promptsStubs } from "../../test/lib/stubs.ts"; const mockIsAgent = mock(); let _modeOverride: string | undefined; diff --git a/packages/cli-core/src/commands/doctor/context.test.ts b/packages/cli-core/src/commands/doctor/context.test.ts index 90291227..2ed04159 100644 --- a/packages/cli-core/src/commands/doctor/context.test.ts +++ b/packages/cli-core/src/commands/doctor/context.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, mock, beforeEach, afterEach } from "bun:test"; -import { credentialStoreStubs, configStubs, gitStubs, stubFetch } from "../../test/stubs.ts"; +import { credentialStoreStubs, configStubs, gitStubs, stubFetch } from "../../test/lib/stubs.ts"; import type { Application } from "../../lib/plapi.ts"; const mockGetToken = mock(); diff --git a/packages/cli-core/src/commands/doctor/doctor.test.ts b/packages/cli-core/src/commands/doctor/doctor.test.ts index ac53af42..25c2a46a 100644 --- a/packages/cli-core/src/commands/doctor/doctor.test.ts +++ b/packages/cli-core/src/commands/doctor/doctor.test.ts @@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { gitStubs, tokenExchangeStubs, stubFetch } from "../../test/stubs.ts"; +import { gitStubs, tokenExchangeStubs, stubFetch } from "../../test/lib/stubs.ts"; import type { CheckResult, CheckStatus, DoctorContext, ResolvedProfile } from "./types.ts"; import type { Application } from "../../lib/plapi.ts"; diff --git a/packages/cli-core/src/commands/env/pull.test.ts b/packages/cli-core/src/commands/env/pull.test.ts index 6e9ecb8f..923e271a 100644 --- a/packages/cli-core/src/commands/env/pull.test.ts +++ b/packages/cli-core/src/commands/env/pull.test.ts @@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, spyOn, mock } from "bun: import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { credentialStoreStubs, gitStubs, configStubs, stubFetch } from "../../test/stubs.ts"; +import { credentialStoreStubs, gitStubs, configStubs, stubFetch } from "../../test/lib/stubs.ts"; mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index 05c3cecb..f8073605 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -6,7 +6,7 @@ import { autolinkStubs, gitStubs, promptsStubs, -} from "../../test/stubs.ts"; +} from "../../test/lib/stubs.ts"; const mockIsAgent = mock(); let _modeOverride: string | undefined; diff --git a/packages/cli-core/src/commands/switch-env/index.test.ts b/packages/cli-core/src/commands/switch-env/index.test.ts index 6a550444..0dd7c56a 100644 --- a/packages/cli-core/src/commands/switch-env/index.test.ts +++ b/packages/cli-core/src/commands/switch-env/index.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, afterEach, mock, spyOn } from "bun:test"; -import { configStubs, credentialStoreStubs } from "../../test/stubs.ts"; +import { configStubs, credentialStoreStubs } from "../../test/lib/stubs.ts"; const mockSetEnvironment = mock(); const mockGetToken = mock(); diff --git a/packages/cli-core/src/commands/unlink/index.test.ts b/packages/cli-core/src/commands/unlink/index.test.ts index c3e665db..645282f5 100644 --- a/packages/cli-core/src/commands/unlink/index.test.ts +++ b/packages/cli-core/src/commands/unlink/index.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, afterEach, mock, spyOn } from "bun:test"; -import { capturedOutput, configStubs, gitStubs, promptsStubs } from "../../test/stubs.ts"; +import { capturedOutput, configStubs, gitStubs, promptsStubs } from "../../test/lib/stubs.ts"; const mockIsAgent = mock(); const mockIsHuman = mock(); diff --git a/packages/cli-core/src/commands/whoami/index.test.ts b/packages/cli-core/src/commands/whoami/index.test.ts index 29eaa923..913ba2ea 100644 --- a/packages/cli-core/src/commands/whoami/index.test.ts +++ b/packages/cli-core/src/commands/whoami/index.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, afterEach, mock, spyOn } from "bun:test"; -import { credentialStoreStubs, tokenExchangeStubs } from "../../test/stubs.ts"; +import { credentialStoreStubs, tokenExchangeStubs } from "../../test/lib/stubs.ts"; const mockGetToken = mock(); const mockFetchUserInfo = mock(); diff --git a/packages/cli-core/src/lib/autolink.test.ts b/packages/cli-core/src/lib/autolink.test.ts index 82352a64..dc496715 100644 --- a/packages/cli-core/src/lib/autolink.test.ts +++ b/packages/cli-core/src/lib/autolink.test.ts @@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun: import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { credentialStoreStubs, gitStubs, stubFetch } from "../test/stubs.ts"; +import { credentialStoreStubs, gitStubs, stubFetch } from "../test/lib/stubs.ts"; mock.module("./credential-store.ts", () => credentialStoreStubs); diff --git a/packages/cli-core/src/lib/credential-store.test.ts b/packages/cli-core/src/lib/credential-store.test.ts index 70460ceb..b118a2a7 100644 --- a/packages/cli-core/src/lib/credential-store.test.ts +++ b/packages/cli-core/src/lib/credential-store.test.ts @@ -13,8 +13,7 @@ process.env.CLERK_CONFIG_DIR = tempDir; // Import constants from the source module to avoid duplication const { KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT } = await import("./credential-store.ts"); -const credFile = () => - join(process.env.CLERK_CONFIG_DIR ?? join(require("os").homedir(), ".clerk"), "credentials"); +const credFile = () => join(tempDir, "credentials"); let keyringModule: typeof import("@napi-rs/keyring") | null; try { diff --git a/packages/cli-core/src/lib/plapi.test.ts b/packages/cli-core/src/lib/plapi.test.ts index bdddd0e3..3437a316 100644 --- a/packages/cli-core/src/lib/plapi.test.ts +++ b/packages/cli-core/src/lib/plapi.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; -import { credentialStoreStubs, stubFetch } from "../test/stubs.ts"; +import { credentialStoreStubs, stubFetch } from "../test/lib/stubs.ts"; const mockGetToken = mock(); mock.module("./credential-store.ts", () => ({ diff --git a/packages/cli-core/src/test/integration/agent-mode.test.ts b/packages/cli-core/src/test/integration/agent-mode.test.ts index 4b0b1b01..ee2b0068 100644 --- a/packages/cli-core/src/test/integration/agent-mode.test.ts +++ b/packages/cli-core/src/test/integration/agent-mode.test.ts @@ -4,7 +4,7 @@ */ import { test, expect } from "bun:test"; -import { useIntegrationTestHarness, http, clerk } from "../lib/setup.ts"; +import { useIntegrationTestHarness, http, clerk } from "./lib/harness.ts"; useIntegrationTestHarness(); diff --git a/packages/cli-core/src/test/integration/api-queries.test.ts b/packages/cli-core/src/test/integration/api-queries.test.ts index b78dcbbc..508de36c 100644 --- a/packages/cli-core/src/test/integration/api-queries.test.ts +++ b/packages/cli-core/src/test/integration/api-queries.test.ts @@ -11,7 +11,7 @@ import { getInstance, MOCK_APP, MOCK_USERS, -} from "../lib/setup.ts"; +} from "./lib/harness.ts"; useIntegrationTestHarness(); diff --git a/packages/cli-core/src/test/integration/auth-lifecycle.test.ts b/packages/cli-core/src/test/integration/auth-lifecycle.test.ts index 419b2ae5..064bae6f 100644 --- a/packages/cli-core/src/test/integration/auth-lifecycle.test.ts +++ b/packages/cli-core/src/test/integration/auth-lifecycle.test.ts @@ -4,7 +4,7 @@ */ import { test, expect } from "bun:test"; -import { useIntegrationTestHarness, mockState, clerk } from "../lib/setup.ts"; +import { useIntegrationTestHarness, mockState, clerk } from "./lib/harness.ts"; useIntegrationTestHarness(); diff --git a/packages/cli-core/src/test/integration/config-management.test.ts b/packages/cli-core/src/test/integration/config-management.test.ts index 853a0e60..51708c6f 100644 --- a/packages/cli-core/src/test/integration/config-management.test.ts +++ b/packages/cli-core/src/test/integration/config-management.test.ts @@ -13,7 +13,7 @@ import { MOCK_APP, MOCK_CONFIG, MOCK_SCHEMA, -} from "../lib/setup.ts"; +} from "./lib/harness.ts"; useIntegrationTestHarness(); diff --git a/packages/cli-core/src/test/integration/config-put.test.ts b/packages/cli-core/src/test/integration/config-put.test.ts index dbb65fa0..a8618993 100644 --- a/packages/cli-core/src/test/integration/config-put.test.ts +++ b/packages/cli-core/src/test/integration/config-put.test.ts @@ -13,7 +13,7 @@ import { clerk, getInstance, MOCK_APP, -} from "../lib/setup.ts"; +} from "./lib/harness.ts"; useIntegrationTestHarness(); diff --git a/packages/cli-core/src/test/integration/deploy-to-prod.test.ts b/packages/cli-core/src/test/integration/deploy-to-prod.test.ts index a89f11da..41ea9f46 100644 --- a/packages/cli-core/src/test/integration/deploy-to-prod.test.ts +++ b/packages/cli-core/src/test/integration/deploy-to-prod.test.ts @@ -13,7 +13,7 @@ import { clerk, getInstance, MOCK_APP, -} from "../lib/setup.ts"; +} from "./lib/harness.ts"; const h = useIntegrationTestHarness(); diff --git a/packages/cli-core/src/test/integration/dry-run.test.ts b/packages/cli-core/src/test/integration/dry-run.test.ts index 3fd89e03..fcd18eaf 100644 --- a/packages/cli-core/src/test/integration/dry-run.test.ts +++ b/packages/cli-core/src/test/integration/dry-run.test.ts @@ -11,7 +11,7 @@ import { clerk, getInstance, MOCK_APP, -} from "../lib/setup.ts"; +} from "./lib/harness.ts"; useIntegrationTestHarness(); diff --git a/packages/cli-core/src/test/integration/env-merge.test.ts b/packages/cli-core/src/test/integration/env-merge.test.ts index 2b5fd35f..8f02da5c 100644 --- a/packages/cli-core/src/test/integration/env-merge.test.ts +++ b/packages/cli-core/src/test/integration/env-merge.test.ts @@ -15,7 +15,7 @@ import { clerk, getInstance, MOCK_APP, -} from "../lib/setup.ts"; +} from "./lib/harness.ts"; const h = useIntegrationTestHarness(); diff --git a/packages/cli-core/src/test/integration/error-recovery.test.ts b/packages/cli-core/src/test/integration/error-recovery.test.ts index 871b2867..a381581a 100644 --- a/packages/cli-core/src/test/integration/error-recovery.test.ts +++ b/packages/cli-core/src/test/integration/error-recovery.test.ts @@ -14,7 +14,7 @@ import { getInstance, MOCK_APP, MOCK_APP_DEV_ONLY, -} from "../lib/setup.ts"; +} from "./lib/harness.ts"; const h = useIntegrationTestHarness(); diff --git a/packages/cli-core/src/test/lib/setup.ts b/packages/cli-core/src/test/integration/lib/harness.ts similarity index 94% rename from packages/cli-core/src/test/lib/setup.ts rename to packages/cli-core/src/test/integration/lib/harness.ts index 064d5556..c6b1a901 100644 --- a/packages/cli-core/src/test/lib/setup.ts +++ b/packages/cli-core/src/test/integration/lib/harness.ts @@ -16,9 +16,9 @@ import { mock, spyOn, beforeEach, afterEach } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { capturedOutput } from "../stubs.ts"; -import { http } from "./http.ts"; -import type { Application, ApplicationInstance } from "../../lib/plapi.ts"; +import { capturedOutput } from "../../lib/stubs.ts"; +import { http } from "../../lib/http.ts"; +import type { Application, ApplicationInstance } from "../../../lib/plapi.ts"; export { capturedOutput, http }; @@ -40,7 +40,7 @@ export const mockState = { // ── Module mocks (executed at import time) ─────────────────────────────────── mock.module( - "../../lib/credential-store.ts", + "../../../lib/credential-store.ts", () => ({ getToken: async () => mockState.storedToken, @@ -51,23 +51,25 @@ mock.module( mockState.storedToken = null; }, _setTokenOverride: () => {}, - }) satisfies typeof import("../../lib/credential-store.ts"), + KEYCHAIN_SERVICE: "clerk-cli", + KEYCHAIN_ACCOUNT: "oauth-access-token", + }) satisfies typeof import("../../../lib/credential-store.ts"), ); mock.module( - "../../lib/git.ts", + "../../../lib/git.ts", () => ({ getGitRepoRoot: async () => mockState.gitRepoRoot, getGitRepoIdentifier: async () => mockState.gitRepoIdentifier, getGitNormalizedRemote: async () => mockState.gitNormalizedRemote, normalizeGitRemoteUrl: (url: string) => url, - }) satisfies typeof import("../../lib/git.ts"), + }) satisfies typeof import("../../../lib/git.ts"), ); let _mode: "human" | "agent" = "human"; mock.module( - "../../mode.ts", + "../../../mode.ts", () => ({ getMode: () => _mode, @@ -76,7 +78,7 @@ mock.module( }, isHuman: () => _mode === "human", isAgent: () => _mode === "agent", - }) satisfies typeof import("../../mode.ts"), + }) satisfies typeof import("../../../mode.ts"), ); // ── Prompt queue (replaces @inquirer/prompts) ──────────────────────────────── @@ -160,7 +162,7 @@ mock.module("@inquirer/prompts", () => ({ })); mock.module( - "../../lib/token-exchange.ts", + "../../../lib/token-exchange.ts", () => ({ exchangeCodeForToken: async () => ({ @@ -172,11 +174,11 @@ mock.module( if (!token || token === "expired_token") throw new Error("Unauthorized"); return { userId: "user_123", email: "test@example.com" }; }, - }) satisfies typeof import("../../lib/token-exchange.ts"), + }) satisfies typeof import("../../../lib/token-exchange.ts"), ); mock.module( - "../../lib/auth-server.ts", + "../../../lib/auth-server.ts", () => ({ startAuthServer: () => ({ @@ -184,22 +186,22 @@ mock.module( waitForCallback: async () => ({ code: "mock_code" }), stop: () => {}, }), - }) satisfies typeof import("../../lib/auth-server.ts"), + }) satisfies typeof import("../../../lib/auth-server.ts"), ); mock.module( - "../../lib/pkce.ts", + "../../../lib/pkce.ts", () => ({ generateCodeVerifier: () => "mock_verifier", generateCodeChallenge: async () => "mock_challenge", generateState: () => "mock_state", - }) satisfies typeof import("../../lib/pkce.ts"), + }) satisfies typeof import("../../../lib/pkce.ts"), ); // ── Real config module ─────────────────────────────────────────────────────── -export const { _setConfigDir, readConfig, setProfile } = await import("../../lib/config.ts"); +export const { _setConfigDir, readConfig, setProfile } = await import("../../../lib/config.ts"); // ── Mock data ──────────────────────────────────────────────────────────────── @@ -371,7 +373,7 @@ export interface CLIResult { } async function execCLI(...args: string[]): Promise { - const { createProgram, runProgram } = await import("../../cli-program.ts"); + const { createProgram, runProgram } = await import("../../../cli-program.ts"); const program = createProgram(); program.exitOverride(); diff --git a/packages/cli-core/src/test/integration/onboard.test.ts b/packages/cli-core/src/test/integration/onboard.test.ts index 035f74e5..2656a0c3 100644 --- a/packages/cli-core/src/test/integration/onboard.test.ts +++ b/packages/cli-core/src/test/integration/onboard.test.ts @@ -17,7 +17,7 @@ import { clerk, getInstance, MOCK_APP, -} from "../lib/setup.ts"; +} from "./lib/harness.ts"; const h = useIntegrationTestHarness(); diff --git a/packages/cli-core/src/test/integration/switch-apps.test.ts b/packages/cli-core/src/test/integration/switch-apps.test.ts index e84ecd38..68873ff5 100644 --- a/packages/cli-core/src/test/integration/switch-apps.test.ts +++ b/packages/cli-core/src/test/integration/switch-apps.test.ts @@ -18,7 +18,7 @@ import { getInstance, MOCK_APP, MOCK_APP_B, -} from "../lib/setup.ts"; +} from "./lib/harness.ts"; const h = useIntegrationTestHarness(); diff --git a/packages/cli-core/src/test/stubs.ts b/packages/cli-core/src/test/lib/stubs.ts similarity index 100% rename from packages/cli-core/src/test/stubs.ts rename to packages/cli-core/src/test/lib/stubs.ts From 5132a8d1a02e905d018c367ac29ba50bce477efb Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 26 Mar 2026 09:10:01 -0600 Subject: [PATCH 2/7] docs: add Claude rules and auth token documentation Add Claude rules for command authoring conventions, error handling patterns, and unit test conventions. Document CLERK_CLI_TOKEN non-interactive authentication in the auth command README. Update CLAUDE.md test command to exclude e2e tests by default. --- .claude/rules/commands.md | 23 +++++++ .claude/rules/errors.md | 64 +++++++++++++++++++ .claude/rules/testing.md | 27 ++++++++ packages/cli-core/src/commands/auth/README.md | 22 +++++++ .../src/test/integration/error-codes.test.ts | 2 +- 5 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 .claude/rules/commands.md create mode 100644 .claude/rules/errors.md create mode 100644 .claude/rules/testing.md diff --git a/.claude/rules/commands.md b/.claude/rules/commands.md new file mode 100644 index 00000000..cdb1706a --- /dev/null +++ b/.claude/rules/commands.md @@ -0,0 +1,23 @@ +--- +description: Command authoring conventions — directory structure, READMEs, agent mode +paths: + - "src/commands/**" +alwaysApply: false +--- + +Every CLI command lives in its own directory under `src/commands//`. Each directory must contain a `README.md` that documents: + +- What the command does +- Usage and options +- Clerk API endpoints the command calls (method, path, description) +- Whether the command (or parts of it) is mocked/stubbed — call this out prominently with a blockquote at the top of the README if so + +When adding a new command, create its directory and README. When modifying a command's behavior, options, or API calls, update its README to match. + +## Agent mode + +When creating or modifying a command, evaluate whether it needs an agent mode. Commands with interactive prompts (menus, wizards, multi-step flows) should check `isAgent()` from `src/mode.ts` and, when in agent mode, output a structured prompt that an AI agent can follow instead of running the interactive flow. Commands that are already non-interactive (e.g., single API calls, browser-based OAuth) typically don't need agent mode. + +## Root README + +`README.md` at the project root contains the CLI help output. When commands are added, removed, or their options change, update the help output in `README.md` to stay in sync. You can regenerate it by running `bun run src/cli.ts --help`. diff --git a/.claude/rules/errors.md b/.claude/rules/errors.md new file mode 100644 index 00000000..11a03fd0 --- /dev/null +++ b/.claude/rules/errors.md @@ -0,0 +1,64 @@ +--- +description: Error handling conventions — CliError, throwUsageError, throwUserAbort, withApiContext +paths: + - "src/commands/**" + - "src/lib/**" +alwaysApply: false +--- + +All error classes and helpers live in `src/lib/errors.ts`. The global error handler in `src/cli.ts` catches thrown errors and formats them for the user. **Never call `console.error` + `process.exit` directly in commands** — throw an error instead and let the global handler deal with output and exit codes. + +## Known failures — `CliError` + +For user-facing errors (missing config, invalid input, resource not found), throw a `CliError`: + +```ts +import { CliError } from "../../lib/errors.ts"; + +throw new CliError("No Clerk project linked. Run `clerk link` first."); + +// With a docs URL (automatically gets .md appended in agent mode for Clerk URLs): +throw new CliError("Not authenticated.", { + docsUrl: "https://clerk.com/docs/guides/development/clerk-environment-variables", +}); +``` + +## Usage/validation errors — `throwUsageError` + +For invalid arguments or options, use `throwUsageError` (exits with code 2): + +```ts +import { throwUsageError } from "../../lib/errors.ts"; + +if (!secretKey) { + throwUsageError("No secret key found. Set CLERK_SECRET_KEY or use --secret-key."); +} +``` + +## User cancellation — `throwUserAbort` + +When the user cancels a prompt or confirmation, call `throwUserAbort()`. The global handler exits cleanly with no error output: + +```ts +import { throwUserAbort } from "../../lib/errors.ts"; + +const confirmed = await confirm({ message: "Proceed?" }); +if (!confirmed) throwUserAbort(); +``` + +## API errors — `withApiContext` + +Wrap API calls with `withApiContext` to attach a human-readable context string. The global handler extracts the first error message from the response body and prints it with the context prefix: + +```ts +import { withApiContext } from "../../lib/errors.ts"; + +const config = await withApiContext( + fetchInstanceConfig(appId, instanceId), + "Failed to fetch config", +); +``` + +## API error classes + +`BapiError` and `PlapiError` (both extend `ApiError`) are thrown by the API helpers in `src/commands/api/bapi.ts` and `src/lib/plapi.ts` respectively. Don't construct these in commands — they're thrown automatically by the fetch wrappers. Use `withApiContext` to add context when calling those helpers. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 00000000..7ae49c6c --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,27 @@ +--- +description: Unit test conventions using bun:test +paths: + - "**/*.test.ts" + - "**/*.test.tsx" +alwaysApply: false +--- + +Use `bun:test` for all unit and integration tests. + +```ts +import { test, expect } from "bun:test"; + +test("hello world", () => { + expect(1).toBe(1); +}); +``` + +Run the unit and integration test suite with: + +```sh +bun run test +``` + +This runs `bun test src/commands/ src/lib/ src/mode.test.ts src/test/integration/` — it excludes e2e fixtures, which require separate setup. See `rules/e2e.md` for e2e instructions. + +Use `spyOn()` for mocking, not `mock.module()`. In Bun 1.x, `mock.module()` registrations are process-lifetime and will pollute the module registry for all subsequent test files run in the same process. Always restore spies in `afterAll` with `mockRestore()`. diff --git a/packages/cli-core/src/commands/auth/README.md b/packages/cli-core/src/commands/auth/README.md index 735fa1af..d3f00304 100644 --- a/packages/cli-core/src/commands/auth/README.md +++ b/packages/cli-core/src/commands/auth/README.md @@ -26,6 +26,28 @@ All requests are made against the Clerk OAuth system instance (default `https:// | Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token | | User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token | +## Non-interactive authentication — `CLERK_CLI_TOKEN` + +Set the `CLERK_CLI_TOKEN` environment variable to a valid CLI access token to bypass the OAuth browser flow entirely. When set, the CLI uses this token for all authenticated requests without prompting for login. + +This is intended for CI/CD pipelines, automated scripts, and running the e2e test suite where interactive login is not possible. The token takes priority over any token stored by `clerk auth login`. + +```sh +CLERK_CLI_TOKEN= clerk +``` + +#### How to obtain a token + +The `CLERK_CLI_TOKEN` is an OAuth access token issued by [clerk.clerk.com](https://clerk.clerk.com). To get one: + +1. Run `clerk auth login` interactively to complete the OAuth flow +2. The CLI stores the resulting token in your OS keychain (service: `clerk-cli`, account: `oauth-access-token`) or, if the keychain is unavailable, in a plaintext credentials file (chmod 600) +3. Extract the token from whichever store was used: + - **macOS Keychain**: open Keychain Access and search for `clerk-cli`, or use `security find-generic-password -s clerk-cli -a oauth-access-token -w` + - **Credentials file**: `~/Library/Application Support/clerk-cli/credentials` on macOS, `~/.local/share/clerk-cli/credentials` on Linux (overridable via `CLERK_CONFIG_DIR`) + +Set the extracted value as `CLERK_CLI_TOKEN` in your CI secrets or local environment. + ### `clerk auth logout` (aliases: `signout`, `sign-out`) Removes the stored authentication token and clears auth info from local config. No API calls are made. diff --git a/packages/cli-core/src/test/integration/error-codes.test.ts b/packages/cli-core/src/test/integration/error-codes.test.ts index 83c7c7c7..06c81e83 100644 --- a/packages/cli-core/src/test/integration/error-codes.test.ts +++ b/packages/cli-core/src/test/integration/error-codes.test.ts @@ -4,7 +4,7 @@ */ import { test, expect } from "bun:test"; -import { useIntegrationTestHarness, clerk, mockState } from "../lib/setup.ts"; +import { useIntegrationTestHarness, clerk, mockState } from "./lib/harness.ts"; useIntegrationTestHarness(); From be8f48cc1129e2b598c81ab0949178edc7e2e40b Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 6 Apr 2026 13:17:30 -0600 Subject: [PATCH 3/7] fix(test): repair stale import paths missed in colocation refactor Two test files were merged into main on PR #59 after the colocation refactor was originally drafted, so they kept their pre-refactor import paths and broke unit tests on this branch: - commands/apps/list.test.ts referenced ../../test/stubs.ts - test/integration/completion.test.ts referenced ../lib/setup.ts Both now match the convention used by every other file in their respective directories. --- packages/cli-core/src/commands/apps/list.test.ts | 2 +- packages/cli-core/src/test/integration/completion.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/apps/list.test.ts b/packages/cli-core/src/commands/apps/list.test.ts index b66df4f7..343ea4c2 100644 --- a/packages/cli-core/src/commands/apps/list.test.ts +++ b/packages/cli-core/src/commands/apps/list.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import { capturedOutput } from "../../test/stubs.ts"; +import { capturedOutput } from "../../test/lib/stubs.ts"; const mockListApplications = mock(); mock.module("../../lib/plapi.ts", () => ({ diff --git a/packages/cli-core/src/test/integration/completion.test.ts b/packages/cli-core/src/test/integration/completion.test.ts index 9cba5984..0ac841e9 100644 --- a/packages/cli-core/src/test/integration/completion.test.ts +++ b/packages/cli-core/src/test/integration/completion.test.ts @@ -4,7 +4,7 @@ */ import { test, expect, describe } from "bun:test"; -import { useIntegrationTestHarness } from "../lib/setup.ts"; +import { useIntegrationTestHarness } from "./lib/harness.ts"; import { generateCompletions } from "../../commands/completion/__complete.ts"; import { createProgram } from "../../cli-program.ts"; From d5e41b3c9de250f985bf67c4540a2d608cd813f1 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 6 Apr 2026 13:59:13 -0600 Subject: [PATCH 4/7] fix(test): address review feedback on e2e docs and refresh script - Remove unimplemented CLERK_CLI_TOKEN section from auth README - Error in refresh-e2e-fixtures when --only is missing a value (previously refreshed every fixture silently) - Align e2e test concurrency default to 4 across runner, package.json, and .claude/rules/e2e.md - Narrow testing rule to permit import-time mock.module() registration - Fix .claude/rules path globs to match packages/cli-core/src layout - Correct CI secret name and clarify production API targeting in e2e rules and CLAUDE.md - Fix bun test -> bun run test drift in CONTRIBUTING.md --- .claude/rules/commands.md | 2 +- .claude/rules/e2e.md | 14 ++++++------ .claude/rules/errors.md | 4 ++-- .claude/rules/testing.md | 4 +++- CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- package.json | 2 +- packages/cli-core/src/commands/auth/README.md | 22 ------------------- scripts/refresh-e2e-fixtures.ts | 10 ++++++++- scripts/run-e2e.ts | 6 ++--- 10 files changed, 28 insertions(+), 40 deletions(-) diff --git a/.claude/rules/commands.md b/.claude/rules/commands.md index cdb1706a..7410c10f 100644 --- a/.claude/rules/commands.md +++ b/.claude/rules/commands.md @@ -1,7 +1,7 @@ --- description: Command authoring conventions — directory structure, READMEs, agent mode paths: - - "src/commands/**" + - "packages/cli-core/src/commands/**" alwaysApply: false --- diff --git a/.claude/rules/e2e.md b/.claude/rules/e2e.md index f1a01b1b..d9aa5382 100644 --- a/.claude/rules/e2e.md +++ b/.claude/rules/e2e.md @@ -39,8 +39,8 @@ E2E_HAR_DIR= # Directory to write HAR files per fixture Preferred (secrets resolved from 1Password, no plaintext on disk): ```sh -bun run test:e2e:op # Run all fixture tests (concurrency 2) -bun run test:e2e:op -- --concurrency 4 # Run with 4 concurrent workers +bun run test:e2e:op # Run all fixture tests (concurrency 4) +bun run test:e2e:op -- --concurrency 1 # Serialize bun run test:e2e:op -- --filter react # Only files matching "react" bun run test:e2e:op -- --debug # Verbose helper logging (CLERK_E2E_DEBUG=1) bun run test:e2e:op -- --har # Capture HAR files to test/e2e/.har @@ -65,7 +65,7 @@ bun run e2e:refresh-fixtures -- --only nextjs-app-router # Refresh one fixture Each test file runs as a separate `bun test` subprocess to avoid shared process state (env vars, module singletons). The runner supports: -- `--concurrency ` (default 2): how many test files run in parallel +- `--concurrency ` (default 4): how many test files run in parallel - `--filter `: only run files whose path contains the string - Automatic single retry on failure (handles transient FAPI throttling, Playwright timeouts) @@ -124,7 +124,7 @@ In CI, use `bunx playwright install chromium --with-deps` to include system-leve ## Concurrency -Fixture files run in parallel (concurrency controlled by the runner, default 2). Each fixture uses an isolated temp directory and `CLERK_CONFIG_DIR`, so there is no shared mutable state. Do not use `test.concurrent` within individual fixture files. +Fixture files run in parallel (concurrency controlled by the runner, default 4). Each fixture uses an isolated temp directory and `CLERK_CONFIG_DIR`, so there is no shared mutable state. Do not use `test.concurrent` within individual fixture files. Within each test file, `useFixture()` runs `setupFixture()` once in `beforeAll` and shares the result with both the build test and browser test. This avoids duplicating the expensive setup. @@ -139,7 +139,7 @@ Helper functions are in `test/e2e/lib/`: - `fixture-setup.ts` - `setupFixture` - `fixture-test.ts` - `useFixture`, `runFixtureTest`, `runBrowserTest` -- `dev-server.ts` - `getAvailablePort`, `startDevServer`, `killDevServer`, `buildDevCommand` +- `dev-server.ts` - `startDevServer` (allocates a port internally and retries on collision), `killDevServer`, `buildDevCommand` - `test-user.ts` - `createTestUser`, `deleteTestUser` - `logger.ts` - `log`, `debug` (shared logging; set `CLERK_E2E_DEBUG=1` for verbose output) - `types.ts` - `FixtureConfig` @@ -151,5 +151,5 @@ E2E tests run in the `test-e2e` job in `.github/workflows/ci.yml`. Key details: - Only runs for PRs from the same repository (skipped for external forks) - Runs on `blacksmith-8vcpu-ubuntu-2404` with a 30-minute timeout - Requires Node.js 22 (for Playwright) alongside Bun -- Secrets `E2E_APP_ID`, `CLERK_PLATFORM_API_KEY` are injected from GitHub Actions secrets -- Points at the staging API (`CLERK_PLATFORM_API_URL`, `CLERK_BACKEND_API_URL` set to `https://api.clerkstage.dev`) +- Secrets `CLERK_CLI_TEST_APP_ID`, `CLERK_PLATFORM_API_KEY` are injected from GitHub Actions secrets +- Targets the production Clerk API (no `CLERK_PLATFORM_API_URL` / `CLERK_BACKEND_API_URL` overrides are set, so the defaults in `packages/cli-core/src/lib/environment.ts` apply). The local `bun run test:e2e:op` flow likewise resolves secrets from the `Clerk CLI - E2E Production Secrets` 1Password item. Test users are created with the `+clerk_test` email suffix and torn down at the end of each fixture run. diff --git a/.claude/rules/errors.md b/.claude/rules/errors.md index 11a03fd0..336acdb7 100644 --- a/.claude/rules/errors.md +++ b/.claude/rules/errors.md @@ -1,8 +1,8 @@ --- description: Error handling conventions — CliError, throwUsageError, throwUserAbort, withApiContext paths: - - "src/commands/**" - - "src/lib/**" + - "packages/cli-core/src/commands/**" + - "packages/cli-core/src/lib/**" alwaysApply: false --- diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 7ae49c6c..0d6180b8 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -24,4 +24,6 @@ bun run test This runs `bun test src/commands/ src/lib/ src/mode.test.ts src/test/integration/` — it excludes e2e fixtures, which require separate setup. See `rules/e2e.md` for e2e instructions. -Use `spyOn()` for mocking, not `mock.module()`. In Bun 1.x, `mock.module()` registrations are process-lifetime and will pollute the module registry for all subsequent test files run in the same process. Always restore spies in `afterAll` with `mockRestore()`. +Prefer `spyOn()` for mocking, and always restore spies in `afterAll` with `mockRestore()`. + +`mock.module()` is acceptable only when registered at file top, before any consumer of the mocked module is loaded (the integration harness at `packages/cli-core/src/test/integration/lib/harness.ts` and `packages/cli-core/src/lib/credential-store.test.ts` both follow this pattern). In Bun 1.x, `mock.module()` registrations are process-lifetime and will pollute the module registry for any later test file that imports the same module via a non-mocked path, so do not call `mock.module()` from inside `beforeEach`/`describe`/`test`, and do not introduce it in test files that will run alongside files importing the real module. diff --git a/CLAUDE.md b/CLAUDE.md index f5e200ed..291d0975 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ bun run test:e2e # Run E2E tests with env vars already set (used by CI; see Locally, prefer `bun run test:e2e:op` so secrets are injected from 1Password in-memory and never written to disk. `bun run test:e2e` is for CI or for cases where the required env vars are already exported. -CI runs `bun run format:check` (fails if unformatted), `bun run lint`, `bun test`, and `bun run test:e2e` on every PR to `main`. E2E tests only run for PRs from the same repository (not external forks) and target a staging Clerk application. +CI runs `bun run format:check` (fails if unformatted), `bun run lint`, `bun test`, and `bun run test:e2e` on every PR to `main`. E2E tests only run for PRs from the same repository (not external forks) and target the production Clerk API with a dedicated test application. ## Versioning diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e9525054..71670c60 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,7 +61,7 @@ After modifying files, run these commands to match what CI enforces on pull requ ```sh bun run format # Format with oxfmt (writes changes) bun run lint # Lint with oxlint -bun test # Run unit tests +bun run test # Run unit tests bun run test:e2e:op # Run E2E tests with secrets from 1Password (preferred locally) bun run test:e2e # Run E2E tests with env vars already set (CI / non-1Password setups) ``` diff --git a/package.json b/package.json index c904b865..5889111c 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "bun run --filter @clerk/cli-core build", "dev": "bun run --cwd packages/cli-core dev", "test": "bun run --filter @clerk/cli-core test", - "test:e2e": "bun run scripts/run-e2e.ts --concurrency 4", + "test:e2e": "bun run scripts/run-e2e.ts", "test:e2e:op": "bun run scripts/run-e2e-op.ts", "test:all": "bun run test && bun run test:e2e", "e2e:refresh-fixtures": "bun run scripts/refresh-e2e-fixtures.ts", diff --git a/packages/cli-core/src/commands/auth/README.md b/packages/cli-core/src/commands/auth/README.md index d3f00304..735fa1af 100644 --- a/packages/cli-core/src/commands/auth/README.md +++ b/packages/cli-core/src/commands/auth/README.md @@ -26,28 +26,6 @@ All requests are made against the Clerk OAuth system instance (default `https:// | Token exchange | `POST` | `/oauth/token` | Exchanges authorization code + `code_verifier` for an access token | | User info | `GET` | `/oauth/userinfo` | Fetches `sub` (user ID) and `email` using the access token | -## Non-interactive authentication — `CLERK_CLI_TOKEN` - -Set the `CLERK_CLI_TOKEN` environment variable to a valid CLI access token to bypass the OAuth browser flow entirely. When set, the CLI uses this token for all authenticated requests without prompting for login. - -This is intended for CI/CD pipelines, automated scripts, and running the e2e test suite where interactive login is not possible. The token takes priority over any token stored by `clerk auth login`. - -```sh -CLERK_CLI_TOKEN= clerk -``` - -#### How to obtain a token - -The `CLERK_CLI_TOKEN` is an OAuth access token issued by [clerk.clerk.com](https://clerk.clerk.com). To get one: - -1. Run `clerk auth login` interactively to complete the OAuth flow -2. The CLI stores the resulting token in your OS keychain (service: `clerk-cli`, account: `oauth-access-token`) or, if the keychain is unavailable, in a plaintext credentials file (chmod 600) -3. Extract the token from whichever store was used: - - **macOS Keychain**: open Keychain Access and search for `clerk-cli`, or use `security find-generic-password -s clerk-cli -a oauth-access-token -w` - - **Credentials file**: `~/Library/Application Support/clerk-cli/credentials` on macOS, `~/.local/share/clerk-cli/credentials` on Linux (overridable via `CLERK_CONFIG_DIR`) - -Set the extracted value as `CLERK_CLI_TOKEN` in your CI secrets or local environment. - ### `clerk auth logout` (aliases: `signout`, `sign-out`) Removes the stored authentication token and clears auth info from local config. No API calls are made. diff --git a/scripts/refresh-e2e-fixtures.ts b/scripts/refresh-e2e-fixtures.ts index 063201ce..e984a223 100644 --- a/scripts/refresh-e2e-fixtures.ts +++ b/scripts/refresh-e2e-fixtures.ts @@ -20,7 +20,15 @@ process.env.CLERK_REFRESH_FIXTURES = "1"; const args = process.argv.slice(2); const force = args.includes("--force"); const onlyIndex = args.indexOf("--only"); -const onlyName = onlyIndex !== -1 ? args[onlyIndex + 1] : null; +let onlyName: string | null = null; +if (onlyIndex !== -1) { + const value = args[onlyIndex + 1]; + if (!value || value.startsWith("--")) { + console.error("--only requires a fixture name (e.g. `--only nextjs-app-router`)."); + process.exit(1); + } + onlyName = value; +} const E2E_DIR = join(import.meta.dir, "../test/e2e"); const FIXTURES_DIR = join(E2E_DIR, "fixtures"); diff --git a/scripts/run-e2e.ts b/scripts/run-e2e.ts index 92d6f295..88d87337 100644 --- a/scripts/run-e2e.ts +++ b/scripts/run-e2e.ts @@ -6,8 +6,8 @@ * runs multiple files in a single process. * * Usage: - * bun run scripts/run-e2e.ts # concurrency 1 (default) - * bun run scripts/run-e2e.ts --concurrency 4 # 4 at a time + * bun run scripts/run-e2e.ts # concurrency 4 (default) + * bun run scripts/run-e2e.ts --concurrency 1 # serialize * bun run scripts/run-e2e.ts --filter react # only files matching "react" * bun run scripts/run-e2e.ts --debug # verbose helper logging (sets CLERK_E2E_DEBUG=1) * bun run scripts/run-e2e.ts --har # write HAR files to ./test/e2e/.har @@ -23,7 +23,7 @@ const DEFAULT_HAR_DIR = "test/e2e/.har"; const { values } = parseArgs({ options: { - concurrency: { type: "string", short: "c", default: "1" }, + concurrency: { type: "string", short: "c", default: "4" }, filter: { type: "string", short: "f", default: "" }, debug: { type: "boolean", default: false }, har: { type: "boolean", default: false }, From 5eede9faf74aba50e7d3fecb8c374201024d8928 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 6 Apr 2026 13:59:23 -0600 Subject: [PATCH 5/7] fix(test): retry dev server bind on port conflict getAvailablePort binds to port 0 and closes the listener before the dev server starts, leaving a TOCTOU window where a sibling fixture (or anything on the host) can grab the same port. At concurrency 4 this is a real collision risk. Move port allocation inside startDevServer and retry on conflict. A new internal tryStart helper watches dev server stdout/stderr for EADDRINUSE / "port X is in use" patterns and bails early instead of waiting the full 60s readiness timeout, then the outer loop picks a fresh port. Max 3 attempts before surfacing a bind failure. getAvailablePort is now module-private. The caller in fixture-test.ts reads the chosen port from the start result. --- test/e2e/lib/dev-server.ts | 133 ++++++++++++++++++++++++++++------- test/e2e/lib/fixture-test.ts | 7 +- 2 files changed, 112 insertions(+), 28 deletions(-) diff --git a/test/e2e/lib/dev-server.ts b/test/e2e/lib/dev-server.ts index c0a1f695..0763edc4 100644 --- a/test/e2e/lib/dev-server.ts +++ b/test/e2e/lib/dev-server.ts @@ -2,8 +2,19 @@ import { createServer } from "node:net"; import type { Subprocess } from "bun"; import { log } from "./logger.ts"; +/** + * Match the assorted ways framework dev servers report a port-in-use error. + * Next.js: "Port 3000 is in use ... using available port" + * Vite: "Port 5173 is in use, trying another one..." + * Nuxt / generic Node: "EADDRINUSE: address already in use 0.0.0.0:3000" + */ +const PORT_CONFLICT = /EADDRINUSE|address already in use|port \S+ is (already )?in use/i; + +const READINESS_TIMEOUT_MS = 60_000; +const MAX_BIND_ATTEMPTS = 3; + /** Find an available port by binding to port 0 and reading the assigned port. */ -export async function getAvailablePort(): Promise { +async function getAvailablePort(): Promise { return new Promise((resolve, reject) => { const server = createServer(); server.listen(0, () => { @@ -27,16 +38,32 @@ export function buildDevCommand(devCmd: string[], port: number): string[] { return [...devCmd, portFlag, String(port)]; } -/** Start a dev server and wait for it to respond. Returns the subprocess. */ -export async function startDevServer(opts: { +interface ReadyServer { + proc: Subprocess; + port: number; + stdout: string[]; + stderr: string[]; +} + +type StartAttempt = { kind: "ready"; value: ReadyServer } | { kind: "port_conflict" }; + +/** + * Single attempt to spawn a dev server on `port` and wait for it to respond. + * + * Returns `port_conflict` if either stream surfaces a port-in-use error + * before the server reports ready. Throws on any other failure (timeout, + * unexpected early exit). + */ +async function tryStart(opts: { devCmd: string[]; port: number; projectDir: string; fixtureName: string; -}): Promise<{ proc: Subprocess; stdout: string[]; stderr: string[] }> { +}): Promise { const { devCmd, port, projectDir, fixtureName } = opts; const fullCmd = buildDevCommand(devCmd, port); const stderrLines: string[] = []; + const stdoutLines: string[] = []; log(fixtureName, `starting dev server: bunx ${fullCmd.join(" ")} on port ${port}`); @@ -47,25 +74,24 @@ export async function startDevServer(opts: { env: { ...process.env, NODE_ENV: "development" }, }); - // Collect stderr for diagnostics - const reader = proc.stderr.getReader(); - const readStderr = async () => { + // Drain stderr in the background so we can scan it for port-conflict signals + // and surface it in error messages. + const stderrReader = proc.stderr.getReader(); + void (async () => { try { while (true) { - const { done, value } = await reader.read(); + const { done, value } = await stderrReader.read(); if (done) break; stderrLines.push(new TextDecoder().decode(value)); } } catch { // Process exited, stop reading } - }; - readStderr(); + })(); - // Collect stdout for diagnostics - const stdoutLines: string[] = []; + // Drain stdout the same way (some frameworks log "Port X in use" to stdout). const stdoutReader = proc.stdout.getReader(); - const readStdout = async () => { + void (async () => { try { while (true) { const { done, value } = await stdoutReader.read(); @@ -75,12 +101,38 @@ export async function startDevServer(opts: { } catch { // Process exited, stop reading } + })(); + + const hasPortConflict = () => + PORT_CONFLICT.test(stderrLines.join("")) || PORT_CONFLICT.test(stdoutLines.join("")); + + const killAndAwait = async () => { + proc.kill("SIGKILL"); + await proc.exited.catch(() => {}); }; - readStdout(); - // Poll until the server responds with 200 or a redirect - const deadline = Date.now() + 60_000; + const deadline = Date.now() + READINESS_TIMEOUT_MS; while (Date.now() < deadline) { + // Early-bail: framework reported the port is taken. Don't wait the full timeout. + if (hasPortConflict()) { + log(fixtureName, `port ${port} reported in use by dev server`); + await killAndAwait(); + return { kind: "port_conflict" }; + } + + // Some frameworks exit non-zero on bind failure rather than logging and + // retrying. Detect that as a port conflict if the output supports it. + if (proc.exitCode !== null) { + if (hasPortConflict()) { + log(fixtureName, `dev server exited (port ${port} in use)`); + return { kind: "port_conflict" }; + } + throw new Error( + `Dev server exited (code ${proc.exitCode}) before becoming ready on port ${port}.\n` + + `stdout:\n${stdoutLines.join("")}\nstderr:\n${stderrLines.join("")}`, + ); + } + try { const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(1000), @@ -88,23 +140,56 @@ export async function startDevServer(opts: { }); if (res.status < 500) { log(fixtureName, `dev server ready (status ${res.status})`); - return { proc, stdout: stdoutLines, stderr: stderrLines }; + return { kind: "ready", value: { proc, port, stdout: stdoutLines, stderr: stderrLines } }; } - await Bun.sleep(500); } catch { - await Bun.sleep(500); + // Connection refused / timeout while polling — keep waiting. } + await Bun.sleep(500); } - // Timeout - kill and throw - proc.kill("SIGKILL"); + // Readiness timeout. If output mentions a port conflict, treat as such so the + // outer loop can retry on a fresh port; otherwise surface a hard failure. + if (hasPortConflict()) { + await killAndAwait(); + return { kind: "port_conflict" }; + } + await killAndAwait(); throw new Error( - `Dev server did not respond within 60s on port ${port}.\n` + - `stdout:\n${stdoutLines.join("")}\n` + - `stderr:\n${stderrLines.join("")}`, + `Dev server did not respond within ${READINESS_TIMEOUT_MS / 1000}s on port ${port}.\n` + + `stdout:\n${stdoutLines.join("")}\nstderr:\n${stderrLines.join("")}`, ); } +/** + * Start a dev server on a free port and wait for it to respond. + * + * `getAvailablePort` has an unavoidable TOCTOU window: the port is freed + * before the dev server binds it, so a sibling fixture (or anything else on + * the host) can race in. We mitigate by retrying with a fresh port whenever + * `tryStart` reports the chosen port is taken. + */ +export async function startDevServer(opts: { + devCmd: string[]; + projectDir: string; + fixtureName: string; +}): Promise { + for (let attempt = 1; attempt <= MAX_BIND_ATTEMPTS; attempt++) { + const port = await getAvailablePort(); + const result = await tryStart({ ...opts, port }); + if (result.kind === "ready") return result.value; + + if (attempt === MAX_BIND_ATTEMPTS) { + throw new Error( + `Dev server could not bind to a free port after ${MAX_BIND_ATTEMPTS} attempts ` + + `(last attempted port: ${port}).`, + ); + } + log(opts.fixtureName, `port ${port} collided, retrying (${attempt + 1}/${MAX_BIND_ATTEMPTS})`); + } + throw new Error("unreachable"); +} + /** Kill a dev server process, falling back to SIGKILL after 5 seconds. */ export async function killDevServer(proc: Subprocess, fixtureName: string): Promise { log(fixtureName, "killing dev server"); diff --git a/test/e2e/lib/fixture-test.ts b/test/e2e/lib/fixture-test.ts index 012c231c..08041bc8 100644 --- a/test/e2e/lib/fixture-test.ts +++ b/test/e2e/lib/fixture-test.ts @@ -4,7 +4,7 @@ import { setupFixture } from "./fixture-setup.ts"; import type { FixtureConfig } from "./types.ts"; import { chromium } from "playwright"; import { clerkSetup, setupClerkTestingToken, clerk } from "@clerk/testing/playwright"; -import { getAvailablePort, startDevServer, killDevServer } from "./dev-server.ts"; +import { startDevServer, killDevServer } from "./dev-server.ts"; import { createTestUser, deleteTestUser } from "./test-user.ts"; import { log } from "./logger.ts"; @@ -188,15 +188,14 @@ export function runBrowserTest(getFixture: () => FixtureState, config: FixtureCo // 1. Create test user testUser = await createTestUser(configDir, secretKey, fixtureName); - // 2. Start dev server - port = await getAvailablePort(); + // 2. Start dev server (port is allocated inside, with retries on collision) const server = await startDevServer({ devCmd: config.devCmd, - port, projectDir, fixtureName, }); proc = server.proc; + port = server.port; stderrLines = server.stderr; stdoutLines = server.stdout; From 72794ce703c8384a9c774d9af17286df3ffdfe19 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 6 Apr 2026 14:22:13 -0600 Subject: [PATCH 6/7] ci: add dependabot config for bun and github-actions Configures weekly dependency updates for the root Bun workspace and the GitHub Actions used in CI. Fixture projects under test/e2e/fixtures/* are intentionally not listed: they are pinned snapshots of scaffolded framework output and are regenerated via `bun run e2e:refresh-fixtures`. Dependabot only checks the directories it is told about, so leaving them out means they will not receive update PRs. --- .github/dependabot.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..1866f2f0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + # Root workspace dependencies (Bun workspaces under packages/*). + # Fixture projects under test/e2e/fixtures/*/package.json are intentionally + # NOT configured here. They are pinned snapshots of scaffolded framework + # output and are regenerated via `bun run e2e:refresh-fixtures`. Letting + # Dependabot bump them would defeat the point of testing against specific + # framework versions. + - package-ecosystem: "bun" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" From 30caec2c4fccfd2956ead6c97cc0f850aa68b62a Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 6 Apr 2026 14:24:11 -0600 Subject: [PATCH 7/7] ci: add explicit exclude-paths for fixtures in dependabot Belt-and-suspenders defense. Dependabot does not auto-discover manifests, so the fixtures are already excluded by virtue of not being listed under `directory`. The new `exclude-paths` entry makes the intent explicit in the config and protects against future expansions of the configured paths (e.g. switching to `directories` with a glob). --- .github/dependabot.yml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1866f2f0..38c31011 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,13 +1,21 @@ version: 2 updates: # Root workspace dependencies (Bun workspaces under packages/*). - # Fixture projects under test/e2e/fixtures/*/package.json are intentionally - # NOT configured here. They are pinned snapshots of scaffolded framework - # output and are regenerated via `bun run e2e:refresh-fixtures`. Letting - # Dependabot bump them would defeat the point of testing against specific - # framework versions. + # + # Fixture projects under test/e2e/fixtures/*/package.json are pinned + # snapshots of scaffolded framework output, regenerated via + # `bun run e2e:refresh-fixtures`. Letting Dependabot bump them would + # defeat the point of testing against specific framework versions. + # + # Two layers of protection: + # 1. `directory: "/"` only configures the root, and Dependabot does + # not auto-discover manifests in unlisted paths. + # 2. `exclude-paths` is a defensive backstop in case the configured + # directory ever expands (e.g. to a glob via `directories`). - package-ecosystem: "bun" directory: "/" + exclude-paths: + - "test/e2e/fixtures/**" schedule: interval: "weekly" open-pull-requests-limit: 5