Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/rules/e2e.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ In CI, use `bunx playwright install chromium --with-deps` to include system-leve

Fixture files run in parallel (concurrency controlled by the runner, defaults to CPU count). 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.
Within each test file, `createGetFixture()` runs `setupFixture()` once in `beforeAll` and shares the result with both the build test and browser test. This avoids duplicating the expensive setup.

## Adding a new fixture

Expand All @@ -143,7 +143,7 @@ Within each test file, `useFixture()` runs `setupFixture()` once in `beforeAll`
Helper functions are in `test/e2e/lib/`:

- `fixture-setup.ts` - `setupFixture`
- `fixture-test.ts` - `useFixture`, `runFixtureTest`, `runBrowserTest`
- `fixture-test.ts` - `createGetFixture`, `runFixtureTest`, `runFileExistsTest`, `runBrowserTest`
- `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)
Expand Down
4 changes: 4 additions & 0 deletions .oxfmtrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": ["test/e2e/fixtures/**"]
}
5 changes: 4 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"rules": {
"unicorn/no-process-exit": "error",
"no-console": "error"
},
"ignorePatterns": ["test/e2e/fixtures/**"],
"overrides": [
{
"files": [
Expand All @@ -18,7 +20,8 @@
"files": [
"scripts/**",
"packages/cli-core/src/**/*.test.ts",
"packages/cli-core/src/test/**"
"packages/cli-core/src/test/**",
"test/e2e/**"
],
"rules": {
"no-console": "off"
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ bunx playwright install chromium

bun run test:e2e:op # Run all E2E tests (secrets from 1Password)
bun run test:e2e:op -- --filter react # Run only tests matching "react"
bun run test:e2e:op -- --debug # Verbose helper logging (sets CLERK_E2E_DEBUG=1)
bun run test:e2e:op -- --debug # Force serial execution for parsing logs (sets CLERK_E2E_DEBUG=1)
bun run test:e2e:op -- --har # Capture HAR files to test/e2e/.har for network debugging
bun run test:e2e:op -- --har-dir ./out # Capture HAR files to a custom directory
bun run e2e:refresh-fixtures # Re-scaffold fixture projects from upstream CLIs
Expand Down
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
"scripts": {
"build": "bun run --filter @clerk/cli-core build",
"dev": "bun run --cwd packages/cli-core dev",
"test": "bun run scripts/run-tests.ts --pattern 'packages/cli-core/src/**/*.test.ts' --pattern 'scripts/**/*.test.ts'",
"test:e2e": "bun run scripts/run-tests.ts --pattern 'test/e2e/*.test.ts' --retries 1",
"test": "bun test 'packages/cli-core/src/' 'scripts/' --parallel --only-failures",
"test:e2e": "bun test 'test/e2e/' --retry 1 --parallel --only-failures",
"test:e2e:op": "bun run scripts/run-e2e-op.ts",
"e2e:refresh-fixtures": "bun run scripts/refresh-e2e-fixtures.ts",
"typecheck": "bun run --filter './packages/*' typecheck && tsc --noEmit -p scripts/tsconfig.json",
"lint": "bun run --filter './packages/*' lint && oxlint -c .oxlintrc.json scripts/",
"format": "bun run --filter './packages/*' format && oxfmt --write scripts/",
"format:check": "bun run --filter './packages/*' format:check && oxfmt --check scripts/",
"typecheck": "bun run --filter './packages/*' typecheck && tsc --noEmit -p scripts/tsconfig.json && tsc --noEmit -p test/e2e/tsconfig.json",
"lint": "bun run --filter './packages/*' lint && oxlint -c .oxlintrc.json scripts/ test/e2e/",
"format": "bun run --filter './packages/*' format && oxfmt --write scripts/ test/e2e/",
"format:check": "bun run --filter './packages/*' format:check && oxfmt --check scripts/ test/e2e/",
"check:patches": "bun run scripts/check-patches.ts",
"build:compile": "bun run --filter @clerk/cli-core build:compile",
"version-packages": "bun changeset version",
Expand Down
3 changes: 2 additions & 1 deletion packages/cli-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json",
"lint": "oxlint src/",
"format": "oxfmt --write src/",
"format:check": "oxfmt --check src/"
"format:check": "oxfmt --check src/",
"test": "bun test src/ --parallel"
},
"dependencies": {
"@clerk/cli-extras": "workspace:*",
Expand Down
21 changes: 20 additions & 1 deletion packages/cli-core/src/test/integration/lib/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,24 @@ mock.module(

// ── Real config module ───────────────────────────────────────────────────────

export const { _setConfigDir, readConfig, setProfile } = await import("../../../lib/config.ts");
type ConfigModule = typeof import("../../../lib/config.ts");

let configModulePromise: Promise<ConfigModule> | null = null;

function getConfigModule(): Promise<ConfigModule> {
configModulePromise ??= import("../../../lib/config.ts");
return configModulePromise;
}

export async function readConfig(): ReturnType<ConfigModule["readConfig"]> {
return (await getConfigModule()).readConfig();
}

export async function setProfile(
...args: Parameters<ConfigModule["setProfile"]>
): ReturnType<ConfigModule["setProfile"]> {
return (await getConfigModule()).setProfile(...args);
}

// ── Mock data ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -526,6 +543,7 @@ function setEnv(key: string, value: string) {
*/
export async function setupTest(): Promise<TestHarness> {
const tempDir = await mkdtemp(join(tmpdir(), "clerk-integration-"));
const { _setConfigDir } = await getConfigModule();
_setConfigDir(tempDir);
process.cwd = () => tempDir;
setEnv("CLERK_PLATFORM_API_KEY", "test_platform_key");
Expand Down Expand Up @@ -558,6 +576,7 @@ export async function setupTest(): Promise<TestHarness> {
* temporary directory.
*/
export async function teardownTest(harness: TestHarness): Promise<void> {
const { _setConfigDir } = await getConfigModule();
currentHarness = null;
assertPromptQueuesEmpty();
http.assertRoutesConsumed();
Expand Down
134 changes: 134 additions & 0 deletions scripts/lib/fixture-deps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { describe, expect, test } from "bun:test";
import {
applyPackageJsonOverrides,
assertPinnedDependencyRanges,
resolveDependencySpecsToExactVersions,
validatePinnedDependencyRanges,
} from "./fixture-deps.ts";

describe("applyPackageJsonOverrides", () => {
test("merges dependency overrides into package.json", () => {
const pkg: {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
} = {
dependencies: {
existing: "^1",
},
};

applyPackageJsonOverrides(pkg, {
dependencies: {
added: "^2",
},
devDependencies: {
dev: "^3",
},
});

expect(pkg.dependencies).toEqual({
existing: "^1",
added: "^2",
});
expect(pkg.devDependencies).toEqual({
dev: "^3",
});
});
});

describe("validatePinnedDependencyRanges", () => {
test("allows satisfying generated dependencies without changing package.json", () => {
const pkg = {
dependencies: {
"fixture-framework": "1.2.3",
react: "^18",
},
};

const warnings = validatePinnedDependencyRanges(pkg, { "fixture-framework": "^1" });

expect(warnings).toEqual([]);
expect(pkg.dependencies["fixture-framework"]).toBe("1.2.3");
});

test("warns and keeps generated dependency when it falls outside the configured range", () => {
const pkg = {
dependencies: {
"fixture-framework": "2.0.0",
},
};

const warnings = validatePinnedDependencyRanges(pkg, { "fixture-framework": "^1" });

expect(pkg.dependencies["fixture-framework"]).toBe("2.0.0");
expect(warnings).toEqual([
'fixture-framework generated version "2.0.0" does not satisfy pinned range "^1"',
]);
});
});

describe("assertPinnedDependencyRanges", () => {
test("throws when pinned dependency validation fails", () => {
const pkg = {
dependencies: {
"fixture-framework": "2.0.0",
},
};

expect(() =>
assertPinnedDependencyRanges(pkg, { "fixture-framework": "^1" }, "fixture-name"),
).toThrow(
'Pinned dependency validation failed for fixture-name:\n - fixture-framework generated version "2.0.0" does not satisfy pinned range "^1"',
);
});
});

describe("resolveDependencySpecsToExactVersions", () => {
test("rewrites generated dependency ranges to exact versions", async () => {
const pkg = {
dependencies: {
"@clerk/react": "latest",
react: "^19.0.0",
"already-exact": "1.2.3",
},
devDependencies: {
typescript: "~5.9.0",
},
};
const resolved: string[] = [];
const versions: Record<string, string> = {
"react@^19.0.0": "19.2.6",
"typescript@~5.9.0": "5.9.3",
};

await resolveDependencySpecsToExactVersions(pkg, async (name, spec) => {
resolved.push(`${name}@${spec}`);
return versions[`${name}@${spec}`]!;
});

expect(pkg).toEqual({
dependencies: {
"@clerk/react": "latest",
react: "19.2.6",
"already-exact": "1.2.3",
},
devDependencies: {
typescript: "5.9.3",
},
});
expect(resolved).toEqual(["react@^19.0.0", "typescript@~5.9.0"]);
});

test("resolves pinned dependency ranges to exact satisfying versions", async () => {
const pkg = {
dependencies: {
"fixture-framework": "^1",
},
};

await resolveDependencySpecsToExactVersions(pkg, async () => "1.2.3");

expect(pkg.dependencies["fixture-framework"]).toBe("1.2.3");
expect(validatePinnedDependencyRanges(pkg, { "fixture-framework": "^1" })).toEqual([]);
});
});
119 changes: 119 additions & 0 deletions scripts/lib/fixture-deps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import semver from "semver";

type PackageJson = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};

type DependencyField = "dependencies" | "devDependencies";

export type DependencyVersionResolver = (name: string, spec: string) => string | Promise<string>;

export type PackageJsonOverrides = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};

const DEPENDENCY_FIELDS: DependencyField[] = ["dependencies", "devDependencies"];
const EXACT_VERSION_EXCLUDED_PACKAGE_SCOPES = ["@clerk/"];

export function applyPackageJsonOverrides(
pkg: PackageJson,
overrides: PackageJsonOverrides | undefined,
): void {
if (!overrides) return;

if (overrides.dependencies) {
pkg.dependencies = { ...pkg.dependencies, ...overrides.dependencies };
}

if (overrides.devDependencies) {
pkg.devDependencies = { ...pkg.devDependencies, ...overrides.devDependencies };
}
}

export async function resolveDependencySpecsToExactVersions(
pkg: PackageJson,
resolveVersion: DependencyVersionResolver,
): Promise<void> {
for (const field of DEPENDENCY_FIELDS) {
const deps = pkg[field];
if (!deps) continue;

for (const [name, spec] of Object.entries(deps)) {
if (EXACT_VERSION_EXCLUDED_PACKAGE_SCOPES.some((scope) => name.startsWith(scope))) {
continue;
}

const exact = semver.valid(spec);
if (exact) {
deps[name] = exact;
continue;
}

const resolved = await resolveVersion(name, spec);
const resolvedExact = semver.valid(resolved);
if (!resolvedExact) {
throw new Error(`${name}@${spec} resolved to non-exact version "${resolved}"`);
}

deps[name] = resolvedExact;
}
}
}

function isSpecWithinRange(spec: string, range: string): boolean {
if (!semver.validRange(range)) return false;

const exact = semver.valid(spec);
if (exact) return semver.satisfies(exact, range);

const specRange = semver.validRange(spec);
return Boolean(specRange && semver.subset(specRange, range));
}

export function validatePinnedDependencyRanges(
pkg: PackageJson,
pinnedDependencyRanges: Record<string, string> | undefined,
): string[] {
if (!pinnedDependencyRanges) return [];

const warnings: string[] = [];

for (const [dep, range] of Object.entries(pinnedDependencyRanges)) {
const deps = pkg.dependencies;
const devDeps = pkg.devDependencies;
const target = deps?.[dep] !== undefined ? deps : devDeps?.[dep] !== undefined ? devDeps : null;

if (!target) {
warnings.push(`${dep} was not generated, so pinned range "${range}" was not applied`);
continue;
}

const generatedSpec = target[dep]!;
if (!isSpecWithinRange(generatedSpec, range)) {
warnings.push(
`${dep} generated version "${generatedSpec}" does not satisfy pinned range "${range}"`,
);
continue;
}
}

return warnings;
}

export function assertPinnedDependencyRanges(
pkg: PackageJson,
pinnedDependencyRanges: Record<string, string> | undefined,
fixtureName: string,
): void {
const errors = validatePinnedDependencyRanges(pkg, pinnedDependencyRanges);
if (errors.length === 0) return;

throw new Error(
[
`Pinned dependency validation failed for ${fixtureName}:`,
...errors.map((error) => ` - ${error}`),
].join("\n"),
);
}
Loading