diff --git a/package.json b/package.json index 5563e51e..3fae986c 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@inquirer/checkbox": "^2.5.0", "@inquirer/select": "^2.5.0", "@oclif/core": "^4", - "@salesforce/core": "^8.24.0", + "@salesforce/core": "^8.24.2", "@salesforce/kit": "^3.2.4", "@salesforce/plugin-info": "^3.4.100", "@salesforce/sf-plugins-core": "^12.2.6", diff --git a/src/commands/org/logout.ts b/src/commands/org/logout.ts index d969542c..e7924b9b 100644 --- a/src/commands/org/logout.ts +++ b/src/commands/org/logout.ts @@ -24,6 +24,7 @@ import { Mode, OrgAuthorization, OrgConfigProperties, + SfError, } from '@salesforce/core'; import checkbox, { Separator } from '@inquirer/checkbox'; import { Flags, loglevel, SfCommand, StandardColors } from '@salesforce/sf-plugins-core'; @@ -122,7 +123,17 @@ export default class Logout extends SfCommand { if (skipPrompt || (await this.confirm({ message: getOrgConfirmationMessage(selectedOrgs, orgAuths.length) }))) { const remover = await AuthRemover.create(); const loggedOutUsernames = selectedOrgs.map((org) => org.username); - await Promise.all(loggedOutUsernames.map((username) => remover.removeAuth(username))); + try { + await processInBatches(loggedOutUsernames, 10, (username) => remover.removeAuth(username)); + } catch (error) { + const err = SfError.wrap(error); + if (err.code === 'ELOCKED' && error && typeof error === 'object' && 'file' in error) { + const lockedFile = error.file as string; + err.setData(lockedFile); + err.message = `${err.message} (file: ${lockedFile})`; + } + throw err; + } this.logSuccess(messages.getMessage('logoutOrgCommandSuccess', [loggedOutUsernames.join(os.EOL)])); return loggedOutUsernames; } else { @@ -192,6 +203,26 @@ const getOrgConfirmationMessage = (selectedOrgs: OrgAuthorization[], originalOrg : messages.getMessage('prompt.confirm', [selectedOrgs.length, selectedOrgs.length > 1 ? 's' : '']); }; +/** + * Process items in batches to reduce lock contention on shared files. + * This is particularly important on Windows where file locking is stricter. + * + * @param items Array of items to process + * @param batchSize Number of items to process concurrently in each batch + * @param processor Function to process each item + */ +const processInBatches = async ( + items: T[], + batchSize: number, + processor: (item: T) => Promise +): Promise => { + for (let i = 0; i < items.length; i += batchSize) { + const batch = items.slice(i, i + batchSize); + // eslint-disable-next-line no-await-in-loop + await Promise.all(batch.map(processor)); + } +}; + /** A whole bunch of custom formatting to make the list look nicer */ const buildChoices = (orgAuths: OrgAuthorization[], all: boolean): Array => { const maxUsernameLength = Math.max('Username'.length, ...orgAuths.map((orgAuth) => orgAuth.username.length)); diff --git a/test/commands/org/logout.test.ts b/test/commands/org/logout.test.ts index 9ad61943..b3dfa8a7 100644 --- a/test/commands/org/logout.test.ts +++ b/test/commands/org/logout.test.ts @@ -174,4 +174,104 @@ describe('org:logout', () => { expect((e as Error).message).to.include('No authenticated org found'); } }); + + describe('batch processing', () => { + beforeEach(() => { + authRemoverSpy = $$.SANDBOX.spy(AuthRemover.prototype, 'removeAuth'); + stubUx($$.SANDBOX); + }); + + it('should process removals in batches of 10', async () => { + // Create 25 test orgs to test batch processing + const testOrgs = Array.from({ length: 25 }, () => new MockTestOrgData()); + await $$.stubAuths(...testOrgs); + $$.setConfigStubContents('Config', { contents: {} }); + + const response = await Logout.run(['-p', '-a', '--json']); + expect(response).to.have.length(25); + expect(authRemoverSpy.callCount).to.equal(25); + // Verify all usernames are in the response + const usernames = testOrgs.map((org) => org.username); + expect(response).to.have.members(usernames); + }); + + it('should handle exactly 10 orgs in a single batch', async () => { + const testOrgs = Array.from({ length: 10 }, () => new MockTestOrgData()); + await $$.stubAuths(...testOrgs); + $$.setConfigStubContents('Config', { contents: {} }); + + const response = await Logout.run(['-p', '-a', '--json']); + expect(response).to.have.length(10); + expect(authRemoverSpy.callCount).to.equal(10); + }); + }); + + describe('ELOCKED error handling', () => { + it('should enrich ELOCKED error with file information', async () => { + await prepareStubs({ 'target-org': testOrg1.username }); + const lockedFile = '/path/to/alias.json'; + // Create an error that mimics proper-lockfile ELOCKED error structure + const elockedError = Object.assign(new Error('Lock file is already being held'), { + code: 'ELOCKED', + file: lockedFile, + }); + + // Stub removeAuth to throw ELOCKED error + authRemoverSpy.restore(); + $$.SANDBOX.stub(AuthRemover.prototype, 'removeAuth').rejects(elockedError); + + try { + await Logout.run(['-p', '--json']); + expect.fail('Expected error to be thrown'); + } catch (e) { + const error = e as Error & { code?: string; data?: string; file?: string }; + expect(error.code).to.equal('ELOCKED'); + expect(error.data).to.equal(lockedFile); + expect(error.message).to.include('Lock file is already being held'); + expect(error.message).to.include(`(file: ${lockedFile})`); + } + }); + + it('should handle ELOCKED error without file property', async () => { + await prepareStubs({ 'target-org': testOrg1.username }); + const elockedError = new Error('Lock file is already being held') as Error & { code?: string }; + elockedError.code = 'ELOCKED'; + + // Stub removeAuth to throw ELOCKED error without file property + authRemoverSpy.restore(); + $$.SANDBOX.stub(AuthRemover.prototype, 'removeAuth').rejects(elockedError); + + try { + await Logout.run(['-p', '--json']); + expect.fail('Expected error to be thrown'); + } catch (e) { + const error = e as Error & { code?: string }; + expect(error.code).to.equal('ELOCKED'); + expect(error.message).to.include('Lock file is already being held'); + // Should not have file in message if file property doesn't exist + expect(error.message).to.not.include('(file:'); + } + }); + + it('should re-throw non-ELOCKED errors unchanged', async () => { + await prepareStubs({ 'target-org': testOrg1.username }); + const otherError = new Error('Some other error') as Error & { code?: string }; + otherError.code = 'ESOMEOTHER'; + + // Stub removeAuth to throw a different error + authRemoverSpy.restore(); + $$.SANDBOX.stub(AuthRemover.prototype, 'removeAuth').rejects(otherError); + + try { + await Logout.run(['-p', '--json']); + expect.fail('Expected error to be thrown'); + } catch (e) { + const error = e as Error & { code?: string }; + expect(error.code).to.equal('ESOMEOTHER'); + expect(error.message).to.equal('Some other error'); + // Should not have file information appended + expect(error.message).to.not.include('(file:'); + } + }); + }); }); diff --git a/yarn.lock b/yarn.lock index be1f8d47..b94c05fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1567,6 +1567,31 @@ ts-retry-promise "^0.8.1" zod "^4.1.12" +"@salesforce/core@^8.24.2": + version "8.24.2" + resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-8.24.2.tgz#04ac4725065851a471abbad0ec2ea8f0391b68d8" + integrity sha512-1CC4q123FYDF0IGRKLF4cWuodYkHKcKI9RurHCPzrEIHU28C2gYePi9gfujKJlhRdqskPMwQ3Q2EtfQQdS4wtQ== + dependencies: + "@jsforce/jsforce-node" "^3.10.10" + "@salesforce/kit" "^3.2.4" + "@salesforce/ts-types" "^2.0.12" + ajv "^8.17.1" + change-case "^4.1.2" + fast-levenshtein "^3.0.0" + faye "^1.4.1" + form-data "^4.0.4" + js2xmlparser "^4.0.1" + jsonwebtoken "9.0.3" + jszip "3.10.1" + memfs "^4.30.1" + pino "^9.7.0" + pino-abstract-transport "^1.2.0" + pino-pretty "^11.3.0" + proper-lockfile "^4.1.2" + semver "^7.7.3" + ts-retry-promise "^0.8.1" + zod "^4.1.12" + "@salesforce/dev-config@^4.3.1": version "4.3.1" resolved "https://registry.yarnpkg.com/@salesforce/dev-config/-/dev-config-4.3.1.tgz#4dac8245df79d675258b50e1d24e8c636eaa5e10"