Skip to content

Commit 80bb450

Browse files
fix(credentials): replace lock busy-wait with async timer backoff
1 parent 272b196 commit 80bb450

7 files changed

Lines changed: 87 additions & 89 deletions

File tree

src/commands/auth.test.ts

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ describe('runConfigure', () => {
165165
const { capture, deps } = makeCapture();
166166
// Pre-existing dev profile — re-running configure interactively must keep it
167167
// (the internal dogfooding flow) without ever prompting for the endpoint.
168-
writeProfile(
168+
await writeProfile(
169169
'dev',
170170
{ apiKey: 'sk-old', apiUrl: 'https://api.example.com:8443' },
171171
{ path: credentialsPath },
@@ -350,7 +350,7 @@ describe('runConfigure', () => {
350350
it('dogfood-2026-05-25 — --from-env without TESTSPRITE_API_URL inherits existing profile api_url AND validates against it', async () => {
351351
const { capture, deps } = makeCapture();
352352
// Pre-write an existing profile with a custom (non-default) endpoint.
353-
writeProfile(
353+
await writeProfile(
354354
'default',
355355
{ apiKey: 'sk-old', apiUrl: 'https://api.example.com' },
356356
{ path: credentialsPath },
@@ -398,7 +398,7 @@ describe('runConfigure', () => {
398398

399399
it('dogfood-2026-05-25 — --endpoint-url flag overrides existing profile api_url', async () => {
400400
const { capture, deps } = makeCapture();
401-
writeProfile(
401+
await writeProfile(
402402
'default',
403403
{ apiKey: 'sk-old', apiUrl: 'https://api.example.com' },
404404
{ path: credentialsPath },
@@ -428,7 +428,7 @@ describe('runConfigure', () => {
428428
it('dogfood-2026-05-25 — no advisory when inherited url equals DEFAULT_API_URL', async () => {
429429
const { capture, deps } = makeCapture();
430430
// Existing profile has the default prod endpoint — no advisory needed.
431-
writeProfile(
431+
await writeProfile(
432432
'default',
433433
{ apiKey: 'sk-old', apiUrl: 'https://api.testsprite.com' },
434434
{ path: credentialsPath },
@@ -504,7 +504,7 @@ describe('runConfigure', () => {
504504
// An exported-but-empty env var (`export TESTSPRITE_API_URL=`) must not
505505
// short-circuit the `??` chain to an empty endpoint; it should fall through
506506
// to the existing profile's api_url.
507-
writeProfile(
507+
await writeProfile(
508508
'default',
509509
{ apiKey: 'sk-old', apiUrl: 'https://api.example.com:8443' },
510510
{ path: credentialsPath },
@@ -610,7 +610,7 @@ describe('runWhoami', () => {
610610
}
611611

612612
it('calls GET /me using the configured profile and prints text output', async () => {
613-
writeProfile(
613+
await writeProfile(
614614
'default',
615615
{ apiKey: 'sk-stored', apiUrl: 'https://api.example.com' },
616616
{ path: credentialsPath },
@@ -633,7 +633,7 @@ describe('runWhoami', () => {
633633
});
634634

635635
it('emits JSON when --output json', async () => {
636-
writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
636+
await writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
637637
const { capture, deps } = makeCapture();
638638
await runWhoami(
639639
{ profile: 'default', output: 'json', debug: false },
@@ -644,7 +644,7 @@ describe('runWhoami', () => {
644644
});
645645

646646
it('L1788: text output includes the resolved endpoint URL', async () => {
647-
writeProfile(
647+
await writeProfile(
648648
'default',
649649
{ apiKey: 'sk-stored', apiUrl: 'https://api.example.com' },
650650
{ path: credentialsPath },
@@ -672,7 +672,7 @@ describe('runWhoami', () => {
672672
});
673673

674674
it('L1788: JSON output does NOT add endpoint (raw /me envelope is passed through)', async () => {
675-
writeProfile(
675+
await writeProfile(
676676
'default',
677677
{ apiKey: 'sk-stored', apiUrl: 'https://api.example.com' },
678678
{ path: credentialsPath },
@@ -701,7 +701,7 @@ describe('runWhoami', () => {
701701
});
702702

703703
it('L1866: renders email + name in text mode when the backend supplies them', async () => {
704-
writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
704+
await writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
705705
const { capture, deps } = makeCapture();
706706
const meWithEmail = new Response(
707707
JSON.stringify({ ...sampleMe, email: 'alice@example.com', displayName: 'Alice' }),
@@ -718,7 +718,7 @@ describe('runWhoami', () => {
718718
});
719719

720720
it('L1866: omits email/name lines when the backend does not return them', async () => {
721-
writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
721+
await writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
722722
const { capture, deps } = makeCapture();
723723
await runWhoami(
724724
{ profile: 'default', output: 'text', debug: false },
@@ -731,7 +731,7 @@ describe('runWhoami', () => {
731731
});
732732

733733
it('L1866: passes email through verbatim in JSON mode when present', async () => {
734-
writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
734+
await writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
735735
const { capture, deps } = makeCapture();
736736
const meWithEmail = new Response(JSON.stringify({ ...sampleMe, email: 'alice@example.com' }), {
737737
status: 200,
@@ -769,7 +769,7 @@ describe('runWhoami', () => {
769769
});
770770

771771
it('emits debug events to stderr when debug is enabled', async () => {
772-
writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
772+
await writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
773773
const { capture, deps } = makeCapture();
774774
await runWhoami(
775775
{ profile: 'default', output: 'json', debug: true },
@@ -782,7 +782,7 @@ describe('runWhoami', () => {
782782
});
783783

784784
it('forwards server AUTH_INVALID with exit code 3', async () => {
785-
writeProfile('default', { apiKey: 'sk-bad' }, { path: credentialsPath });
785+
await writeProfile('default', { apiKey: 'sk-bad' }, { path: credentialsPath });
786786
const { deps } = makeCapture();
787787
const errorBody = {
788788
error: {
@@ -812,7 +812,7 @@ describe('runWhoami', () => {
812812
...sampleMe,
813813
scopes: ['read:projects', 'read:tests'],
814814
};
815-
writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
815+
await writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
816816
const { capture, deps } = makeCapture();
817817
const fetchImpl = makeFetch(
818818
new Response(JSON.stringify(readOnlyMe), {
@@ -835,7 +835,7 @@ describe('runWhoami', () => {
835835
...sampleMe,
836836
scopes: ['read:projects', 'read:tests', 'write:tests', 'run:tests'],
837837
};
838-
writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
838+
await writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
839839
const { capture, deps } = makeCapture();
840840
const fetchImpl = makeFetch(
841841
new Response(JSON.stringify(fullMe), {
@@ -856,7 +856,7 @@ describe('runWhoami', () => {
856856
...sampleMe,
857857
scopes: ['read:projects', 'read:tests'],
858858
};
859-
writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
859+
await writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
860860
const { capture, deps } = makeCapture();
861861
const fetchImpl = makeFetch(
862862
new Response(JSON.stringify(readOnlyMe), {
@@ -878,8 +878,8 @@ describe('runWhoami', () => {
878878

879879
describe('runLogout', () => {
880880
it('removes the profile and reports success', async () => {
881-
writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
882-
writeProfile('dev', { apiKey: 'sk-dev' }, { path: credentialsPath });
881+
await writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
882+
await writeProfile('dev', { apiKey: 'sk-dev' }, { path: credentialsPath });
883883
const { capture, deps } = makeCapture();
884884
await runLogout(
885885
{ profile: 'default', output: 'text', debug: false },
@@ -927,7 +927,7 @@ describe('createAuthCommand wiring', () => {
927927
});
928928

929929
it('remove deletes the active profile and exits 0', async () => {
930-
writeProfile('default', { apiKey: 'sk-remove' }, { path: credentialsPath });
930+
await writeProfile('default', { apiKey: 'sk-remove' }, { path: credentialsPath });
931931
const { deps } = makeCapture();
932932
const auth = createAuthCommand({ ...deps, credentialsPath });
933933
auth.exitOverride();
@@ -937,7 +937,7 @@ describe('createAuthCommand wiring', () => {
937937
});
938938

939939
it('deprecated `whoami` alias emits a deprecation notice pointing at `auth status`', async () => {
940-
writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
940+
await writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
941941
const { capture, deps } = makeCapture();
942942
const auth = createAuthCommand({
943943
...deps,
@@ -953,7 +953,7 @@ describe('createAuthCommand wiring', () => {
953953
});
954954

955955
it('whoami uses injected fetch and exits 0', async () => {
956-
writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
956+
await writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
957957
const { deps } = makeCapture();
958958
const fetchImpl = vi.fn(
959959
async () =>
@@ -970,7 +970,7 @@ describe('createAuthCommand wiring', () => {
970970
});
971971

972972
it('L1802: `status` alias resolves to the whoami action', async () => {
973-
writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
973+
await writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
974974
const { deps } = makeCapture();
975975
const fetchImpl = vi.fn(
976976
async () =>
@@ -987,7 +987,7 @@ describe('createAuthCommand wiring', () => {
987987
});
988988

989989
it('logout removes the profile', async () => {
990-
writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
990+
await writeProfile('default', { apiKey: 'sk' }, { path: credentialsPath });
991991
const { deps } = makeCapture();
992992
const auth = createAuthCommand({ ...deps, credentialsPath });
993993
auth.exitOverride();

src/commands/auth.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ export async function runConfigure(opts: ConfigureOptions, deps: AuthDeps = {}):
168168
);
169169
}
170170

171-
writeProfile(opts.profile, { apiKey, apiUrl }, { path: credentialsPath });
171+
await writeProfile(opts.profile, { apiKey, apiUrl }, { path: credentialsPath });
172172

173173
out.print({ profile: opts.profile, apiUrl, status: 'configured' }, data => {
174174
const d = data as { profile: string; apiUrl: string };
@@ -258,7 +258,7 @@ export async function runLogout(opts: CommonOptions, deps: AuthDeps = {}): Promi
258258
return;
259259
}
260260

261-
const removed = deleteProfile(opts.profile, { path: credentialsPath });
261+
const removed = await deleteProfile(opts.profile, { path: credentialsPath });
262262
out.print({ profile: opts.profile, status: removed ? 'logged_out' : 'no_credentials' }, data => {
263263
const d = data as { profile: string; status: string };
264264
return d.status === 'logged_out'

src/commands/usage.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ describe('runUsage — dry-run', () => {
9797

9898
describe('runUsage — real path without credits (current backend)', () => {
9999
it('returns the /me response and emits a note about missing balance', async () => {
100-
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
100+
await writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
101101
const { capture, deps } = makeCapture();
102102
const result = await runUsage(
103103
{ profile: 'default', output: 'text', debug: false },
@@ -116,7 +116,7 @@ describe('runUsage — real path without credits (current backend)', () => {
116116
});
117117

118118
it('text output includes identity fields even without credits', async () => {
119-
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
119+
await writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
120120
const { capture, deps } = makeCapture();
121121
await runUsage(
122122
{ profile: 'default', output: 'text', debug: false },
@@ -132,7 +132,7 @@ describe('runUsage — real path without credits (current backend)', () => {
132132
});
133133

134134
it('JSON output passes the raw /me response through (no credits key present)', async () => {
135-
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
135+
await writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
136136
const { capture, deps } = makeCapture();
137137
await runUsage(
138138
{ profile: 'default', output: 'json', debug: false },
@@ -150,7 +150,7 @@ describe('runUsage — real path without credits (current backend)', () => {
150150

151151
describe('runUsage — real path with credits (future backend)', () => {
152152
it('renders balance block when credits + subPlan are present', async () => {
153-
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
153+
await writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
154154
const { capture, deps } = makeCapture();
155155
await runUsage(
156156
{ profile: 'default', output: 'text', debug: false },
@@ -173,7 +173,7 @@ describe('runUsage — real path with credits (future backend)', () => {
173173
});
174174

175175
it('does NOT emit the missing-balance note when credits are present', async () => {
176-
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
176+
await writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
177177
const { capture, deps } = makeCapture();
178178
await runUsage(
179179
{ profile: 'default', output: 'text', debug: false },
@@ -189,7 +189,7 @@ describe('runUsage — real path with credits (future backend)', () => {
189189
});
190190

191191
it('emits low-balance warning when credits < creditsPerRun', async () => {
192-
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
192+
await writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
193193
const { capture, deps } = makeCapture();
194194
const lowBalance: UsageResponse = { ...meWithCredits, credits: 1, creditsPerRun: 2 };
195195
await runUsage(
@@ -206,7 +206,7 @@ describe('runUsage — real path with credits (future backend)', () => {
206206
});
207207

208208
it('emits free-plan upgrade hint when subPlan is Free', async () => {
209-
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
209+
await writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
210210
const { capture, deps } = makeCapture();
211211
const freePlan: UsageResponse = { ...meWithCredits, subPlan: 'Free', credits: 10 };
212212
await runUsage(
@@ -223,7 +223,7 @@ describe('runUsage — real path with credits (future backend)', () => {
223223
});
224224

225225
it('JSON output passes credits and subPlan through verbatim', async () => {
226-
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
226+
await writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
227227
const { capture, deps } = makeCapture();
228228
await runUsage(
229229
{ profile: 'default', output: 'json', debug: false },
@@ -249,7 +249,7 @@ describe('runUsage — error handling', () => {
249249
});
250250

251251
it('forwards server AUTH_INVALID with exit code 3', async () => {
252-
writeProfile('default', { apiKey: 'sk-bad' }, { path: credentialsPath });
252+
await writeProfile('default', { apiKey: 'sk-bad' }, { path: credentialsPath });
253253
const { deps } = makeCapture();
254254
const errorBody = {
255255
error: {
@@ -273,7 +273,7 @@ describe('runUsage — error handling', () => {
273273
});
274274

275275
it('re-maps INSUFFICIENT_CREDITS (rate_limited with credits sub-case) to exit 12', async () => {
276-
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
276+
await writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
277277
const { deps } = makeCapture();
278278
const creditError = {
279279
error: {

src/lib/config.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,17 @@ describe('loadConfig', () => {
2121
expect(config.profile).toBe('default');
2222
});
2323

24-
it('honors TESTSPRITE_API_URL over the file', () => {
25-
writeProfile('default', { apiUrl: 'https://from-file.example.com' }, { path: credentialsPath });
24+
it('honors TESTSPRITE_API_URL over the file', async () => {
25+
await writeProfile('default', { apiUrl: 'https://from-file.example.com' }, { path: credentialsPath });
2626
const config = loadConfig({
2727
env: { TESTSPRITE_API_URL: 'https://from-env.example.com' },
2828
credentialsPath,
2929
});
3030
expect(config.apiUrl).toBe('https://from-env.example.com');
3131
});
3232

33-
it('honors TESTSPRITE_API_KEY over the file', () => {
34-
writeProfile('default', { apiKey: 'sk-from-file' }, { path: credentialsPath });
33+
it('honors TESTSPRITE_API_KEY over the file', async () => {
34+
await writeProfile('default', { apiKey: 'sk-from-file' }, { path: credentialsPath });
3535
const config = loadConfig({
3636
env: { TESTSPRITE_API_KEY: 'sk-from-env' },
3737
credentialsPath,
@@ -55,8 +55,8 @@ describe('loadConfig', () => {
5555
).toBe('option-profile');
5656
});
5757

58-
it('option.endpointUrl overrides everything', () => {
59-
writeProfile('default', { apiUrl: 'https://file' }, { path: credentialsPath });
58+
it('option.endpointUrl overrides everything', async () => {
59+
await writeProfile('default', { apiUrl: 'https://file' }, { path: credentialsPath });
6060
const config = loadConfig({
6161
endpointUrl: 'https://flag',
6262
env: { TESTSPRITE_API_URL: 'https://env' },
@@ -65,8 +65,8 @@ describe('loadConfig', () => {
6565
expect(config.apiUrl).toBe('https://flag');
6666
});
6767

68-
it('falls back to credentials file when env is unset', () => {
69-
writeProfile(
68+
it('falls back to credentials file when env is unset', async () => {
69+
await writeProfile(
7070
'default',
7171
{ apiKey: 'sk-file', apiUrl: 'https://from-file.example.com' },
7272
{ path: credentialsPath },
@@ -76,8 +76,8 @@ describe('loadConfig', () => {
7676
expect(config.apiUrl).toBe('https://from-file.example.com');
7777
});
7878

79-
it('reads the requested profile, not just default', () => {
80-
writeProfile('dev', { apiKey: 'sk-dev' }, { path: credentialsPath });
79+
it('reads the requested profile, not just default', async () => {
80+
await writeProfile('dev', { apiKey: 'sk-dev' }, { path: credentialsPath });
8181
const config = loadConfig({ profile: 'dev', env: {}, credentialsPath });
8282
expect(config.apiKey).toBe('sk-dev');
8383
});

0 commit comments

Comments
 (0)