From 68a8f79eda2a0fc09b6c588abcfe727733f0bd52 Mon Sep 17 00:00:00 2001 From: shinoni Date: Thu, 12 Feb 2026 12:25:08 -0800 Subject: [PATCH 1/2] feat: make webapplication.json optional with validation - webapplication.json optional: when absent or force-ignored, require non-empty dist/index.html - Validate required fields (outputDir, routing, trailingSlash, fallback) with clear error messages - Validate fallback and rewrite targets exist on disk - Precise error messages for dist fallback (missing folder, missing file, empty file) - Use path.sep for cross-platform path handling Co-authored-by: Cursor --- .../adapters/webApplicationsSourceAdapter.ts | 145 +++++-- .../webApplicationsSourceAdapter.test.ts | 408 +++++++++++++++--- .../SnapApp/webapplication.json | 6 +- .../SnapApp/webapplication.json | 6 +- .../SnapApp/webapplication.json | 6 +- test/utils/filePathGenerator.test.ts | 27 +- 6 files changed, 499 insertions(+), 99 deletions(-) diff --git a/src/resolve/adapters/webApplicationsSourceAdapter.ts b/src/resolve/adapters/webApplicationsSourceAdapter.ts index 1bdeb6aee..8ab3b53bc 100644 --- a/src/resolve/adapters/webApplicationsSourceAdapter.ts +++ b/src/resolve/adapters/webApplicationsSourceAdapter.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { join } from 'node:path'; +import { join, sep } from 'node:path'; import { Messages } from '@salesforce/core/messages'; import { SfError } from '@salesforce/core/sfError'; import { SourcePath } from '../../common/types'; @@ -24,9 +24,23 @@ import { BundleSourceAdapter } from './bundleSourceAdapter'; Messages.importMessagesDirectory(__dirname); const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr'); +type WebApplicationConfig = { + outputDir: string; + routing: { + trailingSlash: string; + fallback: string; + rewrites?: Array<{ route: string; rewrite: string }>; + }; +}; + +/** + * Source adapter for WebApplication bundles. + * + * If `webapplication.json` is present (and not force-ignored) we validate its + * required fields and check that the files it references exist on disk. + * Otherwise we require a non-empty `dist/index.html`. + */ export class WebApplicationsSourceAdapter extends BundleSourceAdapter { - // Enforces WebApplication bundle requirements for source/deploy while staying - // compatible with metadata-only retrievals. protected populate( trigger: SourcePath, component?: SourceComponent, @@ -41,14 +55,11 @@ export class WebApplicationsSourceAdapter extends BundleSourceAdapter { const appName = baseName(contentPath); const expectedXmlPath = join(contentPath, `${appName}.webapplication-meta.xml`); if (!this.tree.exists(expectedXmlPath)) { - throw new SfError( - messages.getMessage('error_expected_source_files', [expectedXmlPath, this.type.name]), - 'ExpectedSourceFilesError' - ); + this.expectedSourceError(expectedXmlPath); } const resolvedSource = - source.xml && source.xml === expectedXmlPath + source.xml === expectedXmlPath ? source : new SourceComponent( { @@ -65,28 +76,108 @@ export class WebApplicationsSourceAdapter extends BundleSourceAdapter { if (isResolvingSource) { const descriptorPath = join(contentPath, 'webapplication.json'); - const xmlFileName = `${appName}.webapplication-meta.xml`; - const contentEntries = (this.tree.readDirectory(contentPath) ?? []).filter( - (entry) => entry !== xmlFileName && entry !== 'webapplication.json' - ); - if (contentEntries.length === 0) { - // For deploy/source, we expect at least one non-metadata content file (e.g. index.html). - throw new SfError( - messages.getMessage('error_expected_source_files', [contentPath, this.type.name]), - 'ExpectedSourceFilesError' - ); - } - if (!this.tree.exists(descriptorPath)) { - throw new SfError( - messages.getMessage('error_expected_source_files', [descriptorPath, this.type.name]), - 'ExpectedSourceFilesError' - ); - } - if (this.forceIgnore.denies(descriptorPath)) { - throw messages.createError('noSourceIgnore', [this.type.name, descriptorPath]); + const hasDescriptor = this.tree.exists(descriptorPath) && !this.forceIgnore.denies(descriptorPath); + + if (hasDescriptor) { + this.validateDescriptor(descriptorPath, contentPath); + } else { + this.validateDistFolder(contentPath); } } return resolvedSource; } + + private validateDistFolder(contentPath: SourcePath): void { + const distPath = join(contentPath, 'dist'); + const indexPath = join(distPath, 'index.html'); + + if (!this.tree.exists(distPath) || !this.tree.isDirectory(distPath)) { + throw new SfError( + "When webapplication.json is not present, a 'dist' folder containing 'index.html' is required. The 'dist' folder was not found.", + 'ExpectedSourceFilesError' + ); + } + if (!this.tree.exists(indexPath)) { + throw new SfError( + "When webapplication.json is not present, a 'dist/index.html' file is required as the entry point. The file was not found.", + 'ExpectedSourceFilesError' + ); + } + if (this.tree.readFileSync(indexPath).length === 0) { + throw new SfError( + "When webapplication.json is not present, 'dist/index.html' must exist and be non-empty. The file was found but is empty.", + 'ExpectedSourceFilesError' + ); + } + } + + private validateDescriptor(descriptorPath: SourcePath, contentPath: SourcePath): void { + const raw = this.tree.readFileSync(descriptorPath); + let config: WebApplicationConfig; + + try { + config = JSON.parse(raw.toString('utf8')) as WebApplicationConfig; + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + throw new SfError(`Invalid JSON in webapplication.json: ${detail}`, 'InvalidJsonError'); + } + + if (!config.outputDir || typeof config.outputDir !== 'string') { + throw new SfError( + "webapplication.json is missing required field 'outputDir'", + 'InvalidWebApplicationConfigError' + ); + } + const outputDirPath = join(contentPath, config.outputDir); + if (!this.tree.exists(outputDirPath) || !this.tree.isDirectory(outputDirPath)) { + this.expectedSourceError(outputDirPath); + } + + if (!config.routing || typeof config.routing !== 'object') { + throw new SfError("webapplication.json is missing required field 'routing'", 'InvalidWebApplicationConfigError'); + } + if (!config.routing.trailingSlash || typeof config.routing.trailingSlash !== 'string') { + throw new SfError( + "webapplication.json is missing required field 'routing.trailingSlash'", + 'InvalidWebApplicationConfigError' + ); + } + if (!config.routing.fallback || typeof config.routing.fallback !== 'string') { + throw new SfError( + "webapplication.json is missing required field 'routing.fallback'", + 'InvalidWebApplicationConfigError' + ); + } + + // Strip leading path separator (path.sep and / for URL-style paths) + const sepChar = sep.replace(/\\/g, '\\\\'); + const stripLeadingSep = (p: string) => p.replace(new RegExp(`^[${sepChar}/]`), ''); + const fallbackPath = join(outputDirPath, stripLeadingSep(config.routing.fallback)); + if (!this.tree.exists(fallbackPath)) { + throw new SfError( + "The filepath defined in the webapplication.json -> routing.fallback was not found. Ensure this file exists at the location defined.", + 'ExpectedSourceFilesError' + ); + } + + // rewrites are optional, but every target must resolve + if (Array.isArray(config.routing.rewrites)) { + for (const { rewrite } of config.routing.rewrites) { + if (rewrite) { + const rewritePath = join(outputDirPath, stripLeadingSep(rewrite)); + if (!this.tree.exists(rewritePath)) { + this.expectedSourceError(rewritePath); + } + } + } + } + } + + private expectedSourceError(path: SourcePath): never { + throw new SfError( + messages.getMessage('error_expected_source_files', [path, this.type.name]), + 'ExpectedSourceFilesError' + ); + } } diff --git a/test/resolve/adapters/webApplicationsSourceAdapter.test.ts b/test/resolve/adapters/webApplicationsSourceAdapter.test.ts index 2c4940696..c8719f2e3 100644 --- a/test/resolve/adapters/webApplicationsSourceAdapter.test.ts +++ b/test/resolve/adapters/webApplicationsSourceAdapter.test.ts @@ -16,7 +16,14 @@ import { join } from 'node:path'; import { assert, expect } from 'chai'; import { Messages, SfError } from '@salesforce/core'; -import { ForceIgnore, RegistryAccess, SourceComponent, VirtualTreeContainer, registry } from '../../../src'; +import { + ForceIgnore, + RegistryAccess, + SourceComponent, + VirtualTreeContainer, + VirtualDirectory, + registry, +} from '../../../src'; import { WebApplicationsSourceAdapter } from '../../../src/resolve/adapters'; import { RegistryTestUtil } from '../registryTestUtil'; @@ -30,19 +37,31 @@ describe('WebApplicationsSourceAdapter', () => { const META_FILE = join(APP_PATH, `${APP_NAME}.webapplication-meta.xml`); const JSON_FILE = join(APP_PATH, 'webapplication.json'); const CONTENT_FILE = join(APP_PATH, 'src', 'index.html'); + const DIST_PATH = join(APP_PATH, 'dist'); + + const VALID_CONFIG = { + outputDir: 'dist', + routing: { trailingSlash: 'never', fallback: '/index.html' }, + }; const registryAccess = new RegistryAccess(); const forceIgnore = new ForceIgnore(); - const tree = VirtualTreeContainer.fromFilePaths([META_FILE, JSON_FILE, CONTENT_FILE]); + const tree = new VirtualTreeContainer([ + { + dirPath: APP_PATH, + children: [ + `${APP_NAME}.webapplication-meta.xml`, + { name: 'webapplication.json', data: Buffer.from(JSON.stringify(VALID_CONFIG)) }, + 'src', + ], + }, + { dirPath: join(APP_PATH, 'src'), children: ['index.html'] }, + { dirPath: DIST_PATH, children: [{ name: 'index.html', data: Buffer.from('test') }] }, + ]); const adapter = new WebApplicationsSourceAdapter(registry.types.webapplication, registryAccess, forceIgnore, tree); const expectedComponent = new SourceComponent( - { - name: APP_NAME, - type: registry.types.webapplication, - content: APP_PATH, - xml: META_FILE, - }, + { name: APP_NAME, type: registry.types.webapplication, content: APP_PATH, xml: META_FILE }, tree, forceIgnore ); @@ -63,7 +82,7 @@ describe('WebApplicationsSourceAdapter', () => { expect(adapter.getComponent(APP_PATH)).to.deep.equal(expectedComponent); }); - it('should throw ExpectedSourceFilesError if metadata xml is missing', () => { + it('should throw ExpectedSourceFilesError when metadata xml is missing', () => { const noXmlTree = VirtualTreeContainer.fromFilePaths([JSON_FILE, CONTENT_FILE]); const noXmlAdapter = new WebApplicationsSourceAdapter( registry.types.webapplication, @@ -71,46 +90,14 @@ describe('WebApplicationsSourceAdapter', () => { forceIgnore, noXmlTree ); - const expectedXmlPath = join(APP_PATH, `${APP_NAME}.webapplication-meta.xml`); assert.throws( () => noXmlAdapter.getComponent(APP_PATH), SfError, - messages.getMessage('error_expected_source_files', [expectedXmlPath, registry.types.webapplication.name]) - ); - }); - - it('should throw ExpectedSourceFilesError if content files are missing', () => { - const noContentTree = VirtualTreeContainer.fromFilePaths([META_FILE, JSON_FILE]); - const noContentAdapter = new WebApplicationsSourceAdapter( - registry.types.webapplication, - registryAccess, - forceIgnore, - noContentTree - ); - assert.throws( - () => noContentAdapter.getComponent(APP_PATH), - SfError, - messages.getMessage('error_expected_source_files', [APP_PATH, registry.types.webapplication.name]) - ); - }); - - it('should throw ExpectedSourceFilesError if webapplication.json is missing', () => { - const noJsonTree = VirtualTreeContainer.fromFilePaths([META_FILE, CONTENT_FILE]); - const noJsonAdapter = new WebApplicationsSourceAdapter( - registry.types.webapplication, - registryAccess, - forceIgnore, - noJsonTree - ); - const expectedJsonPath = join(APP_PATH, 'webapplication.json'); - assert.throws( - () => noJsonAdapter.getComponent(APP_PATH), - SfError, - messages.getMessage('error_expected_source_files', [expectedJsonPath, registry.types.webapplication.name]) + messages.getMessage('error_expected_source_files', [META_FILE, registry.types.webapplication.name]) ); }); - it('should allow missing webapplication.json when resolving metadata', () => { + it('should skip source validation when resolving metadata only', () => { const metadataTree = VirtualTreeContainer.fromFilePaths([META_FILE]); const metadataAdapter = new WebApplicationsSourceAdapter( registry.types.webapplication, @@ -118,38 +105,323 @@ describe('WebApplicationsSourceAdapter', () => { forceIgnore, metadataTree ); - const expectedMetadataComponent = new SourceComponent( - { - name: APP_NAME, - type: registry.types.webapplication, - content: APP_PATH, - xml: META_FILE, - }, + const expected = new SourceComponent( + { name: APP_NAME, type: registry.types.webapplication, content: APP_PATH, xml: META_FILE }, metadataTree, forceIgnore ); + expect(metadataAdapter.getComponent(META_FILE, false)).to.deep.equal(expected); + }); + + describe('without webapplication.json (dist fallback)', () => { + it('should throw when the dist folder does not exist', () => { + const t = VirtualTreeContainer.fromFilePaths([META_FILE, CONTENT_FILE]); + const a = new WebApplicationsSourceAdapter(registry.types.webapplication, registryAccess, forceIgnore, t); + assert.throws( + () => a.getComponent(APP_PATH), + SfError, + "When webapplication.json is not present, a 'dist' folder containing 'index.html' is required. The 'dist' folder was not found." + ); + }); + + it('should throw when dist exists but index.html is missing', () => { + const vfs: VirtualDirectory[] = [ + { dirPath: APP_PATH, children: [`${APP_NAME}.webapplication-meta.xml`, 'dist'] }, + { dirPath: DIST_PATH, children: [] }, + ]; + const t = new VirtualTreeContainer(vfs); + const a = new WebApplicationsSourceAdapter(registry.types.webapplication, registryAccess, forceIgnore, t); + assert.throws( + () => a.getComponent(APP_PATH), + SfError, + "When webapplication.json is not present, a 'dist/index.html' file is required as the entry point. The file was not found." + ); + }); + + it('should throw when dist/index.html is empty', () => { + const vfs: VirtualDirectory[] = [ + { dirPath: APP_PATH, children: [`${APP_NAME}.webapplication-meta.xml`] }, + { dirPath: DIST_PATH, children: [{ name: 'index.html', data: Buffer.from('') }] }, + ]; + const t = new VirtualTreeContainer(vfs); + const a = new WebApplicationsSourceAdapter(registry.types.webapplication, registryAccess, forceIgnore, t); + assert.throws( + () => a.getComponent(APP_PATH), + SfError, + "When webapplication.json is not present, 'dist/index.html' must exist and be non-empty. The file was found but is empty." + ); + }); - expect(metadataAdapter.getComponent(META_FILE, false)).to.deep.equal(expectedMetadataComponent); + it('should succeed when dist/index.html exists and is non-empty', () => { + const vfs: VirtualDirectory[] = [ + { dirPath: APP_PATH, children: [`${APP_NAME}.webapplication-meta.xml`] }, + { dirPath: DIST_PATH, children: [{ name: 'index.html', data: Buffer.from('App') }] }, + ]; + const t = new VirtualTreeContainer(vfs); + const a = new WebApplicationsSourceAdapter(registry.types.webapplication, registryAccess, forceIgnore, t); + const expected = new SourceComponent( + { name: APP_NAME, type: registry.types.webapplication, content: APP_PATH, xml: META_FILE }, + t, + forceIgnore + ); + expect(a.getComponent(APP_PATH)).to.deep.equal(expected); + }); + + it('should fall back to dist/index.html when webapplication.json is force-ignored', () => { + const testUtil = new RegistryTestUtil(); + const fi = testUtil.stubForceIgnore({ seed: APP_PATH, deny: [JSON_FILE] }); + const vfs: VirtualDirectory[] = [ + { + dirPath: APP_PATH, + children: [ + `${APP_NAME}.webapplication-meta.xml`, + { name: 'webapplication.json', data: Buffer.from(JSON.stringify(VALID_CONFIG)) }, + ], + }, + { dirPath: DIST_PATH, children: [{ name: 'index.html', data: Buffer.from('test') }] }, + ]; + const t = new VirtualTreeContainer(vfs); + const a = new WebApplicationsSourceAdapter(registry.types.webapplication, registryAccess, fi, t); + const expected = new SourceComponent( + { name: APP_NAME, type: registry.types.webapplication, content: APP_PATH, xml: META_FILE }, + t, + fi + ); + expect(a.getComponent(APP_PATH)).to.deep.equal(expected); + testUtil.restore(); + }); }); - it('should throw noSourceIgnore if webapplication.json is forceignored', () => { - const testUtil = new RegistryTestUtil(); - const forceIgnore = testUtil.stubForceIgnore({ - seed: APP_PATH, - deny: [JSON_FILE], + describe('webapplication.json validation', () => { + // helper: build an adapter whose tree has the given webapplication.json content + // plus optional extra VirtualDirectory entries for dist, etc. + const adapterWith = (config: object, extraDirs: VirtualDirectory[] = []): WebApplicationsSourceAdapter => { + const vfs: VirtualDirectory[] = [ + { + dirPath: APP_PATH, + children: [ + `${APP_NAME}.webapplication-meta.xml`, + { name: 'webapplication.json', data: Buffer.from(JSON.stringify(config)) }, + ], + }, + ...extraDirs, + ]; + return new WebApplicationsSourceAdapter( + registry.types.webapplication, + registryAccess, + forceIgnore, + new VirtualTreeContainer(vfs) + ); + }; + + it('should throw on malformed JSON', () => { + const vfs: VirtualDirectory[] = [ + { + dirPath: APP_PATH, + children: [ + `${APP_NAME}.webapplication-meta.xml`, + { name: 'webapplication.json', data: Buffer.from('{ not valid json }') }, + ], + }, + ]; + const a = new WebApplicationsSourceAdapter( + registry.types.webapplication, + registryAccess, + forceIgnore, + new VirtualTreeContainer(vfs) + ); + assert.throws(() => a.getComponent(APP_PATH), SfError, 'Invalid JSON in webapplication.json'); }); - const ignoredAdapter = new WebApplicationsSourceAdapter( - registry.types.webapplication, - registryAccess, - forceIgnore, - tree - ); - assert.throws( - () => ignoredAdapter.getComponent(APP_PATH), - SfError, - messages.getMessage('noSourceIgnore', [registry.types.webapplication.name, JSON_FILE]) - ); - testUtil.restore(); + it('should throw when outputDir is missing', () => { + const a = adapterWith({ routing: { fallback: '/index.html' } }); + assert.throws( + () => a.getComponent(APP_PATH), + SfError, + "webapplication.json is missing required field 'outputDir'" + ); + }); + + it('should throw when outputDir directory does not exist on disk', () => { + const a = adapterWith({ outputDir: 'build', routing: { trailingSlash: 'auto', fallback: '/index.html' } }); + assert.throws( + () => a.getComponent(APP_PATH), + SfError, + messages.getMessage('error_expected_source_files', [ + join(APP_PATH, 'build'), + registry.types.webapplication.name, + ]) + ); + }); + + it('should throw when routing is missing', () => { + const a = adapterWith({ outputDir: 'dist' }, [ + { dirPath: DIST_PATH, children: [{ name: 'index.html', data: Buffer.from('test') }] }, + ]); + assert.throws(() => a.getComponent(APP_PATH), SfError, "webapplication.json is missing required field 'routing'"); + }); + + it('should throw when routing.fallback is missing', () => { + const a = adapterWith({ outputDir: 'dist', routing: { trailingSlash: 'never' } }, [ + { dirPath: DIST_PATH, children: [{ name: 'index.html', data: Buffer.from('test') }] }, + ]); + assert.throws( + () => a.getComponent(APP_PATH), + SfError, + "webapplication.json is missing required field 'routing.fallback'" + ); + }); + + it('should throw when routing.trailingSlash is missing', () => { + const a = adapterWith({ outputDir: 'dist', routing: { fallback: '/index.html' } }, [ + { dirPath: DIST_PATH, children: [{ name: 'index.html', data: Buffer.from('test') }] }, + ]); + assert.throws( + () => a.getComponent(APP_PATH), + SfError, + "webapplication.json is missing required field 'routing.trailingSlash'" + ); + }); + + it('should throw when the fallback file does not exist on disk', () => { + const a = adapterWith({ outputDir: 'dist', routing: { trailingSlash: 'never', fallback: '/missing.html' } }, [ + { dirPath: DIST_PATH, children: [] }, + ]); + assert.throws( + () => a.getComponent(APP_PATH), + SfError, + "The filepath defined in the webapplication.json -> routing.fallback was not found. Ensure this file exists at the location defined." + ); + }); + + it('should throw when a rewrite target does not exist on disk', () => { + const config = { + outputDir: 'dist', + routing: { + trailingSlash: 'never', + fallback: '/index.html', + rewrites: [{ route: '/test', rewrite: '/missing-rewrite.html' }], + }, + }; + const a = adapterWith(config, [ + { dirPath: DIST_PATH, children: [{ name: 'index.html', data: Buffer.from('test') }] }, + ]); + assert.throws( + () => a.getComponent(APP_PATH), + SfError, + messages.getMessage('error_expected_source_files', [ + join(DIST_PATH, 'missing-rewrite.html'), + registry.types.webapplication.name, + ]) + ); + }); + + it('should accept a valid descriptor with outputDir, routing, and rewrites', () => { + const config = { + outputDir: 'dist', + routing: { + trailingSlash: 'never', + fallback: '/index.html', + rewrites: [{ route: '/api/*', rewrite: '/api-proxy.html' }], + }, + }; + const distDir: VirtualDirectory = { + dirPath: DIST_PATH, + children: [ + { name: 'index.html', data: Buffer.from('test') }, + { name: 'api-proxy.html', data: Buffer.from('api') }, + ], + }; + const a = adapterWith(config, [distDir]); + const t = new VirtualTreeContainer([ + { + dirPath: APP_PATH, + children: [ + `${APP_NAME}.webapplication-meta.xml`, + { name: 'webapplication.json', data: Buffer.from(JSON.stringify(config)) }, + ], + }, + distDir, + ]); + const expected = new SourceComponent( + { name: APP_NAME, type: registry.types.webapplication, content: APP_PATH, xml: META_FILE }, + t, + forceIgnore + ); + expect(a.getComponent(APP_PATH)).to.deep.equal(expected); + }); + + it('should validate all rewrites when multiple are present', () => { + const config = { + outputDir: 'dist', + routing: { + trailingSlash: 'auto', + fallback: '/index.html', + rewrites: [ + { route: '/api/*', rewrite: '/api.html' }, + { route: '/docs/*', rewrite: '/docs.html' }, + { route: '/admin', rewrite: '/admin.html' }, + ], + }, + }; + const distDir: VirtualDirectory = { + dirPath: DIST_PATH, + children: [ + { name: 'index.html', data: Buffer.from('test') }, + { name: 'api.html', data: Buffer.from('api') }, + { name: 'docs.html', data: Buffer.from('docs') }, + { name: 'admin.html', data: Buffer.from('admin') }, + ], + }; + const a = adapterWith(config, [distDir]); + const t = new VirtualTreeContainer([ + { + dirPath: APP_PATH, + children: [ + `${APP_NAME}.webapplication-meta.xml`, + { name: 'webapplication.json', data: Buffer.from(JSON.stringify(config)) }, + ], + }, + distDir, + ]); + const expected = new SourceComponent( + { name: APP_NAME, type: registry.types.webapplication, content: APP_PATH, xml: META_FILE }, + t, + forceIgnore + ); + expect(a.getComponent(APP_PATH)).to.deep.equal(expected); + }); + + it('should throw when one of multiple rewrites is missing', () => { + const config = { + outputDir: 'dist', + routing: { + trailingSlash: 'never', + fallback: '/index.html', + rewrites: [ + { route: '/api/*', rewrite: '/api.html' }, + { route: '/docs/*', rewrite: '/missing-docs.html' }, + { route: '/admin', rewrite: '/admin.html' }, + ], + }, + }; + const distDir: VirtualDirectory = { + dirPath: DIST_PATH, + children: [ + { name: 'index.html', data: Buffer.from('test') }, + { name: 'api.html', data: Buffer.from('api') }, + { name: 'admin.html', data: Buffer.from('admin') }, + ], + }; + const a = adapterWith(config, [distDir]); + assert.throws( + () => a.getComponent(APP_PATH), + SfError, + messages.getMessage('error_expected_source_files', [ + join(DIST_PATH, 'missing-docs.html'), + registry.types.webapplication.name, + ]) + ); + }); }); }); diff --git a/test/snapshot/sampleProjects/webApplications/__snapshots__/verify-md-files.expected/webapplications/SnapApp/webapplication.json b/test/snapshot/sampleProjects/webApplications/__snapshots__/verify-md-files.expected/webapplications/SnapApp/webapplication.json index bfe872818..e62c26130 100644 --- a/test/snapshot/sampleProjects/webApplications/__snapshots__/verify-md-files.expected/webapplications/SnapApp/webapplication.json +++ b/test/snapshot/sampleProjects/webApplications/__snapshots__/verify-md-files.expected/webapplications/SnapApp/webapplication.json @@ -1,3 +1,7 @@ { - "outputDir": "src" + "outputDir": "src", + "routing": { + "trailingSlash": "auto", + "fallback": "/index.html" + } } diff --git a/test/snapshot/sampleProjects/webApplications/__snapshots__/verify-source-files.expected/force-app/main/default/webapplications/SnapApp/webapplication.json b/test/snapshot/sampleProjects/webApplications/__snapshots__/verify-source-files.expected/force-app/main/default/webapplications/SnapApp/webapplication.json index bfe872818..e62c26130 100644 --- a/test/snapshot/sampleProjects/webApplications/__snapshots__/verify-source-files.expected/force-app/main/default/webapplications/SnapApp/webapplication.json +++ b/test/snapshot/sampleProjects/webApplications/__snapshots__/verify-source-files.expected/force-app/main/default/webapplications/SnapApp/webapplication.json @@ -1,3 +1,7 @@ { - "outputDir": "src" + "outputDir": "src", + "routing": { + "trailingSlash": "auto", + "fallback": "/index.html" + } } diff --git a/test/snapshot/sampleProjects/webApplications/originalMdapi/webapplications/SnapApp/webapplication.json b/test/snapshot/sampleProjects/webApplications/originalMdapi/webapplications/SnapApp/webapplication.json index bfe872818..e62c26130 100644 --- a/test/snapshot/sampleProjects/webApplications/originalMdapi/webapplications/SnapApp/webapplication.json +++ b/test/snapshot/sampleProjects/webApplications/originalMdapi/webapplications/SnapApp/webapplication.json @@ -1,3 +1,7 @@ { - "outputDir": "src" + "outputDir": "src", + "routing": { + "trailingSlash": "auto", + "fallback": "/index.html" + } } diff --git a/test/utils/filePathGenerator.test.ts b/test/utils/filePathGenerator.test.ts index 33420be6f..627637846 100644 --- a/test/utils/filePathGenerator.test.ts +++ b/test/utils/filePathGenerator.test.ts @@ -27,6 +27,7 @@ type TypeEntry = { typeName: string; expectedFilePaths: string[]; extraResolutionFilePaths?: string[]; + resolutionTree?: VirtualTreeContainer; expectedComponents?: Array<{ name?: string; type?: MetadataType; @@ -154,6 +155,29 @@ const testData = { getFilePath('webapplications/MyWebApp/MyWebApp.webapplication-meta.xml'), ], extraResolutionFilePaths: [getFilePath('webapplications/MyWebApp/src/index.html')], + resolutionTree: new VirtualTreeContainer([ + { dirPath: path.normalize('force-app'), children: ['main'] }, + { dirPath: path.normalize('force-app/main'), children: ['default'] }, + { dirPath: path.normalize('force-app/main/default'), children: ['webapplications'] }, + { dirPath: getFilePath('webapplications'), children: ['MyWebApp'] }, + { + dirPath: getFilePath('webapplications/MyWebApp'), + children: [ + 'MyWebApp.webapplication-meta.xml', + { + name: 'webapplication.json', + data: Buffer.from( + JSON.stringify({ + outputDir: 'src', + routing: { trailingSlash: 'auto', fallback: '/index.html' }, + }) + ), + }, + 'src', + ], + }, + { dirPath: getFilePath('webapplications/MyWebApp/src'), children: ['index.html'] }, + ]), expectedComponents: [ { content: getFilePath('webapplications/MyWebApp'), @@ -279,7 +303,8 @@ describe('generating virtual tree from component name/type', () => { const resolutionFilePaths = typeEntry.extraResolutionFilePaths ? filePaths.concat(typeEntry.extraResolutionFilePaths) : filePaths; - const resolver = new MetadataResolver(registryAccess, VirtualTreeContainer.fromFilePaths(resolutionFilePaths)); + const tree = typeEntry.resolutionTree ?? VirtualTreeContainer.fromFilePaths(resolutionFilePaths); + const resolver = new MetadataResolver(registryAccess, tree); const components = resolver.getComponentsFromPath(packageDir); const expectedComponentsSize = typeEntry.expectedComponents?.length ?? 1; From 0017132224a285498fb677b8efeed6ec3a6961d9 Mon Sep 17 00:00:00 2001 From: shinoni Date: Thu, 12 Feb 2026 12:31:44 -0800 Subject: [PATCH 2/2] fix: custom error messages for rewrites (include path), full invalid JSON test Co-authored-by: Cursor --- .../adapters/webApplicationsSourceAdapter.ts | 5 ++++- .../webApplicationsSourceAdapter.test.ts | 20 +++++++++---------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/resolve/adapters/webApplicationsSourceAdapter.ts b/src/resolve/adapters/webApplicationsSourceAdapter.ts index 8ab3b53bc..e0003e794 100644 --- a/src/resolve/adapters/webApplicationsSourceAdapter.ts +++ b/src/resolve/adapters/webApplicationsSourceAdapter.ts @@ -167,7 +167,10 @@ export class WebApplicationsSourceAdapter extends BundleSourceAdapter { if (rewrite) { const rewritePath = join(outputDirPath, stripLeadingSep(rewrite)); if (!this.tree.exists(rewritePath)) { - this.expectedSourceError(rewritePath); + throw new SfError( + `A rewrite target defined in webapplication.json -> routing.rewrites was not found: ${rewritePath}. Ensure the file exists at that location.`, + 'ExpectedSourceFilesError' + ); } } } diff --git a/test/resolve/adapters/webApplicationsSourceAdapter.test.ts b/test/resolve/adapters/webApplicationsSourceAdapter.test.ts index c8719f2e3..dacec2a10 100644 --- a/test/resolve/adapters/webApplicationsSourceAdapter.test.ts +++ b/test/resolve/adapters/webApplicationsSourceAdapter.test.ts @@ -214,7 +214,7 @@ describe('WebApplicationsSourceAdapter', () => { ); }; - it('should throw on malformed JSON', () => { + it('should throw on malformed JSON with full error detail', () => { const vfs: VirtualDirectory[] = [ { dirPath: APP_PATH, @@ -230,7 +230,11 @@ describe('WebApplicationsSourceAdapter', () => { forceIgnore, new VirtualTreeContainer(vfs) ); - assert.throws(() => a.getComponent(APP_PATH), SfError, 'Invalid JSON in webapplication.json'); + assert.throws( + () => a.getComponent(APP_PATH), + SfError, + /^Invalid JSON in webapplication\.json: .+/ + ); }); it('should throw when outputDir is missing', () => { @@ -306,13 +310,11 @@ describe('WebApplicationsSourceAdapter', () => { const a = adapterWith(config, [ { dirPath: DIST_PATH, children: [{ name: 'index.html', data: Buffer.from('test') }] }, ]); + const expectedPath = join(DIST_PATH, 'missing-rewrite.html'); assert.throws( () => a.getComponent(APP_PATH), SfError, - messages.getMessage('error_expected_source_files', [ - join(DIST_PATH, 'missing-rewrite.html'), - registry.types.webapplication.name, - ]) + `A rewrite target defined in webapplication.json -> routing.rewrites was not found: ${expectedPath}. Ensure the file exists at that location.` ); }); @@ -414,13 +416,11 @@ describe('WebApplicationsSourceAdapter', () => { ], }; const a = adapterWith(config, [distDir]); + const expectedPath = join(DIST_PATH, 'missing-docs.html'); assert.throws( () => a.getComponent(APP_PATH), SfError, - messages.getMessage('error_expected_source_files', [ - join(DIST_PATH, 'missing-docs.html'), - registry.types.webapplication.name, - ]) + `A rewrite target defined in webapplication.json -> routing.rewrites was not found: ${expectedPath}. Ensure the file exists at that location.` ); }); });