Skip to content

Commit d043690

Browse files
committed
fix(deploy): route test failures through api path
1 parent 71c3840 commit d043690

4 files changed

Lines changed: 149 additions & 75 deletions

File tree

packages/cli-core/src/cli-program.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -910,43 +910,40 @@ Tutorial — enable completions for your shell:
910910
createOption(
911911
"--test-force-production-instance",
912912
"Force deploy to use a mocked production instance",
913-
).hideHelp(),
913+
),
914914
)
915915
.addOption(
916916
createOption(
917917
"--test-fail-production-instance-check",
918918
"Simulate a deploy failure while checking for a production instance",
919-
).hideHelp(),
919+
),
920920
)
921921
.addOption(
922922
createOption(
923923
"--test-fail-domain-lookup",
924924
"Simulate a deploy failure while loading the production domain",
925-
).hideHelp(),
925+
),
926926
)
927927
.addOption(
928928
createOption(
929929
"--test-fail-validate-cloning",
930930
"Simulate a deploy failure while validating cloning",
931-
).hideHelp(),
931+
),
932932
)
933933
.addOption(
934934
createOption(
935935
"--test-fail-create-production-instance",
936936
"Simulate a deploy failure while creating the production instance",
937-
).hideHelp(),
937+
),
938938
)
939939
.addOption(
940-
createOption(
941-
"--test-fail-dns-verification",
942-
"Simulate a deploy failure while verifying DNS",
943-
).hideHelp(),
940+
createOption("--test-fail-dns-verification", "Simulate a deploy failure while verifying DNS"),
944941
)
945942
.addOption(
946943
createOption(
947944
"--test-fail-oauth-save",
948945
"Simulate a deploy failure while saving OAuth credentials",
949-
).hideHelp(),
946+
),
950947
)
951948
.action(deploy);
952949

packages/cli-core/src/commands/deploy/index.test.ts

Lines changed: 79 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
33
import { join, relative } from "node:path";
44
import { tmpdir } from "node:os";
55
import { captureLog, promptsStubs, listageStubs } from "../../test/lib/stubs.ts";
6-
import { EXIT_CODE, UserAbortError, type CliError } from "../../lib/errors.ts";
6+
import { CliError, EXIT_CODE, UserAbortError } from "../../lib/errors.ts";
77

