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"