diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..fb2179a --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# CLI message templates - preserve formatting +messages/*.md diff --git a/SF_WEBAPP_DEV_GUIDE.md b/SF_WEBAPP_DEV_GUIDE.md index ec165a6..78f17e0 100644 --- a/SF_WEBAPP_DEV_GUIDE.md +++ b/SF_WEBAPP_DEV_GUIDE.md @@ -24,15 +24,17 @@ The `sf webapp dev` command enables local development of modern web applications ## Quick Start -### 1. Create your webapp in the `webapplications/` folder +### 1. Create your webapp in the SFDX project structure ``` -my-project/ -└── webapplications/ - └── my-app/ # Your webapp folder +my-sfdx-project/ +├── sfdx-project.json +└── force-app/main/default/webapplications/ + └── my-app/ + ├── my-app.webapplication-meta.xml ├── package.json ├── src/ - └── webapplication.json # Optional! + └── webapplication.json ``` ### 2. Run the command @@ -45,11 +47,13 @@ sf webapp dev --target-org myOrg --open Browser opens to `http://localhost:4545` with your app running and Salesforce authentication ready. -> **Note**: `webapplication.json` is optional! If not present, the command uses: +> **Note**: > -> - **Name**: Folder name (e.g., "my-app") -> - **Dev command**: `npm run dev` -> - **Manifest watching**: Disabled +> - `{name}.webapplication-meta.xml` is **required** to identify a valid webapp +> - `webapplication.json` is optional for dev configuration. If not present, defaults to: +> - **Name**: From meta.xml filename or folder name +> - **Dev command**: `npm run dev` +> - **Manifest watching**: Disabled --- @@ -64,7 +68,7 @@ sf webapp dev [OPTIONS] | Option | Short | Description | Default | | -------------- | ----- | ----------------------------------------------- | ------------- | | `--target-org` | `-o` | Salesforce org alias or username | Required | -| `--name` | `-n` | Web application name (from webapplication.json) | Auto-discover | +| `--name` | `-n` | Web application name (folder name) | Auto-discover | | `--url` | `-u` | Explicit dev server URL | Auto-detect | | `--port` | `-p` | Proxy server port | 4545 | | `--open` | `-b` | Open browser automatically | false | @@ -95,78 +99,76 @@ SF_LOG_LEVEL=debug sf webapp dev --target-org myOrg ## Webapp Discovery -The command automatically discovers webapps in the `webapplications/` folder. Each subfolder is treated as a webapp, with `webapplication.json` being optional. +The command discovers webapps using a simplified, deterministic algorithm. Webapps are identified by the presence of a `{name}.webapplication-meta.xml` file (SFDX metadata format). The optional `webapplication.json` file provides dev configuration. ### How Discovery Works ```mermaid flowchart TD - Start["sf webapp dev"] --> FindFolder["Find webapplications/ folder"] - FindFolder --> Found{"Found?"} - Found -->|No| ErrorNone["Error: No webapplications folder found"] - Found -->|Yes| HasName{"--name provided?"} + Start["sf webapp dev"] --> CheckInside{"Inside webapplications/
webapp folder?"} - HasName -->|Yes| SearchByName["Find webapp by name"] - HasName -->|No| InsideWebapp{"Running from inside a webapp?"} + CheckInside -->|Yes| HasNameInside{"--name provided?"} + HasNameInside -->|Yes, different| ErrorConflict["Error: --name conflicts
with current directory"] + HasNameInside -->|No or same| AutoSelect["Auto-select current webapp"] - InsideWebapp -->|Yes| AutoSelect["Auto-select current webapp"] - InsideWebapp -->|No| Count{"How many webapps?"} + CheckInside -->|No| CheckSFDX{"In SFDX project?
(sfdx-project.json)"} - Count -->|1| AutoSelectSingle["Auto-select single webapp"] - Count -->|Multiple| Prompt["Interactive selection prompt"] + CheckSFDX -->|Yes| CheckPath["Check force-app/main/
default/webapplications/"] + CheckPath --> HasName{"--name provided?"} + + CheckSFDX -->|No| CheckMetaXml{"Current dir has
.webapplication-meta.xml?"} + CheckMetaXml -->|Yes| UseStandalone["Use current dir as webapp"] + CheckMetaXml -->|No| ErrorNone["Error: No webapp found"] + + HasName -->|Yes| SearchByName["Find webapp by name"] + HasName -->|No| Prompt["Interactive selection prompt
(always, even if 1 webapp)"] SearchByName --> UseWebapp["Use webapp"] AutoSelect --> UseWebapp - AutoSelectSingle --> UseWebapp + UseStandalone --> UseWebapp Prompt --> UseWebapp - UseWebapp --> HasManifest{"Has webapplication.json?"} - HasManifest -->|Yes| UseManifest["Use manifest config"] - HasManifest -->|No| UseDefaults["Use defaults (npm run dev)"] - - UseManifest --> StartDev["Start dev server and proxy"] - UseDefaults --> StartDev + UseWebapp --> StartDev["Start dev server and proxy"] ``` ### Discovery Behavior -| Scenario | Behavior | -| --------------------------------- | ---------------------------------------------- | -| `--name myApp` provided | Finds webapp by name (manifest name or folder) | -| Running from inside webapp folder | Auto-selects that webapp | -| Single webapp found | Auto-selects it | -| Multiple webapps found | Shows interactive selection with arrow keys | -| No webapplications folder | Shows error with helpful message | +| Scenario | Behavior | +| ----------------------------------- | --------------------------------------------------------- | +| `--name myApp` provided | Finds webapp by name, starts dev server | +| Running from inside webapp folder | Auto-selects that webapp | +| `--name` conflicts with current dir | Error: must match current webapp or run from project root | +| At SFDX project root | **Always prompts** for webapp selection | +| Outside SFDX project with meta.xml | Uses current directory as standalone webapp | +| No webapp found | Shows error with helpful message | -### Folder Structure +### Folder Structure (SFDX Project) ``` -my-project/ -└── webapplications/ # Required folder (case-insensitive) - ├── app-one/ # Webapp 1 (with manifest) - │ ├── webapplication.json - │ ├── package.json - │ └── src/ - ├── app-two/ # Webapp 2 (no manifest - uses defaults) - │ ├── package.json - │ └── src/ - └── app-three/ # Webapp 3 (partial manifest) - ├── webapplication.json # Only has dev.command - └── src/ +my-sfdx-project/ +├── sfdx-project.json # SFDX project marker +└── force-app/main/default/ + └── webapplications/ # Standard SFDX location + ├── app-one/ # Webapp 1 (with dev config) + │ ├── app-one.webapplication-meta.xml # Required: identifies as webapp + │ ├── webapplication.json # Optional: dev configuration + │ ├── package.json + │ └── src/ + └── app-two/ # Webapp 2 (no dev config) + ├── app-two.webapplication-meta.xml # Required + ├── package.json + └── src/ ``` -### Search Scope - -The command searches for the `webapplications/` folder: +### Discovery Strategy -1. **Upward**: First checks if you're inside a webapplications folder -2. **Downward**: Then searches child directories recursively +The command uses a simplified, deterministic approach: -Excluded directories: +1. **Inside webapp folder**: If running from `webapplications//` or deeper, auto-selects that webapp +2. **SFDX project root**: Uses fixed path `force-app/main/default/webapplications/` +3. **Standalone**: If current directory has a `.webapplication-meta.xml` file, uses it directly -- `node_modules`, `.git`, `dist`, `build`, `out`, `coverage` -- `.next`, `.nuxt`, `.output` -- Hidden directories (starting with `.`) +**Important**: Only directories containing a `{name}.webapplication-meta.xml` file are recognized as valid webapps. ### Interactive Selection @@ -175,16 +177,15 @@ When multiple webapps are found, you'll see an interactive prompt: ``` Found 3 webapps in project ? Select the webapp to run: (Use arrow keys) -❯ MyApp - My Application (webapplications/app-one) +❯ app-one (webapplications/app-one) app-two (webapplications/app-two) [no manifest] - CustomName (webapplications/app-three) + app-three (webapplications/app-three) ``` Format: -- **With manifest + label**: `Name - Label (path)` -- **With manifest, no label**: `Name (path)` -- **No manifest**: `name (path) [no manifest]` +- **With manifest**: `folder-name (path)` +- **No manifest**: `folder-name (path) [no manifest]` --- @@ -237,28 +238,14 @@ Browser → Proxy → [Auth Headers Injected] → Salesforce → Response The `webapplication.json` file is **optional**. All fields are also optional - missing fields use defaults. -#### All Fields (All Optional) - -```json -{ - "name": "myApp", - "label": "My Application", - "version": "1.0.0", - "outputDir": "dist", - "dev": { - "command": "npm run dev" - } -} -``` +#### Dev Configuration -| Field | Type | Description | Default | -| ----------- | ------ | ----------------------------------------- | ------------------ | -| `name` | string | Unique identifier (used with --name flag) | Folder name | -| `label` | string | Human-readable display name | None | -| `version` | string | Semantic version (e.g., "1.0.0") | None | -| `outputDir` | string | Build output directory | None (deploy only) | +| Field | Type | Description | Default | +| ------------- | ------ | ------------------------------------- | ------------------------- | +| `dev.command` | string | Command to start dev server | `npm run dev` | +| `dev.url` | string | Dev server URL (when already running) | `http://localhost:5173` | -#### Dev Configuration +All fields are optional. Only specify what you need to override. **Option A: No manifest (uses defaults)** @@ -278,8 +265,6 @@ If no `webapplication.json` exists: } ``` -Only specify what you need to override. - **Option C: Explicit URL (dev server already running)** ```json @@ -335,15 +320,10 @@ Warning: No webapplication.json found for webapp "my-dashboard" Press Ctrl+C to stop ``` -### Example: Full Configuration +### Example: Dev + Routing ```json { - "name": "salesDashboard", - "label": "Sales Dashboard", - "description": "Real-time sales analytics dashboard", - "version": "2.1.0", - "outputDir": "dist", "dev": { "command": "npm run dev" }, @@ -393,27 +373,105 @@ Automatically detects Salesforce Code Builder environment and binds to `0.0.0.0` --- +## The `--url` Flag + +The `--url` flag provides control over which dev server URL the proxy uses. It has smart behavior depending on whether the URL is already available. + +### Behavior + +| Scenario | What Happens | +| ------------------------ | ----------------------------------------------------------------- | +| `--url` is reachable | **Proxy-only mode**: Skips starting dev server, only starts proxy | +| `--url` is NOT reachable | Starts dev server, warns if actual URL differs from `--url` | +| No `--url` provided | Starts dev server automatically, detects URL | + +### Use Case 1: Connect to Existing Dev Server (Proxy-Only Mode) + +If you prefer to manage your dev server separately: + +```bash +# Terminal 1: Start your dev server manually +cd my-webapp +npm run dev +# Output: Local: http://localhost:5173/ + +# Terminal 2: Connect proxy to your running server +sf webapp dev --url http://localhost:5173 --target-org myOrg +``` + +**Output:** + +``` +✅ URL http://localhost:5173 is already available, skipping dev server startup (proxy-only mode) +✅ Ready for development! + → Proxy: http://localhost:4545 + → Dev server: http://localhost:5173 +``` + +### Use Case 2: URL Mismatch Warning + +If you specify a `--url` that doesn't match where the dev server actually starts: + +```bash +# No dev server running, specify wrong port +sf webapp dev --url http://localhost:9999 --target-org myOrg +``` + +**Output:** + +``` +Warning: ⚠️ The --url flag (http://localhost:9999) does not match the actual dev server URL (http://localhost:5173/). +The proxy will use the actual dev server URL. +``` + +The command continues working with the actual dev server URL. + +### Important Notes + +- The `--url` flag checks **only** the URL you specify, not other ports +- If you have a dev server on port 5173 but specify `--url http://localhost:9999`: + - Command checks 9999 → not available + - Starts a NEW dev server → may get port 5174 (if 5173 is taken) + - Warns about mismatch (9999 ≠ 5174) +- To use an existing dev server, specify its **exact** URL with `--url` + +--- + ## Troubleshooting -### "No webapplications folder found" +### "No webapp found" or "No valid webapps" -Create a `webapplications/` folder with at least one webapp subfolder: +Ensure your webapp has the required `.webapplication-meta.xml` file: ``` -my-project/ -└── webapplications/ - └── my-app/ - └── package.json +force-app/main/default/webapplications/ +└── my-app/ + ├── my-app.webapplication-meta.xml # Required! + ├── package.json + └── webapplication.json # Optional (for dev config) ``` -Note: `webapplication.json` is optional! +The `.webapplication-meta.xml` file identifies a valid SFDX webapp. Without it, the directory is ignored. -### "No webapp found with name X" +### "You are inside webapp X but specified --name Y" -The `--name` flag matches either: +This error occurs when you're inside one webapp folder but try to run a different webapp: + +```bash +# You're in FirstWebApp folder but trying to run SecondWebApp +cd webapplications/FirstWebApp +sf webapp dev --name SecondWebApp --target-org myOrg # Error! +``` + +**Solutions:** + +- Remove `--name` to use the current webapp +- Navigate to the project root and use `--name` +- Navigate to the correct webapp folder + +### "No webapp found with name X" -1. The `name` field in `webapplication.json` -2. The folder name (if no manifest or no name in manifest) +The `--name` flag matches the folder name of the webapp. ```bash # This looks for webapp named "myApp" @@ -586,7 +644,7 @@ plugin-app-dev/ | Component | Purpose | | ---------------------- | ------------------------------------------------ | | `dev.ts` | Command orchestration and lifecycle | -| `webappDiscovery.ts` | Recursive webapplication.json discovery | +| `webappDiscovery.ts` | SFDX project detection and webapp discovery | | `org.ts` | Salesforce authentication token management | | `ProxyServer.ts` | HTTP proxy with WebSocket support | | `handler.ts` | Request routing to dev server or Salesforce | diff --git a/messages/webapp.dev.md b/messages/webapp.dev.md index b84fb8b..a27d832 100644 --- a/messages/webapp.dev.md +++ b/messages/webapp.dev.md @@ -24,6 +24,8 @@ Dev server origin to forward UI/HMR/static requests The URL where your dev server is running (e.g., http://localhost:5173). Required if webapplication.json does not contain a dev.command or dev.url configuration. All non-Salesforce API requests will be forwarded to this URL. +Dev server URL precedence: --url flag > manifest dev.url > URL from dev server process (started via manifest dev.command or default npm run dev). + # flags.port.summary Local proxy port @@ -80,17 +82,25 @@ Dev server URL: %s # info.proxy-url -Proxy URL: %s (open this in your browser) +Proxy URL: %s (open this URL in your browser) # info.ready-for-development ✅ Ready for development! -→ Proxy: %s (open this in your browser) -→ Dev server: %s + → %s (open this URL in your browser) + +# info.ready-for-development-vite + +✅ Ready for development! + → %s (Vite proxy active - open this URL in your browser) # info.press-ctrl-c -Press Ctrl+C to stop the server +Press Ctrl+C to stop the proxy server + +# info.server-running + +Dev server is running. Stop it by running "SFDX: Close Live Preview" from the VS Code command palette. # info.dev-server-healthy @@ -145,40 +155,39 @@ Auto-selected webapp "%s" (running from inside its folder) ✅ Using webapp: %s (%s) +# info.starting-webapp + +✅ Starting %s + # prompt.select-webapp Select the webapp to run: -# warning.no-manifest +# info.no-manifest-defaults -No webapplication.json found for webapp "%s" -Location: %s +No webapplication.json found. Using defaults: dev command=%s, proxy port=%s -Using defaults: -→ Name: "%s" (derived from folder) -→ Command: "%s" -→ Manifest watching: disabled - -💡 To customize, create a webapplication.json file in your webapp directory. +Tip: See "sf webapp dev --help" for configuration options. # warning.empty-manifest -webapplication.json found for webapp "%s" but has no dev configuration -Location: %s - -Using defaults: -→ Name: "%s" (derived from folder) -→ Command: "%s" -→ Manifest watching: enabled +No dev configuration in webapplication.json - using defaults (command: %s) -💡 To customize, add dev configuration to your webapplication.json file. -Example: -{ -"dev": { -"command": "npm run dev" -} -} +Tip: See "sf webapp dev --help" for configuration options. # info.using-defaults Using default dev command: %s + +# info.url-already-available + +✅ URL %s is already available, skipping dev server startup (proxy-only mode) + +# warning.url-mismatch + +⚠️ The --url flag (%s) does not match the actual dev server URL (%s). +The proxy will use the actual dev server URL. + +# info.vite-proxy-detected + +Vite WebApp proxy detected at %s - using Vite's built-in proxy (standalone proxy skipped) diff --git a/package.json b/package.json index 9185089..292b35f 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@oclif/plugin-command-snapshot": "^5.3.8", "@salesforce/cli-plugins-testkit": "^5.3.41", "@salesforce/dev-scripts": "^11.0.4", - "@salesforce/plugin-command-reference": "^3.1.79", + "@salesforce/plugin-command-reference": "^3.1.77", "@types/http-proxy": "^1.17.14", "@types/micromatch": "^4.0.10", "eslint-plugin-sf-plugin": "^1.20.33", diff --git a/src/commands/webapp/dev.ts b/src/commands/webapp/dev.ts index fb49e59..c3b46d9 100644 --- a/src/commands/webapp/dev.ts +++ b/src/commands/webapp/dev.ts @@ -102,6 +102,46 @@ export default class WebappDev extends SfCommand { }); } + /** + * Check if a URL is reachable (returns true/false) + * Used to check if --url is already available before starting dev server + */ + private static async isUrlReachable(url: string): Promise { + try { + const response = await fetch(url, { + method: 'HEAD', + signal: AbortSignal.timeout(3000), // 3 second timeout + }); + return response.ok; + } catch { + return false; + } + } + + /** + * Check if Vite's WebAppProxyHandler is active at the dev server URL. + * The Vite plugin responds to a health check query parameter with a custom header + * when the proxy middleware is active. + * + * @param devServerUrl - The dev server URL to check + * @returns true if Vite's proxy is handling requests, false otherwise + */ + private static async checkViteProxyActive(devServerUrl: string): Promise { + try { + // The Vite plugin uses a query parameter for health checks, not a path + const healthUrl = new URL(devServerUrl); + healthUrl.searchParams.set('sfProxyHealthCheck', 'true'); + const response = await fetch(healthUrl.toString(), { + method: 'GET', + signal: AbortSignal.timeout(3000), // 3 second timeout + }); + return response.headers.get('X-Salesforce-WebApp-Proxy') === 'true'; + } catch { + // Health check failed - Vite proxy not active + return false; + } + } + // eslint-disable-next-line complexity public async run(): Promise { const { flags } = await this.parse(WebappDev); @@ -157,18 +197,12 @@ export default class WebappDev extends SfCommand { const hasDevConfig = manifest?.dev?.url != null || manifest?.dev?.command != null; if (!hasDevConfig) { // Manifest exists but has no dev configuration - show empty manifest warning - this.warn( - messages.getMessage('warning.empty-manifest', [ - selectedWebapp.name, - selectedWebapp.relativePath, - selectedWebapp.name, - DEFAULT_DEV_COMMAND, - ]) - ); + this.warn(messages.getMessage('warning.empty-manifest', [DEFAULT_DEV_COMMAND])); } - // Use selectedWebapp.name (already calculated with folder name fallback during discovery) - this.log(messages.getMessage('info.using-webapp', [selectedWebapp.name, selectedWebapp.relativePath])); + // Show starting message + this.log(''); + this.log(messages.getMessage('info.starting-webapp', [selectedWebapp.name])); this.logger.debug(`Manifest loaded: ${selectedWebapp.name}`); // Setup manifest change handler @@ -203,133 +237,193 @@ export default class WebappDev extends SfCommand { this.warn(messages.getMessage('error.manifest-watch-failed', [error.message])); }); } else { - // No manifest - show warning and use defaults - this.warn( - messages.getMessage('warning.no-manifest', [ - selectedWebapp.name, - selectedWebapp.relativePath, - selectedWebapp.name, - DEFAULT_DEV_COMMAND, - ]) - ); - this.log(messages.getMessage('info.using-webapp', [selectedWebapp.name, selectedWebapp.relativePath])); + // No manifest - log applied defaults for troubleshooting + this.log(messages.getMessage('info.no-manifest-defaults', [DEFAULT_DEV_COMMAND, String(flags.port)])); + this.log(''); + this.log(messages.getMessage('info.starting-webapp', [selectedWebapp.name])); } // Step 3: Determine dev server URL + // Track whether we should skip starting dev server (when --url is already reachable) + let skipDevServer = false; + let explicitUrlProvided = false; - // Priority: --url flag > manifest dev.url > manifest dev.command > default command (for no-manifest) + // Handle --url flag: check if URL is already reachable before starting dev server if (flags.url) { - devServerUrl = flags.url; - this.logger.debug(`Using explicit dev server URL: ${devServerUrl}`); - } else if (manifest?.dev?.url) { - devServerUrl = manifest.dev.url; - this.logger.debug(`Using dev server URL from manifest: ${devServerUrl}`); - } else { - // Determine command: from manifest or default - const devCommand = manifest?.dev?.command ?? DEFAULT_DEV_COMMAND; - - if (!selectedWebapp.hasManifest) { - this.logger.debug(messages.getMessage('info.using-defaults', [devCommand])); + explicitUrlProvided = true; + this.logger.debug(`Checking if explicit URL is reachable: ${flags.url}`); + + const isReachable = await WebappDev.isUrlReachable(flags.url); + + if (isReachable) { + // URL is already available - skip starting dev server, only start proxy + devServerUrl = flags.url; + skipDevServer = true; + this.log(messages.getMessage('info.url-already-available', [flags.url])); + this.logger.debug(`URL ${flags.url} is reachable, skipping dev server startup`); + } else { + // URL not reachable - will start dev server and check for mismatch later + this.logger.debug(`URL ${flags.url} is not reachable, will start dev server`); } + } - // Start dev server from the webapp directory - this.logger.debug(`Starting dev server with command: ${devCommand}`); - this.devServerManager = new DevServerManager({ - command: devCommand, - cwd: webappDir, - }); + // If we're not skipping dev server, determine how to start it + if (!skipDevServer) { + if (manifest?.dev?.url && !explicitUrlProvided) { + // Use manifest dev.url + devServerUrl = manifest.dev.url; + this.logger.debug(`Using dev server URL from manifest: ${devServerUrl}`); + } else { + // Start dev server with command + const devCommand = manifest?.dev?.command ?? DEFAULT_DEV_COMMAND; + + if (!selectedWebapp.hasManifest) { + this.logger.debug(messages.getMessage('info.using-defaults', [devCommand])); + } - // Setup dev server event handlers - this.devServerManager.on('ready', (url: string) => { - this.logger?.debug(`Dev server ready at: ${url}`); - // Clear any dev server error when server starts successfully - this.proxyServer?.clearActiveDevServerError(); - }); + // Start dev server from the webapp directory + this.logger.debug(`Starting dev server with command: ${devCommand}`); + this.devServerManager = new DevServerManager({ + command: devCommand, + cwd: webappDir, + startupTimeout: 60_000, // 60 seconds - aligned with VS Code extension + }); - this.devServerManager.on('error', (error: SfError | DevServerError) => { - // Set error for proxy to display in browser (if proxy is running) - // Don't log here - the error will be thrown and displayed by the main catch block - if ('stderrLines' in error && Array.isArray(error.stderrLines) && 'title' in error && 'type' in error) { - this.proxyServer?.setActiveDevServerError(error); - } - this.logger?.debug(`Dev server error: ${error.message}`); - }); + // Setup dev server event handlers + this.devServerManager.on('ready', (url: string) => { + this.logger?.debug(`Dev server ready at: ${url}`); + // Clear any dev server error when server starts successfully + this.proxyServer?.clearActiveDevServerError(); + }); - this.devServerManager.on('exit', () => { - this.logger?.debug('Dev server stopped'); - }); + this.devServerManager.on('error', (error: SfError | DevServerError) => { + // Set error for proxy to display in browser (if proxy is running) + // Don't log here - the error will be thrown and displayed by the main catch block + if ('stderrLines' in error && Array.isArray(error.stderrLines) && 'title' in error && 'type' in error) { + this.proxyServer?.setActiveDevServerError(error); + } + this.logger?.debug(`Dev server error: ${error.message}`); + }); - this.devServerManager.start(); - - // Wait for dev server to be ready - devServerUrl = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject( - new SfError('Dev server did not start within 30 seconds.', 'DevServerTimeoutError', [ - 'The dev server may be taking longer than expected to start', - 'Check if the dev server command is correct in webapplication.json', - 'Try running the dev server command manually to see if it starts', - ]) - ); - }, 30_000); - - this.devServerManager?.on('ready', (url: string) => { - clearTimeout(timeout); - resolve(url); + this.devServerManager.on('exit', () => { + this.logger?.debug('Dev server stopped'); }); - this.devServerManager?.on('error', (error: SfError) => { - clearTimeout(timeout); - reject(error); + this.devServerManager.start(); + + // Wait for dev server to be ready + const actualDevServerUrl = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject( + new SfError('❌ Dev server did not start within 60 seconds.', 'DevServerTimeoutError', [ + 'The dev server may be taking longer than expected to start', + 'Check if the dev server command is correct in webapplication.json', + 'Try running the dev server command manually to see if it starts', + ]) + ); + }, 60_000); + + this.devServerManager?.on('ready', (url: string) => { + clearTimeout(timeout); + resolve(url); + }); + + this.devServerManager?.on('error', (error: SfError) => { + clearTimeout(timeout); + reject(error); + }); }); - }); + + // Check for URL mismatch if --url was provided + if (explicitUrlProvided && flags.url && flags.url !== actualDevServerUrl) { + this.warn(messages.getMessage('warning.url-mismatch', [flags.url, actualDevServerUrl])); + } + + // Use the actual dev server URL + devServerUrl = actualDevServerUrl; + } } - // Step 3: Get org info for authentication + // Step 4: Get org info for authentication const orgConnection = flags['target-org'].getConnection(undefined); orgUsername = flags['target-org'].getUsername() ?? orgConnection.getUsername() ?? 'unknown'; this.logger.debug(`Using authentication for org: ${orgUsername}`); - // Step 4: Start proxy server - this.logger.debug(`Starting proxy server on port ${flags.port}...`); - const salesforceInstanceUrl = orgConnection.instanceUrl; - this.proxyServer = new ProxyServer({ - devServerUrl, - salesforceInstanceUrl, - port: flags.port, - manifest: manifest ?? undefined, - orgAlias: orgUsername, - }); + // Ensure devServerUrl is set (should always be set by step 3) + if (!devServerUrl) { + throw new SfError( + '❌ Unable to determine dev server URL. Please specify --url or configure dev.url in webapplication.json.', + 'DevServerUrlError' + ); + } - await this.proxyServer.start(); - const proxyUrl = this.proxyServer.getProxyUrl(); - this.logger.debug(`Proxy server running on ${proxyUrl}`); + // Step 5: Check for Vite proxy and conditionally start standalone proxy + this.logger.debug('Checking if Vite WebApp proxy is active...'); + const viteProxyActive = await WebappDev.checkViteProxyActive(devServerUrl); - // Listen for dev server status changes (minimal output) - this.proxyServer.on('dev-server-up', (url: string) => { - this.logger?.debug(messages.getMessage('info.dev-server-detected', [url])); - }); + // Track the final URL to open in browser (either proxy or dev server) + let finalUrl: string; - this.proxyServer.on('dev-server-down', (url: string) => { - this.log(messages.getMessage('warning.dev-server-unreachable-status', [url])); - this.log(messages.getMessage('info.start-dev-server-hint')); - }); + if (viteProxyActive) { + // Vite's WebAppProxyHandler is handling the proxy - skip standalone proxy + this.log(messages.getMessage('info.vite-proxy-detected', [devServerUrl])); + this.logger.debug('Vite proxy detected, skipping standalone proxy server'); + finalUrl = devServerUrl; + } else { + // Start standalone proxy server + this.logger.debug(`Starting proxy server on port ${flags.port}...`); + const salesforceInstanceUrl = orgConnection.instanceUrl; + this.proxyServer = new ProxyServer({ + devServerUrl, + salesforceInstanceUrl, + port: flags.port, + manifest: manifest ?? undefined, + orgAlias: orgUsername, + }); + + await this.proxyServer.start(); + const proxyUrl = this.proxyServer.getProxyUrl(); + this.logger.debug(`Proxy server running on ${proxyUrl}`); + + // Listen for dev server status changes (minimal output) + this.proxyServer.on('dev-server-up', (url: string) => { + this.logger?.debug(messages.getMessage('info.dev-server-detected', [url])); + }); + + this.proxyServer.on('dev-server-down', (url: string) => { + this.log(messages.getMessage('warning.dev-server-unreachable-status', [url])); + this.log(messages.getMessage('info.start-dev-server-hint')); + }); - // Step 5: Check if dev server is reachable (non-blocking warning) - if (devServerUrl) { + finalUrl = proxyUrl; + } + + // Step 6: Check if dev server is reachable (non-blocking warning) - only when using standalone proxy + if (!viteProxyActive && devServerUrl) { await this.checkDevServerHealth(devServerUrl); } - // Step 6: Open browser if requested + // Step 7: Open browser if requested if (flags.open) { this.logger.debug('Opening browser...'); - await WebappDev.openBrowser(proxyUrl); + await WebappDev.openBrowser(finalUrl); } // Display usage instructions this.log(''); - this.log(messages.getMessage('info.ready-for-development', [proxyUrl, devServerUrl ?? 'N/A'])); - this.log(messages.getMessage('info.press-ctrl-c')); + if (viteProxyActive) { + this.log(messages.getMessage('info.ready-for-development-vite', [devServerUrl])); + } else { + this.log(messages.getMessage('info.ready-for-development', [finalUrl])); + } + // Show appropriate stop message based on execution context + // In TTY (interactive terminal): show "Press Ctrl+C to stop" + // In non-TTY (IDE, CI, piped): show generic "Server running" message + if (process.stdout.isTTY) { + this.log(messages.getMessage('info.press-ctrl-c')); + } else { + this.log(messages.getMessage('info.server-running')); + } this.log(''); // Keep the command running until interrupted or dev server exits @@ -361,7 +455,7 @@ export default class WebappDev extends SfCommand { // Return result (never reached, but required for type safety) return { - url: proxyUrl, + url: finalUrl, devServerUrl: devServerUrl ?? '', }; } catch (error) { @@ -375,7 +469,7 @@ export default class WebappDev extends SfCommand { // Wrap unknown errors const errorMessage = error instanceof Error ? error.message : String(error); - throw new SfError(`Failed to start webapp dev command: ${errorMessage}`, 'UnexpectedError', [ + throw new SfError(`❌ Failed to start webapp dev command: ${errorMessage}`, 'UnexpectedError', [ 'This is an unexpected error', 'Please try again', 'If the problem persists, check the command logs with SF_LOG_LEVEL=debug', diff --git a/src/config/webappDiscovery.ts b/src/config/webappDiscovery.ts index 3c756f0..fa2df5c 100644 --- a/src/config/webappDiscovery.ts +++ b/src/config/webappDiscovery.ts @@ -14,20 +14,28 @@ * limitations under the License. */ -import { readdir, readFile } from 'node:fs/promises'; +import { access, readdir, readFile } from 'node:fs/promises'; import { basename, dirname, join, relative } from 'node:path'; -import { SfError } from '@salesforce/core'; +import { Logger, SfError, SfProject } from '@salesforce/core'; import type { WebAppManifest } from './manifest.js'; +const logger = Logger.childFromRoot('WebappDiscovery'); + /** * Default command to run when no webapplication.json manifest is found */ export const DEFAULT_DEV_COMMAND = 'npm run dev'; /** - * Pattern to match the webapplications folder (case-insensitive) + * Standard metadata path segment for webapplications (relative to package directory). + * Consistent with other metadata types: packagePath/main/default/webapplications + */ +const WEBAPPLICATIONS_RELATIVE_PATH = 'main/default/webapplications'; + +/** + * Pattern to match webapplication metadata XML files */ -const WEBAPPLICATIONS_FOLDER_PATTERN = /^webapplications$/i; +const WEBAPP_META_XML_PATTERN = /^(.+)\.webapplication-meta\.xml$/; /** * Discovered webapp with its directory path and optional manifest @@ -39,49 +47,82 @@ export type DiscoveredWebapp = { relativePath: string; /** Parsed manifest content (null if no webapplication.json found) */ manifest: WebAppManifest | null; - /** Webapp name (from manifest.name or folder name) */ + /** Webapp name (from .webapplication-meta.xml or folder name) */ name: string; /** Whether this webapp has a webapplication.json manifest file */ hasManifest: boolean; /** Path to the manifest file (null if no manifest) */ manifestPath: string | null; + /** Whether this webapp has a .webapplication-meta.xml file (valid SFDX webapp) */ + hasMetaXml: boolean; }; /** - * Directories to exclude when searching for webapplications folder - */ -const EXCLUDED_DIRECTORIES = new Set([ - 'node_modules', - '.git', - 'dist', - 'build', - 'out', - 'coverage', - '.next', - '.nuxt', - '.output', - '__pycache__', - '.venv', - 'venv', -]); - -/** - * Maximum depth to search for webapplications folder + * Directories to exclude when processing webapplications folder. + * Note: Directories starting with '.' are excluded separately in shouldExcludeDirectory() */ -const MAX_SEARCH_DEPTH = 10; +const EXCLUDED_DIRECTORIES = new Set(['node_modules', 'dist', 'build', 'out', 'coverage', '__pycache__', 'venv']); /** - * Check if a directory should be excluded from search + * Check if a directory should be excluded from processing */ function shouldExcludeDirectory(dirName: string): boolean { return EXCLUDED_DIRECTORIES.has(dirName) || dirName.startsWith('.'); } +/** Folder name for webapplications metadata */ +const WEBAPPLICATIONS_FOLDER = 'webapplications'; + /** - * Check if a folder name matches "webapplications" (case-insensitive) + * Check if a folder name is the standard webapplications folder */ function isWebapplicationsFolder(folderName: string): boolean { - return WEBAPPLICATIONS_FOLDER_PATTERN.test(folderName); + return folderName === WEBAPPLICATIONS_FOLDER; +} + +/** + * Check if a directory contains a {name}.webapplication-meta.xml file + * Returns the webapp name extracted from the filename, or null if not found. + * Logs a warning if multiple metadata files are found (uses first match). + */ +async function findWebappMetaXml(dirPath: string): Promise { + try { + const entries = await readdir(dirPath); + const matches: string[] = []; + + for (const entry of entries) { + const match = WEBAPP_META_XML_PATTERN.exec(entry); + if (match) { + matches.push(match[1]); + } + } + + if (matches.length === 0) { + return null; + } + + if (matches.length > 1) { + logger.warn( + `Multiple .webapplication-meta.xml files found in ${dirPath}: ${matches.join(', ')}. Using "${matches[0]}".` + ); + } + + return matches[0]; + } catch { + return null; + } +} + +/** + * Check if a path exists + */ +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } } /** @@ -105,38 +146,55 @@ async function tryParseWebappManifest(filePath: string): Promise folderName. + * Manifest does not have a name property - do not depend on it. + * + * @param folderName - The folder name (fallback) + * @param metaXmlName - Name extracted from .webapplication-meta.xml (or null) + * @returns The resolved webapp name + */ +function resolveWebappName(folderName: string, metaXmlName: string | null): string { + return metaXmlName ?? folderName; +} + +/** + * Try to resolve SFDX project root from a given directory. + * Uses SfProject from @salesforce/core which walks up directories to find sfdx-project.json. * - * @param dir - Directory to search in - * @param depth - Current search depth - * @returns Path to webapplications folder or null if not found + * @param cwd - Directory to start from + * @returns Project root path or null if not in an SFDX project */ -async function findWebapplicationsFolderRecursive(dir: string, depth: number = 0): Promise { - if (depth > MAX_SEARCH_DEPTH) { +async function tryResolveSfdxProjectRoot(cwd: string): Promise { + try { + return await SfProject.resolveProjectPath(cwd); + } catch { + // Not in an SFDX project return null; } +} +/** + * Get all webapplications folder paths from the project's package directories. + * Consistent with other metadata types: each package can have main/default/webapplications. + * + * @param projectRoot - Absolute path to project root (where sfdx-project.json lives) + * @returns Array of absolute paths to webapplications folders that exist + */ +async function getWebapplicationsPathsFromProject(projectRoot: string): Promise { try { - const entries = await readdir(dir, { withFileTypes: true }); - - // Check if any directory at this level is "webapplications" (case-insensitive) - const webappsFolder = entries.find((e) => e.isDirectory() && isWebapplicationsFolder(e.name)); - if (webappsFolder) { - return join(dir, webappsFolder.name); - } - - // Recursively search subdirectories in parallel - const subdirectories = entries.filter((e) => e.isDirectory() && !shouldExcludeDirectory(e.name)); - - const results = await Promise.all( - subdirectories.map((subdir) => findWebapplicationsFolderRecursive(join(dir, subdir.name), depth + 1)) + const project = await SfProject.resolve(projectRoot); + const packageDirs = project.getUniquePackageDirectories(); + + const existenceChecks = await Promise.all( + packageDirs.map(async (pkg) => { + const webappsPath = join(projectRoot, pkg.path, WEBAPPLICATIONS_RELATIVE_PATH); + return (await pathExists(webappsPath)) ? webappsPath : null; + }) ); - // Return the first non-null result - return results.find((result) => result !== null) ?? null; + return existenceChecks.filter((p): p is string => p !== null); } catch { - // Permission denied or other read error - skip this directory - return null; + return []; } } @@ -145,21 +203,17 @@ async function findWebapplicationsFolderRecursive(dir: string, depth: number = 0 * * This handles cases where the user runs the command from inside a webapp folder: * - * Example 1: Running from /project/webapplications/my-app/src/ + * Example 1: Running from /project/force-app/main/default/webapplications/my-app/src/ * Traverses: src -> my-app -> webapplications (found!) - * Returns: { webappsFolder: "/project/webapplications", currentWebappName: "my-app" } + * Returns: { webappsFolder: "/project/.../webapplications", currentWebappName: "my-app" } * - * Example 2: Running from /project/webapplications/my-app/ + * Example 2: Running from /project/force-app/main/default/webapplications/my-app/ * Checks parent: webapplications (found!) - * Returns: { webappsFolder: "/project/webapplications", currentWebappName: "my-app" } + * Returns: { webappsFolder: "/project/.../webapplications", currentWebappName: "my-app" } * - * Example 3: Running from /project/webapplications/ + * Example 3: Running from /project/force-app/main/default/webapplications/ * Current dir is webapplications (found!) - * Returns: { webappsFolder: "/project/webapplications", currentWebappName: null } - * - * Example 4: Running from /project/src/ - * Traverses: src -> project -> / (not found) - * Returns: null (will fall back to downward search) + * Returns: { webappsFolder: "/project/.../webapplications", currentWebappName: null } * * @param dir - Directory to start from * @returns Object with webapplications folder path and current webapp name, or null if not found @@ -211,13 +265,13 @@ function findWebapplicationsFolderUpward( } /** - * Discover all webapps inside the webapplications folder - * Each subdirectory is treated as a webapp. If a webapplication.json exists, use it. - * Otherwise, use the folder name as the webapp name with defaults. + * Discover all webapps inside the webapplications folder. + * Only directories containing a {name}.webapplication-meta.xml file are considered valid webapps. + * If a webapplication.json exists, use it for dev configuration. * * @param webappsFolderPath - Absolute path to the webapplications folder * @param cwd - Original working directory for relative path calculation - * @returns Array of discovered webapps + * @returns Array of discovered webapps (only those with .webapplication-meta.xml) */ async function discoverWebappsInFolder(webappsFolderPath: string, cwd: string): Promise { try { @@ -227,41 +281,37 @@ async function discoverWebappsInFolder(webappsFolderPath: string, cwd: string): const webappDirs = entries.filter((e) => e.isDirectory() && !shouldExcludeDirectory(e.name)); // Process each webapp directory in parallel - const webappPromises = webappDirs.map(async (entry): Promise => { + const webappPromises = webappDirs.map(async (entry): Promise => { const webappPath = join(webappsFolderPath, entry.name); + + // Check for .webapplication-meta.xml file - this identifies valid webapps + const metaXmlName = await findWebappMetaXml(webappPath); + + // Only include directories that have a .webapplication-meta.xml file + if (!metaXmlName) { + return null; + } + const manifestFilePath = join(webappPath, 'webapplication.json'); - // Try to load manifest + // Try to load manifest for dev configuration const manifest = await tryParseWebappManifest(manifestFilePath); - if (manifest) { - // Webapp has manifest file - use manifest data with folder name as fallback - // Name: use manifest.name if present, otherwise folder name - const webappName = - manifest.name && typeof manifest.name === 'string' && manifest.name.trim() ? manifest.name : entry.name; - - return { - path: webappPath, - relativePath: relative(cwd, webappPath) || entry.name, - manifest, - name: webappName, - hasManifest: true, - manifestPath: manifestFilePath, - }; - } else { - // No manifest file - use folder name and defaults - return { - path: webappPath, - relativePath: relative(cwd, webappPath) || entry.name, - manifest: null, - name: entry.name, - hasManifest: false, - manifestPath: null, - }; - } + return { + path: webappPath, + relativePath: relative(cwd, webappPath) || entry.name, + manifest, + name: resolveWebappName(entry.name, metaXmlName), + hasManifest: manifest !== null, + manifestPath: manifest ? manifestFilePath : null, + hasMetaXml: true, + }; }); - return await Promise.all(webappPromises); + const results = await Promise.all(webappPromises); + + // Filter out null results (directories without .webapplication-meta.xml) + return results.filter((webapp): webapp is DiscoveredWebapp => webapp !== null); } catch { // Permission denied or other read error return []; @@ -269,41 +319,102 @@ async function discoverWebappsInFolder(webappsFolderPath: string, cwd: string): } /** - * Result of finding all webapps, includes hint for auto-selection + * Result of finding all webapps, includes context about current location */ type FindAllWebappsResult = { /** All discovered webapps */ webapps: DiscoveredWebapp[]; - /** Name of webapp user is currently inside (for auto-selection), null if not inside any */ + /** Name of webapp user is currently inside (folder name), null if not inside any */ currentWebappName: string | null; - /** Whether the webapplications folder was found (even if empty) */ + /** Whether the webapplications folder was found (even if empty or no valid webapps) */ webappsFolderFound: boolean; + /** Whether we're in an SFDX project context */ + inSfdxProject: boolean; }; /** - * Find all webapps in the webapplications folder. - * Also returns a hint if the user is currently inside a specific webapp folder. + * Find all webapps using simplified discovery algorithm. + * + * Discovery strategy (in order): + * 1. Check if inside a webapplications/ directory (upward search) + * 2. Check for SFDX project and search webapplications in all package directories + * 3. If neither, check if current directory is a webapp (has .webapplication-meta.xml) * * @param cwd - Directory to search from (defaults to process.cwd()) - * @returns Object with discovered webapps and currentWebappName hint for auto-selection + * @returns Object with discovered webapps and context information */ async function findAllWebapps(cwd: string = process.cwd()): Promise { - // Step 1: Check upward first - this gives us currentWebappName if inside a webapp - const upwardResult = findWebapplicationsFolderUpward(cwd); - let webappsFolder: string | null = null; let currentWebappName: string | null = null; + let inSfdxProject = false; + + // Step 1: Check if we're inside a webapplications folder (upward search) + // This handles: running from webapplications/ or webapplications//src/ + const upwardResult = findWebapplicationsFolderUpward(cwd); if (upwardResult) { webappsFolder = upwardResult.webappsFolder; currentWebappName = upwardResult.currentWebappName; } else { - // Step 2: Search downward if not found upward - webappsFolder = await findWebapplicationsFolderRecursive(cwd); + // Step 2: Check for SFDX project and search webapplications in all package directories + const projectRoot = await tryResolveSfdxProjectRoot(cwd); + + if (projectRoot) { + inSfdxProject = true; + const webappsPaths = await getWebapplicationsPathsFromProject(projectRoot); + + if (webappsPaths.length > 0) { + // Discover webapps from all package directories and combine + const webappArrays = await Promise.all( + webappsPaths.map((path) => discoverWebappsInFolder(path, cwd)) + ); + const allWebapps = webappArrays.flat(); + + return { + webapps: allWebapps.sort((a, b) => a.name.localeCompare(b.name)), + currentWebappName: null, + webappsFolderFound: true, + inSfdxProject, + }; + } + } } + // Step 3: If no webapplications folder found, check if current directory IS a webapp + // (has a .webapplication-meta.xml file) - for running outside SFDX project context if (!webappsFolder) { - return { webapps: [], currentWebappName: null, webappsFolderFound: false }; + const metaXmlName = await findWebappMetaXml(cwd); + if (metaXmlName) { + // Current directory is a standalone webapp + const manifestFilePath = join(cwd, 'webapplication.json'); + const manifest = await tryParseWebappManifest(manifestFilePath); + const webappName = resolveWebappName(basename(cwd), metaXmlName); + + const standaloneWebapp: DiscoveredWebapp = { + path: cwd, + relativePath: '.', + manifest, + name: webappName, + hasManifest: manifest !== null, + manifestPath: manifest ? manifestFilePath : null, + hasMetaXml: true, + }; + + return { + webapps: [standaloneWebapp], + currentWebappName: webappName, + webappsFolderFound: false, + inSfdxProject: false, + }; + } + + // No webapp found anywhere + return { + webapps: [], + currentWebappName: null, + webappsFolderFound: false, + inSfdxProject, + }; } // Discover all webapps in the folder @@ -314,14 +425,15 @@ async function findAllWebapps(cwd: string = process.cwd()): Promise a.name.localeCompare(b.name)), currentWebappName, webappsFolderFound: true, + inSfdxProject, }; } /** * Result of webapp discovery */ -type DiscoverWebappResult = { - /** The selected/discovered webapp (null if user needs to select) */ +export type DiscoverWebappResult = { + /** The selected/discovered webapp (null if user needs to select via prompt) */ webapp: DiscoveredWebapp | null; /** All discovered webapps */ allWebapps: DiscoveredWebapp[]; @@ -332,60 +444,93 @@ type DiscoverWebappResult = { /** * Get a single webapp, handling the various discovery scenarios. * - * Selection priority: - * 1. If --name flag provided, find that specific webapp - * 2. If user is inside a webapp folder, auto-select that webapp - * 3. If only one webapp exists, auto-select it - * 4. If multiple webapps, return null (user must select) + * Discovery use cases: + * 1. SFDX Project Root: Search webapplications in all package directories + * - Webapps identified by {name}.webapplication-meta.xml + * - Always prompt for selection (even if only 1 webapp) * - * @param name - Optional webapp name to search for + * 2. Inside webapplications/ directory: + * - Auto-select current webapp + * - Error if --name conflicts with current directory + * + * 3. Outside SFDX project with .webapplication-meta.xml in current dir: + * - Use current directory as standalone webapp + * + * @param name - Optional webapp name to search for (--name flag) * @param cwd - Directory to search from * @returns Object containing the discovered webapp, all webapps, and autoSelected flag - * @throws SfError if no webapps found or named webapp not found + * @throws SfError if no webapps found, named webapp not found, or --name conflicts with current dir */ export async function discoverWebapp( name: string | undefined, cwd: string = process.cwd() ): Promise { - const { webapps: allWebapps, currentWebappName, webappsFolderFound } = await findAllWebapps(cwd); + const { webapps: allWebapps, currentWebappName, webappsFolderFound, inSfdxProject } = await findAllWebapps(cwd); // No webapps found if (allWebapps.length === 0) { if (webappsFolderFound) { - // Folder exists but is empty + // Folder exists but no valid webapps (no .webapplication-meta.xml files) throw new SfError( - 'Found "webapplications" folder but no webapps inside it.\n' + - 'Create webapp subdirectories inside the "webapplications" folder to get started.\n\n' + + 'Found "webapplications" folder but no valid webapps inside it.\n' + + 'Each webapp must have a {name}.webapplication-meta.xml file.\n\n' + 'Expected structure:\n' + ' webapplications/\n' + - ' ├── my-app-1/\n' + - ' │ └── webapplication.json (optional)\n' + - ' └── my-app-2/', + ' └── my-app/\n' + + ' ├── my-app.webapplication-meta.xml (required)\n' + + ' └── webapplication.json (optional, for dev config)', + 'WebappNotFoundError' + ); + } else if (inSfdxProject) { + // In SFDX project but webapplications folder doesn't exist + throw new SfError( + 'No webapplications folder found in the SFDX project.\n\n' + + 'Create the folder structure in any package directory (e.g. force-app, packages/my-pkg):\n' + + ' /main/default/webapplications/\n' + + ' └── my-app/\n' + + ' ├── my-app.webapplication-meta.xml (required)\n' + + ' └── webapplication.json (optional, for dev config)', 'WebappNotFoundError' ); } else { - // Folder doesn't exist + // Not in SFDX project and no webapp found throw new SfError( - 'No webapplications folder found in the current directory or subdirectories.\n' + - 'Create a "webapplications" folder with webapp subdirectories to get started.\n\n' + - 'Expected structure:\n' + - ' webapplications/\n' + - ' ├── my-app-1/\n' + - ' │ └── webapplication.json (optional)\n' + - ' └── my-app-2/', + 'No webapp found.\n\n' + + 'To use this command, either:\n' + + '1. Run from an SFDX project with webapps in /main/default/webapplications/\n' + + '2. Run from inside a webapplications// directory\n' + + '3. Run from a directory containing a {name}.webapplication-meta.xml file', 'WebappNotFoundError' ); } } - // Priority 1: If name is provided via --name flag, find that specific webapp + // Check for --name conflict with current directory + // If user is inside webapp A but specifies --name B, that's an error + if (name && currentWebappName) { + const currentWebapp = allWebapps.find( + (w) => w.name === currentWebappName || basename(w.path) === currentWebappName + ); + if (currentWebapp && currentWebapp.name !== name && basename(currentWebapp.path) !== name) { + throw new SfError( + `You are inside the "${currentWebappName}" webapp directory but specified --name "${name}".\n\n` + + 'Either:\n' + + ` - Remove the --name flag to use the current webapp ("${currentWebappName}")\n` + + ` - Navigate to the "${name}" webapp directory and run the command from there\n` + + ' - Run the command from the project root to use --name', + 'WebappNameConflictError' + ); + } + } + + // Priority 1: If --name flag provided, find that specific webapp if (name) { - const webapp = allWebapps.find((w) => w.name === name); + const webapp = allWebapps.find((w) => w.name === name || basename(w.path) === name); if (!webapp) { const WARNING = '\u26A0\uFE0F'; // ⚠️ const availableNames = allWebapps - .map((w) => ` - ${w.name} - (Path:${w.relativePath})${w.hasManifest ? '' : ` - ${WARNING} No Manifest`}`) + .map((w) => ` - ${w.name} (${w.relativePath})${w.hasManifest ? '' : ` ${WARNING} No dev manifest`}`) .join('\n'); throw new SfError( `No webapp found with name "${name}".\n\nAvailable webapps:\n${availableNames}`, @@ -396,7 +541,6 @@ export async function discoverWebapp( } // Priority 2: If user is inside a webapp folder, auto-select that webapp - // Match by webapp.name OR by folder name (extracted from path) if (currentWebappName) { const webapp = allWebapps.find((w) => w.name === currentWebappName || basename(w.path) === currentWebappName); if (webapp) { @@ -404,11 +548,7 @@ export async function discoverWebapp( } } - // Priority 3: If only one webapp exists, auto-select it - if (allWebapps.length === 1) { - return { webapp: allWebapps[0], allWebapps, autoSelected: false }; - } - - // Multiple webapps found - return null to indicate selection is needed + // No auto-selection - always prompt user to select + // (Removed: auto-selection of single webapp - reviewer wants prompt even for 1 webapp) return { webapp: null, allWebapps, autoSelected: false }; } diff --git a/src/error/DevServerErrorParser.ts b/src/error/DevServerErrorParser.ts index 4d1009b..f2d380f 100644 --- a/src/error/DevServerErrorParser.ts +++ b/src/error/DevServerErrorParser.ts @@ -189,8 +189,9 @@ export class DevServerErrorParser { } } - // Fallback (should never reach here due to catch-all pattern) - return this.createGenericError(stderr, exitCode, signal); + // This point is unreachable because the last pattern (.*) matches everything + // TypeScript requires a return statement, so we throw an error for safety + throw new Error('Unreachable: ERROR_PATTERNS catch-all should always match'); } /** @@ -216,29 +217,4 @@ export class DevServerErrorParser { // Return last N lines (most recent errors) return lines.slice(-maxLines); } - - /** - * Create a generic error when no specific pattern matches - * - * @param stderr - Full stderr output - * @param exitCode - Process exit code - * @param signal - Process exit signal - * @returns Generic DevServerError - */ - private static createGenericError(stderr: string, exitCode?: number | null, signal?: string | null): DevServerError { - return { - type: 'unknown', - title: 'Dev Server Failed to Start', - message: 'The dev server encountered an error. Check the error output below for details.', - stderrLines: this.extractRelevantLines(stderr, 15), - suggestions: [ - 'Review the error output above', - 'Try running your dev command manually to debug', - 'Verify your project setup is correct', - ], - fullStderr: stderr, - exitCode, - signal, - }; - } } diff --git a/src/proxy/ProxyServer.ts b/src/proxy/ProxyServer.ts index 62d0af1..8e3f977 100644 --- a/src/proxy/ProxyServer.ts +++ b/src/proxy/ProxyServer.ts @@ -176,11 +176,6 @@ export class ProxyServer extends EventEmitter { this.server.on('connection', (socket) => { this.activeConnections.add(socket); - socket.on('error', (err) => { - // Handle ECONNRESET and other socket errors gracefully - // These can happen when the dev server crashes or a client disconnects abruptly - this.logger.debug(`Socket error (${err.message}), cleaning up connection`); - }); socket.once('close', () => { this.activeConnections.delete(socket); }); diff --git a/src/server/DevServerManager.ts b/src/server/DevServerManager.ts index 1c55a6f..6475d0d 100644 --- a/src/server/DevServerManager.ts +++ b/src/server/DevServerManager.ts @@ -210,7 +210,7 @@ export class DevServerManager extends EventEmitter { // Validate that command is provided if (!this.options.command) { throw new SfError( - 'Dev server command is required when explicit URL is not provided', + '❌ Dev server command is required when explicit URL is not provided', 'DevServerCommandRequired', ['Provide a "command" in DevServerOptions', 'Or provide an "explicitUrl" to skip spawning'] ); @@ -232,7 +232,7 @@ export class DevServerManager extends EventEmitter { } catch (error) { const sfError = error instanceof Error ? error : new Error(error instanceof Object ? JSON.stringify(error) : String(error)); - throw new SfError(`Failed to spawn dev server process: ${sfError.message}`, 'DevServerSpawnError', [ + throw new SfError(`❌ Failed to spawn dev server process: ${sfError.message}`, 'DevServerSpawnError', [ `Verify the command is correct: ${this.options.command}`, 'Check that the executable exists in your PATH', 'Ensure you have the necessary dependencies installed', @@ -346,9 +346,11 @@ export class DevServerManager extends EventEmitter { // Emit output event for consumers this.emit(stream, output); + // Split lines once and reuse for all operations + const lines = output.split('\n').filter((line) => line.trim()); + // Capture stderr lines for error parsing if (stream === 'stderr') { - const lines = output.split('\n').filter((line) => line.trim()); this.stderrBuffer.push(...lines); // Keep only the last N lines to prevent memory issues @@ -358,7 +360,6 @@ export class DevServerManager extends EventEmitter { } // Log dev server output (only visible when SF_LOG_LEVEL=debug) - const lines = output.split('\n').filter((line) => line.trim()); for (const line of lines) { this.logger.debug(`[Dev Server ${stream}] ${line}`); } @@ -429,10 +430,12 @@ export class DevServerManager extends EventEmitter { this.logger.error(`Dev server error: ${parsedError.title}`); this.logger.debug(`Error type: ${parsedError.type}`); - // Emit the parsed DevServerError directly so the receiver (dev.ts) - // can access stderrLines, title, and type for the error page. - // Previously this was wrapped in SfError which lost those properties. - this.emit('error', parsedError); + // Convert to SfError for proper error handling + // Use just the message (not title) since title will be shown separately + // Prefix with ❌ for visual consistency with success messages (✅) + const sfError = new SfError(`❌ ${parsedError.message}`, 'DevServerError', parsedError.suggestions); + + this.emit('error', sfError); } // Reset state @@ -449,7 +452,7 @@ export class DevServerManager extends EventEmitter { private handleProcessError(error: Error): void { this.logger.error(`Dev server process error: ${error.message}`); - const sfError = new SfError(`Dev server process error: ${error.message}`, 'DevServerProcessError', [ + const sfError = new SfError(`❌ Dev server process error: ${error.message}`, 'DevServerProcessError', [ 'Check that the command is correct in webapplication.json', 'Verify all dependencies are installed', 'Try running the command manually to see the error', @@ -468,7 +471,7 @@ export class DevServerManager extends EventEmitter { this.logger.error('Dev server failed to start within timeout period'); const error = new SfError( - `Dev server did not start within ${this.options.startupTimeout / 1000} seconds`, + `❌ Dev server did not start within ${this.options.startupTimeout / 1000} seconds`, 'DevServerStartupTimeout', [ 'The dev server may be taking longer than expected to start', diff --git a/test/commands/webapp/dev.test.ts b/test/commands/webapp/dev.test.ts index 3a0fcbd..ceb9668 100644 --- a/test/commands/webapp/dev.test.ts +++ b/test/commands/webapp/dev.test.ts @@ -15,6 +15,7 @@ */ import { expect } from 'chai'; +import sinon from 'sinon'; import { TestContext } from '@salesforce/core/testSetup'; import type { WebAppManifest, WebAppDevResult } from '../../../src/config/types.js'; @@ -25,6 +26,151 @@ describe('webapp:dev command integration', () => { $$.restore(); }); + describe('Vite Proxy Detection', () => { + let fetchStub: sinon.SinonStub; + + beforeEach(() => { + fetchStub = sinon.stub(global, 'fetch'); + }); + + afterEach(() => { + fetchStub.restore(); + }); + + /** + * Helper function that mirrors the checkViteProxyActive logic from dev.ts + * This allows us to test the detection behavior without needing to run the full command + */ + async function checkViteProxyActive(devServerUrl: string): Promise { + try { + const healthUrl = new URL(devServerUrl); + healthUrl.searchParams.set('sfProxyHealthCheck', 'true'); + const response = await fetch(healthUrl.toString(), { + method: 'GET', + signal: AbortSignal.timeout(3000), + }); + return response.headers.get('X-Salesforce-WebApp-Proxy') === 'true'; + } catch { + return false; + } + } + + it('should return true when X-Salesforce-WebApp-Proxy header is present and true', async () => { + const mockHeaders = new Headers(); + mockHeaders.set('X-Salesforce-WebApp-Proxy', 'true'); + + fetchStub.resolves({ + ok: true, + headers: mockHeaders, + } as Response); + + const result = await checkViteProxyActive('http://localhost:5173'); + + expect(result).to.be.true; + expect(fetchStub.calledOnce).to.be.true; + + // Verify the correct URL with query parameter was called + const calledUrl = fetchStub.firstCall.args[0] as string; + expect(calledUrl).to.include('sfProxyHealthCheck=true'); + }); + + it('should return false when X-Salesforce-WebApp-Proxy header is not present', async () => { + const mockHeaders = new Headers(); + // No X-Salesforce-WebApp-Proxy header + + fetchStub.resolves({ + ok: true, + headers: mockHeaders, + } as Response); + + const result = await checkViteProxyActive('http://localhost:5173'); + + expect(result).to.be.false; + }); + + it('should return false when X-Salesforce-WebApp-Proxy header is present but not "true"', async () => { + const mockHeaders = new Headers(); + mockHeaders.set('X-Salesforce-WebApp-Proxy', 'false'); + + fetchStub.resolves({ + ok: true, + headers: mockHeaders, + } as Response); + + const result = await checkViteProxyActive('http://localhost:5173'); + + expect(result).to.be.false; + }); + + it('should return false when fetch throws an error (network failure)', async () => { + fetchStub.rejects(new Error('Network error')); + + const result = await checkViteProxyActive('http://localhost:5173'); + + expect(result).to.be.false; + }); + + it('should return false when fetch times out', async () => { + fetchStub.rejects(new DOMException('The operation was aborted', 'AbortError')); + + const result = await checkViteProxyActive('http://localhost:5173'); + + expect(result).to.be.false; + }); + + it('should return false when dev server is not reachable (connection refused)', async () => { + fetchStub.rejects(new TypeError('Failed to fetch')); + + const result = await checkViteProxyActive('http://localhost:5173'); + + expect(result).to.be.false; + }); + + it('should construct correct health check URL with query parameter', async () => { + const mockHeaders = new Headers(); + mockHeaders.set('X-Salesforce-WebApp-Proxy', 'true'); + + fetchStub.resolves({ + ok: true, + headers: mockHeaders, + } as Response); + + await checkViteProxyActive('http://localhost:5173'); + + const calledUrl = fetchStub.firstCall.args[0] as string; + expect(calledUrl).to.equal('http://localhost:5173/?sfProxyHealthCheck=true'); + }); + + it('should preserve existing query parameters when adding health check', async () => { + const mockHeaders = new Headers(); + mockHeaders.set('X-Salesforce-WebApp-Proxy', 'true'); + + fetchStub.resolves({ + ok: true, + headers: mockHeaders, + } as Response); + + await checkViteProxyActive('http://localhost:5173/?existing=param'); + + const calledUrl = fetchStub.firstCall.args[0] as string; + expect(calledUrl).to.include('existing=param'); + expect(calledUrl).to.include('sfProxyHealthCheck=true'); + }); + + it('should use GET method for health check request', async () => { + const mockHeaders = new Headers(); + fetchStub.resolves({ + ok: true, + headers: mockHeaders, + } as Response); + + await checkViteProxyActive('http://localhost:5173'); + + const options = fetchStub.firstCall.args[1] as RequestInit; + expect(options.method).to.equal('GET'); + }); + }); + describe('Type Definitions', () => { it('should have correct WebAppManifest structure', () => { const manifest: WebAppManifest = { @@ -112,4 +258,156 @@ describe('webapp:dev command integration', () => { expect(manifest.dev?.command).to.not.be.empty; }); }); + + /** + * Tests for devServerUrl vs actualDevServerUrl mismatch handling. + * Mirrors the logic in dev.ts (lines 335-342) to enumerate all combinations. + * + * Combinations: + * - explicitUrlProvided: --url flag was passed + * - skipDevServer: --url was reachable, so we never start dev server + * - actualDevServerUrl: URL from DevServerManager 'ready' event (only when we start dev server) + */ + describe('Dev Server URL Mismatch', () => { + /** + * Helper that mirrors the mismatch check logic from dev.ts: + * if (explicitUrlProvided && flags.url && flags.url !== actualDevServerUrl) { this.warn(...) } + */ + function shouldWarnUrlMismatch( + explicitUrlProvided: boolean, + flagsUrl: string | undefined, + actualDevServerUrl: string + ): boolean { + return !!(explicitUrlProvided && flagsUrl && flagsUrl !== actualDevServerUrl); + } + + /** + * Helper that mirrors the final devServerUrl assignment when dev server is started: + * devServerUrl = actualDevServerUrl + */ + function getFinalDevServerUrlWhenStarted(actualDevServerUrl: string): string { + return actualDevServerUrl; + } + + describe('when --url is provided and dev server is started (explicitUrlProvided=true)', () => { + it('should NOT warn when flags.url matches actualDevServerUrl', () => { + const flagsUrl = 'http://localhost:5173'; + const actualDevServerUrl = 'http://localhost:5173'; + + expect(shouldWarnUrlMismatch(true, flagsUrl, actualDevServerUrl)).to.be.false; + expect(getFinalDevServerUrlWhenStarted(actualDevServerUrl)).to.equal(flagsUrl); + }); + + it('should warn when flags.url differs from actualDevServerUrl (different port)', () => { + const flagsUrl = 'http://localhost:3000'; + const actualDevServerUrl = 'http://localhost:5173'; + + expect(shouldWarnUrlMismatch(true, flagsUrl, actualDevServerUrl)).to.be.true; + expect(getFinalDevServerUrlWhenStarted(actualDevServerUrl)).to.equal(actualDevServerUrl); + }); + + it('should warn when flags.url differs from actualDevServerUrl (localhost vs 127.0.0.1)', () => { + const flagsUrl = 'http://localhost:5173'; + const actualDevServerUrl = 'http://127.0.0.1:5173'; + + expect(shouldWarnUrlMismatch(true, flagsUrl, actualDevServerUrl)).to.be.true; + expect(getFinalDevServerUrlWhenStarted(actualDevServerUrl)).to.equal(actualDevServerUrl); + }); + + it('should warn when flags.url differs from actualDevServerUrl (trailing slash)', () => { + const flagsUrl = 'http://localhost:5173'; + const actualDevServerUrl = 'http://localhost:5173/'; + + expect(shouldWarnUrlMismatch(true, flagsUrl, actualDevServerUrl)).to.be.true; + expect(getFinalDevServerUrlWhenStarted(actualDevServerUrl)).to.equal(actualDevServerUrl); + }); + + it('should always use actualDevServerUrl as final devServerUrl when dev server is started', () => { + const actualDevServerUrl = 'http://localhost:8080'; + expect(getFinalDevServerUrlWhenStarted(actualDevServerUrl)).to.equal(actualDevServerUrl); + }); + }); + + describe('when --url is NOT provided (explicitUrlProvided=false)', () => { + it('should NOT warn - no mismatch check when no explicit URL', () => { + const actualDevServerUrl = 'http://localhost:5173'; + + expect(shouldWarnUrlMismatch(false, undefined, actualDevServerUrl)).to.be.false; + expect(getFinalDevServerUrlWhenStarted(actualDevServerUrl)).to.equal(actualDevServerUrl); + }); + + it('should use actualDevServerUrl from dev server (manifest has dev.command)', () => { + const actualDevServerUrl = 'http://localhost:3000'; + expect(getFinalDevServerUrlWhenStarted(actualDevServerUrl)).to.equal(actualDevServerUrl); + }); + }); + + describe('when --url is reachable (skipDevServer=true)', () => { + it('should use flags.url as devServerUrl - no actualDevServerUrl, no mismatch possible', () => { + const flagsUrl = 'http://localhost:5173'; + // When skipDevServer, we never get actualDevServerUrl - devServerUrl stays as flags.url + expect(flagsUrl).to.equal('http://localhost:5173'); + }); + }); + + describe('when manifest has dev.url and no --url (skipDevServer=false, no dev server start)', () => { + it('should use manifest.dev.url as devServerUrl - no actualDevServerUrl', () => { + const manifestUrl = 'http://localhost:5173'; + // When manifest.dev.url && !explicitUrlProvided, we use manifest url, don't start dev server + expect(manifestUrl).to.equal('http://localhost:5173'); + }); + }); + + describe('combination matrix: explicitUrlProvided × match/mismatch', () => { + const combinations: Array<{ + explicitUrlProvided: boolean; + flagsUrl: string | undefined; + actualDevServerUrl: string; + expectMismatch: boolean; + description: string; + }> = [ + { + explicitUrlProvided: true, + flagsUrl: 'http://localhost:5173', + actualDevServerUrl: 'http://localhost:5173', + expectMismatch: false, + description: 'explicit URL matches actual', + }, + { + explicitUrlProvided: true, + flagsUrl: 'http://localhost:3000', + actualDevServerUrl: 'http://localhost:5173', + expectMismatch: true, + description: 'explicit URL different port', + }, + { + explicitUrlProvided: true, + flagsUrl: 'http://127.0.0.1:5173', + actualDevServerUrl: 'http://localhost:5173', + expectMismatch: true, + description: 'explicit URL different host', + }, + { + explicitUrlProvided: true, + flagsUrl: 'https://localhost:5173', + actualDevServerUrl: 'http://localhost:5173', + expectMismatch: true, + description: 'explicit URL different protocol', + }, + { + explicitUrlProvided: false, + flagsUrl: undefined, + actualDevServerUrl: 'http://localhost:5173', + expectMismatch: false, + description: 'no explicit URL - no mismatch check', + }, + ]; + + combinations.forEach(({ explicitUrlProvided, flagsUrl, actualDevServerUrl, expectMismatch, description }) => { + it(`should ${expectMismatch ? 'warn' : 'not warn'} when ${description}`, () => { + expect(shouldWarnUrlMismatch(explicitUrlProvided, flagsUrl, actualDevServerUrl)).to.equal(expectMismatch); + }); + }); + }); + }); }); diff --git a/test/config/ManifestWatcher.test.ts b/test/config/ManifestWatcher.test.ts index 16d35a3..54d72a2 100644 --- a/test/config/ManifestWatcher.test.ts +++ b/test/config/ManifestWatcher.test.ts @@ -142,7 +142,7 @@ describe('ManifestWatcher', () => { }); describe('Partial Manifest Support', () => { - it('should accept manifest with only dev.command (no name, outputDir)', async () => { + it('should accept manifest with only dev.command (no name, label, version, outputDir)', async () => { const partialManifest = { dev: { command: 'npm run dev', @@ -271,13 +271,13 @@ describe('ManifestWatcher', () => { // Wait a bit then modify the file setTimeout(() => { - const updated = { ...validManifest, name: 'updatedApp' }; + const updated = { ...validManifest, version: '2.0.0' }; writeFileSync(testManifestPath, JSON.stringify(updated, null, 2)); }, 200); const event = await changePromise; expect(event.type).to.equal('changed'); - expect(event.manifest?.name).to.equal('updatedApp'); + expect((event.manifest as unknown as Record)?.version).to.equal('2.0.0'); await watcher.stop(); }); @@ -348,22 +348,22 @@ describe('ManifestWatcher', () => { // Make multiple rapid changes setTimeout(() => { - writeFileSync(testManifestPath, JSON.stringify({ ...validManifest, name: 'change1' }, null, 2)); + writeFileSync(testManifestPath, JSON.stringify({ ...validManifest, version: '1.0.1' }, null, 2)); }, 100); setTimeout(() => { - writeFileSync(testManifestPath, JSON.stringify({ ...validManifest, name: 'change2' }, null, 2)); + writeFileSync(testManifestPath, JSON.stringify({ ...validManifest, version: '1.0.2' }, null, 2)); }, 150); setTimeout(() => { - writeFileSync(testManifestPath, JSON.stringify({ ...validManifest, name: 'change3' }, null, 2)); + writeFileSync(testManifestPath, JSON.stringify({ ...validManifest, version: '1.0.3' }, null, 2)); }, 200); // Check that only one change event was emitted after debounce await new Promise((resolve) => setTimeout(resolve, 800)); expect(changeCount).to.equal(1); - expect(watcher.getManifest()?.name).to.equal('change3'); + expect((watcher.getManifest() as unknown as Record)?.version).to.equal('1.0.3'); await watcher.stop(); }); }); @@ -418,12 +418,13 @@ describe('ManifestWatcher', () => { eventEmitted = true; }); - writeFileSync(testManifestPath, JSON.stringify({ ...validManifest, name: 'changedApp' }, null, 2)); + writeFileSync(testManifestPath, JSON.stringify({ ...validManifest, version: '2.0.0' }, null, 2)); await new Promise((resolve) => setTimeout(resolve, 300)); expect(eventEmitted).to.be.false; - expect(watcher.getManifest()?.name).to.equal('testApp'); // Still old value + // Watcher stopped before write, so manifest unchanged (validManifest has no version) + expect(watcher.getManifest()).to.deep.equal(validManifest); await watcher.stop(); }); diff --git a/test/config/webappDiscovery.test.ts b/test/config/webappDiscovery.test.ts index 5b9aac0..4d5fc87 100644 --- a/test/config/webappDiscovery.test.ts +++ b/test/config/webappDiscovery.test.ts @@ -17,27 +17,76 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { expect } from 'chai'; -import { SfError } from '@salesforce/core'; -import { TestContext } from '@salesforce/core/testSetup'; +import { SfError, SfProject } from '@salesforce/core'; import { DEFAULT_DEV_COMMAND, discoverWebapp } from '../../src/config/webappDiscovery.js'; describe('webappDiscovery', () => { - const $$ = new TestContext(); const testDir = join(process.cwd(), '.test-webapp-discovery'); + // Standard SFDX webapplications path + const sfdxWebappsPath = join(testDir, 'force-app', 'main', 'default', 'webapplications'); + + // Store original resolveProjectPath + let originalResolveProjectPath: typeof SfProject.resolveProjectPath; + + /** + * Helper to create a valid webapp directory with required .webapplication-meta.xml + */ + function createWebapp(webappsPath: string, name: string, manifest?: object): string { + const appPath = join(webappsPath, name); + mkdirSync(appPath, { recursive: true }); + // Create required .webapplication-meta.xml file + writeFileSync(join(appPath, `${name}.webapplication-meta.xml`), ''); + if (manifest) { + writeFileSync(join(appPath, 'webapplication.json'), JSON.stringify(manifest)); + } + return appPath; + } + + /** + * Helper to setup SFDX project structure and mock SfProject.resolveProjectPath. + * Creates sfdx-project.json with packageDirectories so getUniquePackageDirectories works. + */ + function setupSfdxProject( + packageDirs: Array<{ path: string; default?: boolean }> = [{ path: 'force-app', default: true }] + ): void { + // Create SFDX project structure + mkdirSync(sfdxWebappsPath, { recursive: true }); + writeFileSync( + join(testDir, 'sfdx-project.json'), + JSON.stringify({ packageDirectories: packageDirs }) + ); + // Mock SfProject.resolveProjectPath to return testDir + SfProject.resolveProjectPath = async () => testDir; + } + + /** + * Helper to mock SfProject.resolveProjectPath to throw (not in SFDX project) + */ + function mockNotInSfdxProject(): void { + SfProject.resolveProjectPath = async () => { + throw new Error('Not in SFDX project'); + }; + } + beforeEach(() => { + // Store original - eslint-disable needed because we're intentionally storing the method for mocking + // eslint-disable-next-line @typescript-eslint/unbound-method + originalResolveProjectPath = SfProject.resolveProjectPath; // Create test directory mkdirSync(testDir, { recursive: true }); }); afterEach(() => { + // Restore original and clear cached project instances + SfProject.resolveProjectPath = originalResolveProjectPath; + SfProject.clearInstances(); // Clean up test directory try { rmSync(testDir, { recursive: true, force: true }); } catch { // Ignore cleanup errors } - $$.restore(); }); describe('DEFAULT_DEV_COMMAND', () => { @@ -47,20 +96,41 @@ describe('webappDiscovery', () => { }); describe('discoverWebapp', () => { - it('should throw error if no webapplications folder found', async () => { + it('should throw error if no webapp found (not in SFDX project)', async () => { + mockNotInSfdxProject(); + try { await discoverWebapp(undefined, testDir); expect.fail('Should have thrown an error'); } catch (error) { expect(error).to.be.instanceOf(SfError); expect((error as SfError).name).to.equal('WebappNotFoundError'); - expect((error as SfError).message).to.include('No webapplications folder found'); + expect((error as SfError).message).to.include('No webapp found'); } }); - it('should throw error if webapplications folder exists but is empty', async () => { - const webappsPath = join(testDir, 'webapplications'); - mkdirSync(webappsPath, { recursive: true }); + it('should throw error if SFDX project has no webapplications folder', async () => { + // Create SFDX project but NOT the webapplications folder + writeFileSync( + join(testDir, 'sfdx-project.json'), + JSON.stringify({ packageDirectories: [{ path: 'force-app', default: true }] }) + ); + SfProject.resolveProjectPath = async () => testDir; + + try { + await discoverWebapp(undefined, testDir); + expect.fail('Should have thrown an error'); + } catch (error) { + expect(error).to.be.instanceOf(SfError); + expect((error as SfError).name).to.equal('WebappNotFoundError'); + expect((error as SfError).message).to.include('No webapplications folder found in the SFDX project'); + } + }); + + it('should throw error if webapplications folder exists but has no valid webapps', async () => { + setupSfdxProject(); + // Create directory without .webapplication-meta.xml + mkdirSync(join(sfdxWebappsPath, 'invalid-app'), { recursive: true }); try { await discoverWebapp(undefined, testDir); @@ -68,15 +138,14 @@ describe('webappDiscovery', () => { } catch (error) { expect(error).to.be.instanceOf(SfError); expect((error as SfError).name).to.equal('WebappNotFoundError'); - expect((error as SfError).message).to.include('Found "webapplications" folder but no webapps inside it'); - expect((error as SfError).message).to.not.include('No webapplications folder found'); + expect((error as SfError).message).to.include('no valid webapps'); } }); it('should find webapp by name when provided', async () => { - const webappsPath = join(testDir, 'webapplications'); - mkdirSync(join(webappsPath, 'app-a'), { recursive: true }); - mkdirSync(join(webappsPath, 'app-b'), { recursive: true }); + setupSfdxProject(); + createWebapp(sfdxWebappsPath, 'app-a'); + createWebapp(sfdxWebappsPath, 'app-b'); const result = await discoverWebapp('app-b', testDir); @@ -86,8 +155,8 @@ describe('webappDiscovery', () => { }); it('should throw error if named webapp not found', async () => { - const webappsPath = join(testDir, 'webapplications'); - mkdirSync(join(webappsPath, 'my-app'), { recursive: true }); + setupSfdxProject(); + createWebapp(sfdxWebappsPath, 'my-app'); try { await discoverWebapp('non-existent', testDir); @@ -102,9 +171,9 @@ describe('webappDiscovery', () => { it('should auto-select webapp when inside its folder', async () => { const webappsPath = join(testDir, 'webapplications'); - const myAppPath = join(webappsPath, 'my-app'); - mkdirSync(myAppPath, { recursive: true }); - mkdirSync(join(webappsPath, 'other-app'), { recursive: true }); + mkdirSync(webappsPath, { recursive: true }); + const myAppPath = createWebapp(webappsPath, 'my-app'); + createWebapp(webappsPath, 'other-app'); const result = await discoverWebapp(undefined, myAppPath); @@ -114,10 +183,11 @@ describe('webappDiscovery', () => { it('should auto-select webapp when inside subfolder', async () => { const webappsPath = join(testDir, 'webapplications'); - const myAppPath = join(webappsPath, 'my-app'); + mkdirSync(webappsPath, { recursive: true }); + const myAppPath = createWebapp(webappsPath, 'my-app'); const srcPath = join(myAppPath, 'src'); mkdirSync(srcPath, { recursive: true }); - mkdirSync(join(webappsPath, 'other-app'), { recursive: true }); + createWebapp(webappsPath, 'other-app'); const result = await discoverWebapp(undefined, srcPath); @@ -125,34 +195,35 @@ describe('webappDiscovery', () => { expect(result.autoSelected).to.be.true; }); - it('should auto-select by folder name when manifest name differs', async () => { + it('should use meta.xml name (manifest.name is not used)', async () => { const webappsPath = join(testDir, 'webapplications'); - const myAppPath = join(webappsPath, 'folder-name'); - mkdirSync(myAppPath, { recursive: true }); - mkdirSync(join(webappsPath, 'other-app'), { recursive: true }); - - writeFileSync(join(myAppPath, 'webapplication.json'), JSON.stringify({ name: 'ManifestName' })); + mkdirSync(webappsPath, { recursive: true }); + const myAppPath = createWebapp(webappsPath, 'folder-name', { name: 'ManifestName' }); + createWebapp(webappsPath, 'other-app'); const result = await discoverWebapp(undefined, myAppPath); - expect(result.webapp?.name).to.equal('ManifestName'); + // Name comes from .webapplication-meta.xml (folder-name), not manifest.name + expect(result.webapp?.name).to.equal('folder-name'); expect(result.autoSelected).to.be.true; }); - it('should auto-select single webapp', async () => { - const webappsPath = join(testDir, 'webapplications'); - mkdirSync(join(webappsPath, 'only-app'), { recursive: true }); + it('should return null webapp for single webapp at project root (always prompt)', async () => { + setupSfdxProject(); + createWebapp(sfdxWebappsPath, 'only-app'); const result = await discoverWebapp(undefined, testDir); - expect(result.webapp?.name).to.equal('only-app'); + // Now returns null to prompt even for single webapp (reviewer feedback) + expect(result.webapp).to.be.null; expect(result.autoSelected).to.be.false; + expect(result.allWebapps).to.have.length(1); }); it('should return null webapp for multiple webapps (selection needed)', async () => { - const webappsPath = join(testDir, 'webapplications'); - mkdirSync(join(webappsPath, 'app-a'), { recursive: true }); - mkdirSync(join(webappsPath, 'app-b'), { recursive: true }); + setupSfdxProject(); + createWebapp(sfdxWebappsPath, 'app-a'); + createWebapp(sfdxWebappsPath, 'app-b'); const result = await discoverWebapp(undefined, testDir); @@ -161,16 +232,103 @@ describe('webappDiscovery', () => { expect(result.allWebapps).to.have.length(2); }); - it('should prioritize --name flag over auto-selection', async () => { + it('should throw error when --name conflicts with current webapp directory', async () => { const webappsPath = join(testDir, 'webapplications'); - const currentApp = join(webappsPath, 'current-app'); - mkdirSync(currentApp, { recursive: true }); - mkdirSync(join(webappsPath, 'other-app'), { recursive: true }); + mkdirSync(webappsPath, { recursive: true }); + const currentAppPath = createWebapp(webappsPath, 'current-app'); + createWebapp(webappsPath, 'other-app'); - const result = await discoverWebapp('other-app', currentApp); + try { + // Inside current-app but specifying --name other-app + await discoverWebapp('other-app', currentAppPath); + expect.fail('Should have thrown an error'); + } catch (error) { + expect(error).to.be.instanceOf(SfError); + expect((error as SfError).name).to.equal('WebappNameConflictError'); + expect((error as SfError).message).to.include('current-app'); + expect((error as SfError).message).to.include('other-app'); + } + }); + + it('should allow --name matching current webapp directory', async () => { + const webappsPath = join(testDir, 'webapplications'); + mkdirSync(webappsPath, { recursive: true }); + const currentAppPath = createWebapp(webappsPath, 'current-app'); + createWebapp(webappsPath, 'other-app'); - expect(result.webapp?.name).to.equal('other-app'); + // Inside current-app and specifying --name current-app (should work) + const result = await discoverWebapp('current-app', currentAppPath); + + expect(result.webapp?.name).to.equal('current-app'); expect(result.autoSelected).to.be.false; }); + + it('should recognize webapp by .webapplication-meta.xml file', async () => { + setupSfdxProject(); + + // Create directory with .webapplication-meta.xml + const validAppPath = join(sfdxWebappsPath, 'valid-app'); + mkdirSync(validAppPath, { recursive: true }); + writeFileSync(join(validAppPath, 'valid-app.webapplication-meta.xml'), ''); + + // Create directory without .webapplication-meta.xml (should be ignored) + const invalidAppPath = join(sfdxWebappsPath, 'invalid-app'); + mkdirSync(invalidAppPath, { recursive: true }); + + const result = await discoverWebapp(undefined, testDir); + + // Only valid-app should be discovered + expect(result.allWebapps).to.have.length(1); + expect(result.allWebapps[0].name).to.equal('valid-app'); + expect(result.allWebapps[0].hasMetaXml).to.be.true; + }); + + it('should use standalone webapp when current dir has .webapplication-meta.xml', async () => { + mockNotInSfdxProject(); + + // Create a standalone webapp directory (not in webapplications folder) + const standaloneDir = join(testDir, 'standalone-app'); + mkdirSync(standaloneDir, { recursive: true }); + writeFileSync(join(standaloneDir, 'standalone-app.webapplication-meta.xml'), ''); + + const result = await discoverWebapp(undefined, standaloneDir); + + expect(result.webapp?.name).to.equal('standalone-app'); + expect(result.allWebapps).to.have.length(1); + }); + + it('should discover webapps from multiple package directories', async () => { + // Create project with two packages: force-app and packages/einstein + const einsteinWebappsPath = join(testDir, 'packages', 'einstein', 'main', 'default', 'webapplications'); + mkdirSync(einsteinWebappsPath, { recursive: true }); + setupSfdxProject([ + { path: 'force-app', default: true }, + { path: 'packages/einstein', default: false }, + ]); + createWebapp(sfdxWebappsPath, 'force-app-webapp'); + createWebapp(einsteinWebappsPath, 'einstein-webapp'); + + const result = await discoverWebapp(undefined, testDir); + + expect(result.allWebapps).to.have.length(2); + const names = result.allWebapps.map((w) => w.name).sort(); + expect(names).to.deep.equal(['einstein-webapp', 'force-app-webapp']); + }); + + it('should warn and use first match when directory has multiple .webapplication-meta.xml files', async () => { + setupSfdxProject(); + + // Create webapp directory with multiple metadata files (misconfiguration) + const multiMetaPath = join(sfdxWebappsPath, 'multi-meta-app'); + mkdirSync(multiMetaPath, { recursive: true }); + writeFileSync(join(multiMetaPath, 'alpha.webapplication-meta.xml'), ''); + writeFileSync(join(multiMetaPath, 'beta.webapplication-meta.xml'), ''); + + const result = await discoverWebapp(undefined, testDir); + + // Discovery should succeed - uses first match (order depends on readdir) + expect(result.allWebapps).to.have.length(1); + expect(['alpha', 'beta']).to.include(result.allWebapps[0].name); + }); }); }); diff --git a/yarn.lock b/yarn.lock index dc543d4..8a816ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1739,10 +1739,10 @@ dependencies: "@salesforce/ts-types" "^2.0.12" -"@salesforce/plugin-command-reference@^3.1.79": - version "3.1.79" - resolved "https://registry.yarnpkg.com/@salesforce/plugin-command-reference/-/plugin-command-reference-3.1.79.tgz#36f1ea069d134ee45489882e28cc9a9bf6db6709" - integrity sha512-t3DH+Ez2ESrY8M8zO6yEodsFq7IYKBseFFRwqFxTf0bIVZcWh4i7UO8oUFQyT9Px+Q3TcnSXw4X9njopxTc2lQ== +"@salesforce/plugin-command-reference@^3.1.77": + version "3.1.78" + resolved "https://registry.npmjs.org/@salesforce/plugin-command-reference/-/plugin-command-reference-3.1.78.tgz" + integrity sha512-8GhAUhYTDD51pAFN6h/wrhD46ZJEs53EMDa//jNIe06nFxs/A2iheJSaVNyf9p+ft1KGTvhg+5WXQBFW5daFsA== dependencies: "@oclif/core" "^4" "@salesforce/core" "^8.23.3" @@ -1758,18 +1758,18 @@ resolved "https://registry.npmjs.org/@salesforce/prettier-config/-/prettier-config-0.0.3.tgz" integrity sha512-hYOhoPTCSYMDYn+U1rlEk16PoBeAJPkrdg4/UtAzupM1mRRJOwEPMG1d7U8DxJFKuXW3DMEYWr2MwAIBDaHmFg== -"@salesforce/sdk-core@^1.22.0": - version "1.22.0" - resolved "https://registry.yarnpkg.com/@salesforce/sdk-core/-/sdk-core-1.22.0.tgz#6488c2a64954ef554253f7d6293239d3e3ba9e61" - integrity sha512-L3GT267pg8iRJFXLUg+DVjn76UgJSwexXhWsAV5WDiLEkXlEwKdGFmpmKYbDx9M9sUN3NckiYw+trWGRjUEHNw== +"@salesforce/sdk-core@^1.29.1": + version "1.29.1" + resolved "https://registry.yarnpkg.com/@salesforce/sdk-core/-/sdk-core-1.29.1.tgz#4679e9e2cc8c34fafb302610312f1f8eb249349e" + integrity sha512-Xc2Hh1yzV+vMj8+Ot4SjBIJgqU8OZJ51H3Hg6clKlzlzcuBzSTprHB4Cw252NWOGJ7UtG0rLGG8Q6F3qEg2SMQ== -"@salesforce/sdk-data@^1.22.0": - version "1.22.0" - resolved "https://registry.yarnpkg.com/@salesforce/sdk-data/-/sdk-data-1.22.0.tgz#2dbf26f8b29f4bcc56aaf070baa74dbe64d02cd6" - integrity sha512-KH5RcQfyXj0jjvpI7gv54+e7qhiOBZ+XjuBA6UsOuk4bRvRfvqtwxCl1qSTLTU6iEoburudq6ixu1n7A6MOG+g== +"@salesforce/sdk-data@^1.29.1": + version "1.29.1" + resolved "https://registry.yarnpkg.com/@salesforce/sdk-data/-/sdk-data-1.29.1.tgz#4a1ad906d597d9e0ffca4e8c2991e2e54b90db7d" + integrity sha512-8TjrB8GiEgMEnVveQahYFd+8ak5Dr5xJgj0DoIDgIkToc1t6UzPVZAP4D/XO+aydPnzYO1ZXSPbemYOa3ar+uA== dependencies: "@conduit-client/salesforce-lightning-service-worker" "^3.7.0" - "@salesforce/sdk-core" "^1.22.0" + "@salesforce/sdk-core" "^1.29.1" "@salesforce/sf-plugins-core@^11.3.12": version "11.3.12" @@ -1811,12 +1811,12 @@ integrity sha512-BIJyduJC18Kc8z+arUm5AZ9VkPRyw1KKAm+Tk+9LT99eOzhNilyfKzhZ4t+tG2lIGgnJpmytZfVDZ0e2kFul8g== "@salesforce/webapp-experimental@^1.23.0": - version "1.23.0" - resolved "https://registry.yarnpkg.com/@salesforce/webapp-experimental/-/webapp-experimental-1.23.0.tgz#b95ebfebd3254361732e8edcfbdc56a8b819a948" - integrity sha512-5EKzZ6MFnCzmKdHSSt+28riAIgFQ+5PfPRQg3Gl0mnUA3GzN9XwzEq8hI/r52sDlKPvtb2x+MKAxyYs/182OEg== + version "1.29.1" + resolved "https://registry.yarnpkg.com/@salesforce/webapp-experimental/-/webapp-experimental-1.29.1.tgz#98648cc166b34c1b479f9545e87d10b61dd76adc" + integrity sha512-i5ZzGs7hrjv3Fbrvmm7v8OTVoJRewWvqzXIB5JRW5l2mcz1HxK2DXriBbLHHOmYRcVSrNdN/VS8PW7YwcTuHZw== dependencies: "@salesforce/core" "^8.23.4" - "@salesforce/sdk-data" "^1.22.0" + "@salesforce/sdk-data" "^1.29.1" axios "^1.7.7" micromatch "^4.0.8" path-to-regexp "^8.3.0"