88
const mockIsAgent = mock();
99
let _modeOverride: string | undefined;
@@ -197,6 +197,20 @@ describe("deploy", () => {
197197
return captured.run(() => deploy(options));
198198
}
199199

200+
async function expectTestApiFailure(promise: Promise<unknown>, message: string): Promise<Error> {
201+
let error: Error | undefined;
202+
try {
203+
await promise;
204+
} catch (caught) {
205+
error = caught as Error;
206+
}
207+
208+
expect(error).toBeInstanceOf(Error);
209+
expect(error).not.toBeInstanceOf(CliError);
210+
expect(error?.message).toContain(message);
211+
return error!;
212+
}
213+
200214
async function linkedProject(profile: Record<string, unknown> = {}) {
201215
tempDir = await mkdtemp(join(tmpdir(), "clerk-deploy-test-"));
202216
_setConfigDir(tempDir);
@@ -857,14 +871,47 @@ describe("deploy", () => {
857871
await linkedProject();
858872
mockIsAgent.mockReturnValue(false);
859873

860-
await expect(runDeploy({ testFailProductionInstanceCheck: true })).rejects.toThrow(
874+
await expectTestApiFailure(
875+
runDeploy({ testFailProductionInstanceCheck: true }),
861876
"Simulated deploy failure: production instance check.",
862877
);
863878

864-
expect(mockFetchApplication).not.toHaveBeenCalled();
879+
expect(mockFetchApplication).toHaveBeenCalledWith("app_xyz789");
865880
expect(mockFetchInstanceConfig).not.toHaveBeenCalled();
866881
});
867882

883+
test("--test-fail-production-instance-check prints one Failed status in interactive output", async () => {
884+
await linkedProject();
885+
mockIsAgent.mockReturnValue(false);
886+
stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);
887+
const originalCi = process.env.CI;
888+
const originalIsTty = process.stderr.isTTY;
889+
Object.defineProperty(process.stderr, "isTTY", { configurable: true, value: true });
890+
delete process.env.CI;
891+
892+
try {
893+
await expectTestApiFailure(
894+
runDeploy({ testFailProductionInstanceCheck: true }),
895+
"Simulated deploy failure: production instance check.",
896+
);
897+
} finally {
898+
Object.defineProperty(process.stderr, "isTTY", {
899+
configurable: true,
900+
value: originalIsTty,
901+
});
902+
if (originalCi === undefined) {
903+
delete process.env.CI;
904+
} else {
905+
process.env.CI = originalCi;
906+
}
907+
}
908+
909+
const terminalOutput = stripAnsi(
910+
stderrSpy.mock.calls.map((call: unknown[]) => String(call[0])).join(""),
911+
);
912+
expect(terminalOutput.match(/\bFailed\b/g) ?? []).toHaveLength(1);
913+
});
914+
868915
test("--test-fail-domain-lookup simulates production domain lookup failure", async () => {
869916
await linkedProject();
870917
mockLiveProduction({
@@ -873,22 +920,26 @@ describe("deploy", () => {
873920
});
874921
mockIsAgent.mockReturnValue(false);
875922

876-
await expect(runDeploy({ testFailDomainLookup: true })).rejects.toThrow(
923+
await expectTestApiFailure(
924+
runDeploy({ testFailDomainLookup: true }),
877925
"Simulated deploy failure: production domain lookup.",
878926
);
879927

880-
expect(mockListApplicationDomains).not.toHaveBeenCalled();
928+
expect(mockListApplicationDomains).toHaveBeenCalledWith("app_xyz789");
881929
});
882930

883931
test("--test-fail-validate-cloning simulates cloning validation failure", async () => {
884932
await linkedProject();
885933
mockIsAgent.mockReturnValue(false);
886934

887-
await expect(runDeploy({ testFailValidateCloning: true })).rejects.toThrow(
935+
await expectTestApiFailure(
936+
runDeploy({ testFailValidateCloning: true }),
888937
"Simulated deploy failure: cloning validation.",
889938
);
890939

891-
expect(mockValidateCloning).not.toHaveBeenCalled();
940+
expect(mockValidateCloning).toHaveBeenCalledWith("app_xyz789", {
941+
clone_instance_id: "ins_dev_123",
942+
});
892943
expect(mockCreateProductionInstance).not.toHaveBeenCalled();
893944
});
894945

@@ -898,11 +949,15 @@ describe("deploy", () => {
898949
mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
899950
mockInput.mockResolvedValueOnce("example.com");
900951

901-
await expect(runDeploy({ testFailCreateProductionInstance: true })).rejects.toThrow(
952+
await expectTestApiFailure(
953+
runDeploy({ testFailCreateProductionInstance: true }),
902954
"Simulated deploy failure: production instance creation.",
903955
);
904956

905-
expect(mockCreateProductionInstance).not.toHaveBeenCalled();
957+
expect(mockCreateProductionInstance).toHaveBeenCalledWith("app_xyz789", {
958+
home_url: "example.com",
959+
clone_instance_id: "ins_dev_123",
960+
});
906961
});
907962

908963
test("--test-fail-dns-verification simulates DNS verification failure", async () => {
@@ -920,23 +975,13 @@ describe("deploy", () => {
920975
mockPassword.mockResolvedValueOnce("google-secret");
921976
mockPatchInstanceConfig.mockResolvedValueOnce({});
922977

923-
await runDeploy({ testFailDnsVerification: true });
924-
const err = stripAnsi(captured.err);
978+
await expectTestApiFailure(
979+
runDeploy({ testFailDnsVerification: true }),
980+
"Simulated deploy failure: DNS verification.",
981+
);
925982

926-
expect(mockGetDeployStatus).not.toHaveBeenCalled();
927-
expect(err).toContain("DNS propagation can take time");
928-
expect(err).toContain("Add the following records at your DNS provider:");
929-
expect(err).toContain("Host: clerk.example.com");
930-
expect(err).toContain("Value: frontend-api.clerk.services");
931-
expect(err).toContain("Skipping DNS verification for now.");
932-
expect(err).toContain("Saved Google OAuth credentials");
933-
expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock", {
934-
connection_oauth_google: {
935-
enabled: true,
936-
client_id: "google-client-id.apps.googleusercontent.com",
937-
client_secret: "google-secret",
938-
},
939-
});
983+
expect(mockGetDeployStatus).toHaveBeenCalledWith("app_xyz789", "ins_prod_mock");
984+
expect(mockPatchInstanceConfig).not.toHaveBeenCalled();
940985
});
941986

942987
test("--test-fail-oauth-save simulates OAuth credential save failure", async () => {
@@ -953,11 +998,18 @@ describe("deploy", () => {
953998
mockInput.mockResolvedValueOnce("google-client-id.apps.googleusercontent.com");
954999
mockPassword.mockResolvedValueOnce("google-secret");
9551000

956-
await expect(runDeploy({ testFailOAuthSave: true })).rejects.toThrow(
1001+
await expectTestApiFailure(
1002+
runDeploy({ testFailOAuthSave: true }),
9571003
"Simulated deploy failure: OAuth credential save.",
9581004
);
9591005

960-
expect(mockPatchInstanceConfig).not.toHaveBeenCalled();
1006+
expect(mockPatchInstanceConfig).toHaveBeenCalledWith("app_xyz789", "ins_prod_123", {
1007+
connection_oauth_google: {
1008+
enabled: true,
1009+
client_id: "google-client-id.apps.googleusercontent.com",
1010+
client_secret: "google-secret",
1011+
},
1012+
});
9611013
});
9621014

9631015
test("plain deploy resumes DNS verification from live API state", async () => {

packages/cli-core/src/commands/deploy/index.ts

Lines changed: 62 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import { NEXT_STEPS } from "../../lib/next-steps.ts";
33
import { isInsideGutter, log, setPrefixTone, type PrefixTone } from "../../lib/log.ts";
44
import { sleep } from "../../lib/sleep.ts";
55
import { bar, intro, outro, withSpinner } from "../../lib/spinner.ts";
6-
import { CliError, UserAbortError, isPromptExitError, throwUsageError } from "../../lib/errors.ts";
6+
import {
7+
PlapiError,
8+
UserAbortError,
9+
isPromptExitError,
10+
throwUsageError,
11+
} from "../../lib/errors.ts";
712
import { resolveProfile, setProfile } from "../../lib/config.ts";
813
import {
914
type Application,
@@ -211,14 +216,15 @@ async function resolveDeployContext(options: DeployOptions): Promise<DeployConte
211216
profileKey: resolved.path,
212217
profile: resolved.profile,
213218
...testFlags,
214-
...(await withSpinner("Checking for production instance...", () => {
215-
if (testFlags.testFailProductionInstanceCheck) {
216-
throw testDeployFailure("production instance check");
217-
}
218-
return resolveLiveApplicationContext(resolved.profile, {
219-
forceMockProductionInstance: testFlags.testForceProductionInstance,
220-
});
221-
})),
219+
...(await withSpinner("Checking for production instance...", () =>
220+
withTestFailureAfterApiCall(
221+
resolveLiveApplicationContext(resolved.profile, {
222+
forceMockProductionInstance: testFlags.testForceProductionInstance,
223+
}),
224+
testFlags.testFailProductionInstanceCheck,
225+
"production instance check",
226+
),
227+
)),
222228
};
223229
}
224230

@@ -245,8 +251,24 @@ function resolveTestDeployFlags(
245251
};
246252
}
247253

248-
function testDeployFailure(step: string): CliError {
249-
return new CliError(`Simulated deploy failure: ${step}.`);
254+
function simulatedDeployApiFailure(step: string): PlapiError {
255+
return new PlapiError(
256+
500,
257+
JSON.stringify({ errors: [{ message: `Simulated deploy failure: ${step}.` }] }),
258+
"clerk deploy test flag",
259+
);
260+
}
261+
262+
async function withTestFailureAfterApiCall<T>(
263+
promise: Promise<T>,
264+
shouldFail: boolean | undefined,
265+
step: string,
266+
): Promise<T> {
267+
const result = await promise;
268+
if (shouldFail) {
269+
throw simulatedDeployApiFailure(step);
270+
}
271+
return result;
250272
}
251273

252274
async function resolveLiveApplicationContext(
@@ -516,13 +538,13 @@ async function resolveLiveDeploySnapshot(
516538
}
517539

518540
async function loadProductionDomain(ctx: DeployContext): Promise<ApplicationDomain | undefined> {
519-
if (ctx.testFailDomainLookup) {
520-
throw testDeployFailure("production domain lookup");
521-
}
522541
if (ctx.testForceProductionInstance) {
523542
return mockProductionDomain();
524543
}
525544
const domains = await listApplicationDomains(ctx.appId);
545+
if (ctx.testFailDomainLookup) {
546+
throw simulatedDeployApiFailure("production domain lookup");
547+
}
526548
return domains.data.find((domain) => !domain.is_satellite) ?? domains.data[0];
527549
}
528550

@@ -635,10 +657,11 @@ function discoverEnabledOAuthProviders(config: Record<string, unknown>): Discove
635657

636658
async function runValidateCloning(ctx: DeployContext): Promise<void> {
637659
await withSpinner("Validating subscription compatibility...", async () => {
638-
if (ctx.testFailValidateCloning) {
639-
throw testDeployFailure("cloning validation");
640-
}
641-
await validateCloning(ctx.appId, { clone_instance_id: ctx.developmentInstanceId });
660+
await withTestFailureAfterApiCall(
661+
validateCloning(ctx.appId, { clone_instance_id: ctx.developmentInstanceId }),
662+
ctx.testFailValidateCloning,
663+
"cloning validation",
664+
);
642665
});
643666
}
644667

@@ -647,13 +670,14 @@ async function createProductionInstance(
647670
domain: string,
648671
): Promise<ProductionInstanceResponse> {
649672
return withSpinner("Creating production instance...", async () => {
650-
if (ctx.testFailCreateProductionInstance) {
651-
throw testDeployFailure("production instance creation");
652-
}
653-
return apiCreateProductionInstance(ctx.appId, {
654-
home_url: domain,
655-
clone_instance_id: ctx.developmentInstanceId,
656-
});
673+
return withTestFailureAfterApiCall(
674+
apiCreateProductionInstance(ctx.appId, {
675+
home_url: domain,
676+
clone_instance_id: ctx.developmentInstanceId,
677+
}),
678+
ctx.testFailCreateProductionInstance,
679+
"production instance creation",
680+
);
657681
});
658682
}
659683

@@ -757,11 +781,11 @@ async function runDnsVerification(
757781
}
758782

759783
const verified = await withSpinner(`Verifying DNS for ${state.domain}...`, async () => {
760-
if (ctx.testFailDnsVerification) {
761-
return false;
762-
}
763784
for (let attempt = 0; attempt < DEPLOY_STATUS_MAX_POLLS; attempt++) {
764785
const result = await getDeployStatus(ctx.appId, productionInstanceId);
786+
if (ctx.testFailDnsVerification) {
787+
throw simulatedDeployApiFailure("DNS verification");
788+
}
765789
if (result.status === "complete") return true;
766790
await sleep(DEPLOY_STATUS_POLL_INTERVAL_MS);
767791
}
@@ -882,15 +906,16 @@ async function collectAndSaveOAuthCredentials(
882906
);
883907

884908
await withSpinner(`Saving ${label} OAuth credentials...`, async () => {
885-
if (ctx.testFailOAuthSave) {
886-
throw testDeployFailure("OAuth credential save");
887-
}
888-
await patchInstanceConfig(ctx.appId, productionInstanceId, {
889-
[`connection_oauth_${provider}`]: {
890-
enabled: true,
891-
...credentials,
892-
},
893-
});
909+
await withTestFailureAfterApiCall(
910+
patchInstanceConfig(ctx.appId, productionInstanceId, {
911+
[`connection_oauth_${provider}`]: {
912+
enabled: true,
913+
...credentials,
914+
},
915+
}),
916+
ctx.testFailOAuthSave,
917+
"OAuth credential save",
918+
);
894919
});
895920
log.success(`Saved ${label} OAuth credentials`);
896921
return true;

0 commit comments

Comments
 (0)