diff --git a/package.json b/package.json index 0c0cf77f..499cd21a 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "dependencies": { "@salesforce/core": "^8.24.0", "@salesforce/kit": "^3.2.4", - "@salesforce/source-deploy-retrieve": "^12.31.5", + "@salesforce/source-deploy-retrieve": "^12.31.8", "@salesforce/ts-types": "^2.0.12", "fast-xml-parser": "^4.5.3", "graceful-fs": "^4.2.11", diff --git a/src/shared/functions.ts b/src/shared/functions.ts index d60f2ea5..37e22f9b 100644 --- a/src/shared/functions.ts +++ b/src/shared/functions.ts @@ -64,6 +64,10 @@ export const excludeLwcLocalOnlyTest = (filePath: string): boolean => export const pathIsInFolder = (folder: string) => (filePath: string): boolean => { + // empty paths should not match anything; an empty folder would normalize to '/' and match everything + if (!folder || !filePath) { + return false; + } if (folder === filePath) { return true; } diff --git a/src/shared/localComponentSetArray.ts b/src/shared/localComponentSetArray.ts index 6de287e9..b52f9f7c 100644 --- a/src/shared/localComponentSetArray.ts +++ b/src/shared/localComponentSetArray.ts @@ -78,13 +78,16 @@ const getNonSequential = ({ packageDirs, nonDeletes: nonDeletes, deletes: deletes, -}: GroupedFileInput): GroupedFile[] => [ - { - nonDeletes, - deletes, - path: packageDirs.map((dir) => dir.name).join(';'), - }, -]; +}: GroupedFileInput): GroupedFile[] => { + if (packageDirs.length === 0) return []; + return [ + { + nonDeletes, + deletes, + path: packageDirs.map((dir) => dir.name).join(';'), + }, + ]; +}; export const getComponentSets = ({ groupings, diff --git a/src/sourceTracking.ts b/src/sourceTracking.ts index d90a3a60..d59c2ba2 100644 --- a/src/sourceTracking.ts +++ b/src/sourceTracking.ts @@ -700,6 +700,14 @@ export class SourceTracking extends AsyncCreatable { private async maybeSubscribeLifecycleEvents(): Promise { if (this.subscribeSDREvents && (await this.org.tracksSource())) { const lifecycle = Lifecycle.getInstance(); + + // Always remove the pre events when `maybeSubscribeLifecycleEvents` is called. + // Events are attached to a singleton (sfdx-core's Lifecycle), so when + // instantiating `SourceTracking` multiple times in the same process we need + // each instance starts clean. + lifecycle.removeAllListeners('scopedPreDeploy') + lifecycle.removeAllListeners('scopedPreRetrieve') + // the only thing STL uses pre events for is to check conflicts. So if you don't care about conflicts, don't listen! if (!this.ignoreConflicts) { this.logger.debug('subscribing to predeploy/retrieve events'); diff --git a/test/unit/localDetectMovedFiles.test.ts b/test/unit/localDetectMovedFiles.test.ts index de41dc8e..2f515558 100644 --- a/test/unit/localDetectMovedFiles.test.ts +++ b/test/unit/localDetectMovedFiles.test.ts @@ -272,7 +272,7 @@ describe('local detect moved files', () => { } }); - it.only('automatically commits moved files and leaves other changes alone', async () => { + it('automatically commits moved files and leaves other changes alone', async () => { let projectDir!: string; try { projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'localShadowRepoTest')); diff --git a/test/unit/pathIsInFolder.test.ts b/test/unit/pathIsInFolder.test.ts index 8086d71c..5fe2e34f 100644 --- a/test/unit/pathIsInFolder.test.ts +++ b/test/unit/pathIsInFolder.test.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { normalize } from 'node:path'; +import { normalize, sep } from 'node:path'; import { expect } from 'chai'; import { pathIsInFolder } from '../../src/shared/functions'; @@ -73,7 +73,10 @@ describe('pathIsInFolder', () => { }); it('handles paths with mixed separators', () => { - expect(pathIsInFolder(normalize('/foo\\bar'))(normalize('/foo/bar/baz'))).to.equal(true); + // On Windows, backslash is a path separator, so normalize('/foo\\bar') becomes '/foo/bar' + // On Unix, backslash is a valid filename character, so it stays as '/foo\\bar' + const isWindows = sep === '\\'; + expect(pathIsInFolder(normalize('/foo\\bar'))(normalize('/foo/bar/baz'))).to.equal(isWindows); }); it('handles exact paths', () => { diff --git a/test/unit/remote/remoteSourceTracking.test.ts b/test/unit/remote/remoteSourceTracking.test.ts index c240f329..852f141d 100644 --- a/test/unit/remote/remoteSourceTracking.test.ts +++ b/test/unit/remote/remoteSourceTracking.test.ts @@ -601,14 +601,12 @@ describe('remoteSourceTrackingService', () => { queryStub.onFirstCall().resolves([]); const queryResult = [1, 2, 3].map((rev) => getSourceMember(rev)); - // @ts-ignore queryStub.onSecondCall().resolves(queryResult); - // @ts-ignore + // @ts-expect-error stubbing private method for testing const trackSpy = $$.SANDBOX.stub(remoteSourceTrackingService, 'trackSourceMembers'); - // @ts-ignore - await remoteSourceTrackingService.pollForSourceTracking(memberNames, 2); + await remoteSourceTrackingService.pollForSourceTracking(new RegistryAccess(), memberNames); // this test changed from toolbelt because each server query now update the tracking files expect( trackSpy.calledTwice, @@ -704,11 +702,10 @@ describe('remoteSourceTrackingService', () => { it('should stop if the computed pollingTimeout is exceeded', async () => { const queryStub = $$.SANDBOX.stub(orgQueryMocks, 'querySourceMembersFrom').resolves([]); - // @ts-ignore + // @ts-expect-error stubbing private method for testing const trackSpy = $$.SANDBOX.stub(remoteSourceTrackingService, 'trackSourceMembers'); - // @ts-ignore - await remoteSourceTrackingService.pollForSourceTracking(memberNames); + await remoteSourceTrackingService.pollForSourceTracking(new RegistryAccess(), memberNames); // changed from toolbelt because each query result goes to tracking expect(trackSpy.callCount).to.equal(6); expect(warns.size).to.be.greaterThan(0); @@ -722,11 +719,10 @@ describe('remoteSourceTrackingService', () => { reResolveEnvVars(); const queryStub = $$.SANDBOX.stub(orgQueryMocks, 'querySourceMembersFrom').resolves([]); - // @ts-ignore + // @ts-expect-error stubbing private method for testing const trackSpy = $$.SANDBOX.stub(remoteSourceTrackingService, 'trackSourceMembers'); - // @ts-ignore - await remoteSourceTrackingService.pollForSourceTracking(memberNames); + await remoteSourceTrackingService.pollForSourceTracking(new RegistryAccess(), memberNames); expect(trackSpy.called).to.equal(true); expect(warns.size).to.be.greaterThan(0); @@ -739,11 +735,10 @@ describe('remoteSourceTrackingService', () => { reResolveEnvVars(); const queryStub = $$.SANDBOX.stub(orgQueryMocks, 'querySourceMembersFrom').resolves([]); - // @ts-ignore + // @ts-expect-error stubbing private method for testing const trackSpy = $$.SANDBOX.stub(remoteSourceTrackingService, 'trackSourceMembers'); - // @ts-ignore - await remoteSourceTrackingService.pollForSourceTracking(memberNames); + await remoteSourceTrackingService.pollForSourceTracking(new RegistryAccess(), memberNames); expect(trackSpy.called).to.equal(true); expect(warns.size).to.be.greaterThan(0); diff --git a/test/unit/sourceTrackingLifecycleEvents.test.ts b/test/unit/sourceTrackingLifecycleEvents.test.ts new file mode 100644 index 00000000..194571ca --- /dev/null +++ b/test/unit/sourceTrackingLifecycleEvents.test.ts @@ -0,0 +1,94 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import sinon from 'sinon'; +import { expect } from 'chai'; +import { MockTestOrgData, instantiateContext, stubContext, restoreContext } from '@salesforce/core/testSetup'; +import { Lifecycle, Org, SfProject } from '@salesforce/core'; +import { SourceTracking } from '../../src/sourceTracking'; + +describe('SourceTracking lifecycle events cleanup', () => { + const $$ = instantiateContext(); + const username = 'test@example.com'; + + afterEach(() => { + restoreContext($$); + sinon.restore(); + }); + + it('removes pre-event listeners when subscribing to lifecycle events', async () => { + stubContext($$); + const orgData = new MockTestOrgData(); + orgData.username = username; + orgData.tracksSource = true; + await $$.stubAuths(orgData); + + const org = await Org.create({ aliasOrUsername: username }); + const project = SfProject.getInstance(); + + sinon.stub(project, 'getPackageDirectories').returns([ + { + name: 'force-app', + path: 'force-app', + fullPath: '/test/force-app', + default: true, + }, + ]); + + const lifecycle = Lifecycle.getInstance(); + const removeAllListenersSpy = sinon.spy(lifecycle, 'removeAllListeners'); + + await SourceTracking.create({ + org, + project, + subscribeSDREvents: true, + }); + + expect(removeAllListenersSpy.calledWith('scopedPreDeploy')).to.equal(true); + expect(removeAllListenersSpy.calledWith('scopedPreRetrieve')).to.equal(true); + }); + + it('does not remove listeners when subscribeSDREvents is false', async () => { + stubContext($$); + const orgData = new MockTestOrgData(); + orgData.username = username; + orgData.tracksSource = true; + await $$.stubAuths(orgData); + + const org = await Org.create({ aliasOrUsername: username }); + const project = SfProject.getInstance(); + + sinon.stub(project, 'getPackageDirectories').returns([ + { + name: 'force-app', + path: 'force-app', + fullPath: '/test/force-app', + default: true, + }, + ]); + + const lifecycle = Lifecycle.getInstance(); + const removeAllListenersSpy = sinon.spy(lifecycle, 'removeAllListeners'); + + await SourceTracking.create({ + org, + project, + subscribeSDREvents: false, + }); + + expect(removeAllListenersSpy.calledWith('scopedPreDeploy')).to.equal(false); + expect(removeAllListenersSpy.calledWith('scopedPreRetrieve')).to.equal(false); + }); +}); diff --git a/yarn.lock b/yarn.lock index 7763be3d..a06f1983 100644 --- a/yarn.lock +++ b/yarn.lock @@ -586,22 +586,6 @@ node-fetch "^2.6.1" xml2js "^0.6.2" -"@jsforce/jsforce-node@^3.10.8": - version "3.10.8" - resolved "https://registry.npmjs.org/@jsforce/jsforce-node/-/jsforce-node-3.10.8.tgz" - integrity sha512-XGD/ivZz+htN5SgctFyEZ+JNG6C8FXzaEwvPbRSdsIy/hpWlexY38XtTpdT5xX3KnYSnOE4zA1M/oIbTm7RD/Q== - dependencies: - "@sindresorhus/is" "^4" - base64url "^3.0.1" - csv-parse "^5.5.2" - csv-stringify "^6.6.0" - faye "^1.4.0" - form-data "^4.0.4" - https-proxy-agent "^5.0.0" - multistream "^3.1.0" - node-fetch "^2.6.1" - xml2js "^0.6.2" - "@jsonjoy.com/base64@^1.1.2": version "1.1.2" resolved "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz" @@ -692,32 +676,7 @@ strip-ansi "6.0.1" ts-retry-promise "^0.8.1" -"@salesforce/core@^8.23.1", "@salesforce/core@^8.8.0": - version "8.23.3" - resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-8.23.3.tgz#23d92d6eb887e946e26989552a605fa085e626e8" - integrity sha512-BD9cOUOw3wTR8ud6dBacLvA4x0KAfQXkNGdxtU9ujz5nEW86ms5tU1AEUzVXnhuDrrtdQZh7/yTGxqg5mS7rZg== - dependencies: - "@jsforce/jsforce-node" "^3.10.8" - "@salesforce/kit" "^3.2.4" - "@salesforce/schemas" "^1.10.3" - "@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.2" - 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" - -"@salesforce/core@^8.24.0": +"@salesforce/core@^8.23.1", "@salesforce/core@^8.24.0", "@salesforce/core@^8.8.0": version "8.24.0" resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-8.24.0.tgz#13426f9f3b5ed0ec126b8009e5eda68e03db0401" integrity sha512-8Ra5RT95bRkmHmaaFgABwkXbnHNSNS7l9gbJzJgO6VQpaEeytGPPyymnAE7TcTM2xp/QwlXn+PgX4biX7Lb7JA== @@ -796,10 +755,10 @@ resolved "https://registry.yarnpkg.com/@salesforce/schemas/-/schemas-1.10.3.tgz#52c867fdd60679cf216110aa49542b7ad391f5d1" integrity sha512-FKfvtrYTcvTXE9advzS25/DEY9yJhEyLvStm++eQFtnAaX1pe4G3oGHgiQ0q55BM5+0AlCh0+0CVtQv1t4oJRA== -"@salesforce/source-deploy-retrieve@^12.31.5": - version "12.31.5" - resolved "https://registry.yarnpkg.com/@salesforce/source-deploy-retrieve/-/source-deploy-retrieve-12.31.5.tgz#2765c7422acf064d63dea5d2e83b4072610dfb6f" - integrity sha512-x5EQJsvLBzg6IAOYvVjPYHNSNOmKNbzjqwwaeMBgxiVfWzmkYjz6igGY4oUkRw9VgBFrzMEK2sq2SOypSEq/+w== +"@salesforce/source-deploy-retrieve@^12.31.7": + version "12.31.7" + resolved "https://registry.yarnpkg.com/@salesforce/source-deploy-retrieve/-/source-deploy-retrieve-12.31.7.tgz#d61b85a871fac0a1c7d636190d93917aad76e48f" + integrity sha512-SLfGjJnDdB0J+gwhY7Tt+Hcd6/4Qknqjnk94cTfXxrJy3gepP1SdfR0T0zimMWRXOZ/BH3yLlfBbj5EBRWvznA== dependencies: "@salesforce/core" "^8.24.0" "@salesforce/kit" "^3.2.4" @@ -3933,22 +3892,6 @@ jsonparse@^1.2.0: resolved "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz" integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== -jsonwebtoken@9.0.2: - version "9.0.2" - resolved "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz" - integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ== - dependencies: - jws "^3.2.2" - lodash.includes "^4.3.0" - lodash.isboolean "^3.0.3" - lodash.isinteger "^4.0.4" - lodash.isnumber "^3.0.3" - lodash.isplainobject "^4.0.6" - lodash.isstring "^4.0.1" - lodash.once "^4.0.0" - ms "^2.1.1" - semver "^7.5.4" - jsonwebtoken@9.0.3: version "9.0.3" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz#6cd57ab01e9b0ac07cb847d53d3c9b6ee31f7ae2" @@ -3985,15 +3928,6 @@ just-extend@^6.2.0: resolved "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz" integrity sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw== -jwa@^1.4.1: - version "1.4.2" - resolved "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz" - integrity sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw== - dependencies: - buffer-equal-constant-time "^1.0.1" - ecdsa-sig-formatter "1.0.11" - safe-buffer "^5.0.1" - jwa@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804" @@ -4003,14 +3937,6 @@ jwa@^2.0.1: ecdsa-sig-formatter "1.0.11" safe-buffer "^5.0.1" -jws@^3.2.2: - version "3.2.2" - resolved "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz" - integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== - dependencies: - jwa "^1.4.1" - safe-buffer "^5.0.1" - jws@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690" @@ -5453,12 +5379,7 @@ semver@^6.0.0, semver@^6.3.1: resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.3.4, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3: - version "7.7.2" - resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz" - integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== - -semver@^7.7.3: +semver@^7.3.4, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3, semver@^7.7.3: version "7.7.3" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==