diff --git a/SF_WEBAPP_DEV_GUIDE.md b/SF_WEBAPP_DEV_GUIDE.md index 65f5c85..98268ff 100644 --- a/SF_WEBAPP_DEV_GUIDE.md +++ b/SF_WEBAPP_DEV_GUIDE.md @@ -10,7 +10,9 @@ The `sf webapp dev` command enables local development of modern web applications ### Key Features -- **Auto-Discovery**: Automatically finds `webapplication.json` files in your project +- **Auto-Discovery**: Automatically finds webapps in `webapplications/` folder +- **Optional Manifest**: `webapplication.json` is optional - uses sensible defaults +- **Auto-Selection**: Automatically selects webapp when running from inside its folder - **Interactive Selection**: Prompts with arrow-key navigation when multiple webapps exist - **Authentication Injection**: Automatically adds Salesforce auth headers to API calls - **Intelligent Routing**: Routes requests to dev server or Salesforce based on URL patterns @@ -22,18 +24,15 @@ The `sf webapp dev` command enables local development of modern web applications ## Quick Start -### 1. Create `webapplication.json` in your project +### 1. Create your webapp in the `webapplications/` folder -```json -{ - "name": "myApp", - "label": "My Application", - "version": "1.0.0", - "outputDir": "dist", - "dev": { - "command": "npm run dev" - } -} +``` +my-project/ +└── webapplications/ + └── my-app/ # Your webapp folder + ├── package.json + ├── src/ + └── webapplication.json # Optional! ``` ### 2. Run the command @@ -46,6 +45,12 @@ 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: +> +> - **Name**: Folder name (e.g., "my-app") +> - **Dev command**: `npm run dev` +> - **Manifest watching**: Disabled + --- ## Command Syntax @@ -90,62 +95,96 @@ SF_LOG_LEVEL=debug sf webapp dev --target-org myOrg ## Webapp Discovery -The command automatically discovers `webapplication.json` files in your project, making the `--name` flag optional in most cases. +The command automatically discovers webapps in the `webapplications/` folder. Each subfolder is treated as a webapp, with `webapplication.json` being optional. ### How Discovery Works ```mermaid flowchart TD - Start["sf webapp dev"] --> HasName{"--name provided?"} - HasName -->|Yes| SearchByName["Search for webapplication.json with matching name field"] - HasName -->|No| SearchAll["Search all webapplication.json files in project"] + Start["sf webapp dev"] --> FindFolder["Find webapplications/ folder"] + FindFolder --> Found{"Found?"} + Found -->|No| ErrorNone["Error: No webapplications folder found"] + Found -->|Yes| HasName{"--name provided?"} - SearchByName --> FoundMatch{"Found?"} - FoundMatch -->|Yes| UseManifest["Use matched webapplication.json"] - FoundMatch -->|No| ErrorNotFound["Error: No webapp found with name X"] + HasName -->|Yes| SearchByName["Find webapp by name"] + HasName -->|No| InsideWebapp{"Running from inside a webapp?"} - SearchAll --> Count{"How many found?"} - Count -->|0| ErrorNone["Error: No webapplication.json found"] - Count -->|1| UseManifest + InsideWebapp -->|Yes| AutoSelect["Auto-select current webapp"] + InsideWebapp -->|No| Count{"How many webapps?"} + + Count -->|1| AutoSelectSingle["Auto-select single webapp"] Count -->|Multiple| Prompt["Interactive selection prompt"] - Prompt --> UseManifest + + SearchByName --> UseWebapp["Use webapp"] + AutoSelect --> UseWebapp + AutoSelectSingle --> 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 ``` ### Discovery Behavior -| Scenario | Behavior | -| -------------------------------- | ----------------------------------------------------------- | -| `--name myApp` provided | Finds webapplication.json where `name` field equals "myApp" | -| No `--name`, single webapp found | Auto-selects the webapp | -| No `--name`, multiple found | Shows interactive selection with arrow keys | -| No `--name`, none found | Shows error with helpful message | +| 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 | + +### Folder Structure + +``` +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/ +``` ### Search Scope -The command searches the current directory and all subdirectories, excluding: +The command searches for the `webapplications/` folder: + +1. **Upward**: First checks if you're inside a webapplications folder +2. **Downward**: Then searches child directories recursively -- `node_modules` -- `.git` -- `dist`, `build`, `out` -- `coverage` +Excluded directories: + +- `node_modules`, `.git`, `dist`, `build`, `out`, `coverage` - `.next`, `.nuxt`, `.output` - Hidden directories (starting with `.`) ### Interactive Selection -When multiple `webapplication.json` files are found, you'll see an interactive prompt: +When multiple webapps are found, you'll see an interactive prompt: ``` -Found 3 webapplication.json files in project +Found 3 webapps in project ? Select the webapp to run: (Use arrow keys) -❯ myApp - My Application (webapplication.json) - adminPortal - Admin Portal (apps/admin/webapplication.json) - dashboard - Dashboard App (packages/dashboard/webapplication.json) +❯ MyApp - My Application (webapplications/app-one) + app-two (webapplications/app-two) [no manifest] + CustomName (webapplications/app-three) ``` -Use arrow keys to navigate and Enter to select. +Format: + +- **With manifest + label**: `Name - Label (path)` +- **With manifest, no label**: `Name (path)` +- **No manifest**: `name (path) [no manifest]` --- @@ -196,39 +235,52 @@ Browser → Proxy → [Auth Headers Injected] → Salesforce → Response ### webapplication.json Schema -#### Required Fields +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" + "outputDir": "dist", + "dev": { + "command": "npm run dev" + } } ``` -| Field | Type | Description | -| ----------- | ------ | ----------------------------------------- | -| `name` | string | Unique identifier (used with --name flag) | -| `label` | string | Human-readable display name | -| `version` | string | Semantic version (e.g., "1.0.0") | -| `outputDir` | string | Build output directory | +| 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) | #### Dev Configuration -**Option A: Auto-spawn dev server** +**Option A: No manifest (uses defaults)** + +If no `webapplication.json` exists: + +- Dev command: `npm run dev` +- Name: folder name +- Manifest watching: disabled + +**Option B: Minimal manifest** ```json { "dev": { - "command": "npm run dev" + "command": "npm start" } } ``` -The command will spawn your dev server and automatically detect its URL. +Only specify what you need to override. -**Option B: Explicit URL (dev server already running)** +**Option C: Explicit URL (dev server already running)** ```json { @@ -253,7 +305,37 @@ Use this when you want to start the dev server yourself. } ``` -### Complete Example +### Example: Minimal (No Manifest) + +``` +webapplications/ +└── my-dashboard/ + ├── package.json # Has "scripts": { "dev": "vite" } + └── src/ +``` + +Run: `sf webapp dev --target-org myOrg` + +Console output: + +``` +Warning: No webapplication.json found for webapp "my-dashboard" + Location: my-dashboard + Using defaults: + → Name: "my-dashboard" (derived from folder) + → Command: "npm run dev" + → Manifest watching: disabled + 💡 To customize, create a webapplication.json file in your webapp directory. + +✅ Using webapp: my-dashboard (webapplications/my-dashboard) + +✅ Ready for development! + → Proxy: http://localhost:4545 (open this in your browser) + → Dev server: http://localhost:5173 +Press Ctrl+C to stop +``` + +### Example: Full Configuration ```json { @@ -287,6 +369,8 @@ Manifest changed detected Dev server URL updated to: http://localhost:5174 ``` +> **Note**: Manifest watching is only enabled when `webapplication.json` exists. Webapps without manifests don't have this feature. + ### Health Monitoring The proxy continuously monitors dev server availability: @@ -311,29 +395,39 @@ Automatically detects Salesforce Code Builder environment and binds to `0.0.0.0` ## Troubleshooting -### "No webapplication.json found" +### "No webapplications folder found" -Ensure you have a `webapplication.json` file with required fields: +Create a `webapplications/` folder with at least one webapp subfolder: -```json -{ - "name": "myApp", - "label": "My Application", - "version": "1.0.0", - "outputDir": "dist" -} +``` +my-project/ +└── webapplications/ + └── my-app/ + └── package.json ``` +Note: `webapplication.json` is optional! + ### "No webapp found with name X" -The `--name` flag matches the `name` field inside `webapplication.json`, not the file path: +The `--name` flag matches either: + +1. The `name` field in `webapplication.json` +2. The folder name (if no manifest or no name in manifest) ```bash -# This looks for webapplication.json where name="myApp" +# This looks for webapp named "myApp" sf webapp dev --name myApp --target-org myOrg ``` -Check your `webapplication.json` content to verify the name. +### "Dependencies Not Installed" / "command not found" + +Install dependencies in your webapp folder: + +```bash +cd webapplications/my-app +npm install +``` ### "No Dev Server Detected" @@ -362,12 +456,37 @@ sf org login web --alias myOrg ### Debug Mode -Enable detailed logging: +Enable detailed logging by setting `SF_LOG_LEVEL=debug`. Debug logs are written to the SF CLI log file (not stdout). + +**Step 1: Start log tail in Terminal 1** + +```bash +# Tail today's log file, filtering for webapp messages +tail -f ~/.sf/sf-$(date +%Y-%m-%d).log | grep --line-buffered WebappDev + +# Or for cleaner output (requires jq): +tail -f ~/.sf/sf-$(date +%Y-%m-%d).log | grep --line-buffered WebappDev | jq -r '.msg' +``` + +**Step 2: Run command in Terminal 2** ```bash SF_LOG_LEVEL=debug sf webapp dev --target-org myOrg ``` +**Example debug output:** + +``` +Discovering webapplication.json manifest(s)... +Using webapp: myApp at webapplications/my-app +Manifest loaded: myApp +Starting dev server with command: npm run dev +Dev server ready at: http://localhost:5173/ +Using authentication for org: user@example.com +Starting proxy server on port 4545... +Proxy server running on http://localhost:4545 +``` + --- ## VSCode Integration diff --git a/messages/webapp.dev.md b/messages/webapp.dev.md index 283a00b..b84fb8b 100644 --- a/messages/webapp.dev.md +++ b/messages/webapp.dev.md @@ -84,7 +84,9 @@ Proxy URL: %s (open this in your browser) # info.ready-for-development -✓ Ready for development! +✅ Ready for development! +→ Proxy: %s (open this in your browser) +→ Dev server: %s # info.press-ctrl-c @@ -129,16 +131,54 @@ Failed to watch manifest: %s # error.dev-server-failed -Dev server failed to start: %s +%s # info.multiple-webapps-found -Found %s webapplication.json files in project +Found %s webapps in project + +# info.webapp-auto-selected + +Auto-selected webapp "%s" (running from inside its folder) # info.using-webapp -Using webapp: %s (%s) +✅ Using webapp: %s (%s) # prompt.select-webapp Select the webapp to run: + +# warning.no-manifest + +No webapplication.json found for webapp "%s" +Location: %s + +Using defaults: +→ Name: "%s" (derived from folder) +→ Command: "%s" +→ Manifest watching: disabled + +💡 To customize, create a webapplication.json file in your webapp directory. + +# 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 + +💡 To customize, add dev configuration to your webapplication.json file. +Example: +{ +"dev": { +"command": "npm run dev" +} +} + +# info.using-defaults + +Using default dev command: %s diff --git a/src/commands/webapp/dev.ts b/src/commands/webapp/dev.ts index d5e84a1..7514066 100644 --- a/src/commands/webapp/dev.ts +++ b/src/commands/webapp/dev.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { dirname } from 'node:path'; import open from 'open'; import select from '@inquirer/select'; import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; @@ -24,8 +23,7 @@ import type { WebAppManifest } from '../../config/manifest.js'; import { ManifestWatcher } from '../../config/ManifestWatcher.js'; import { DevServerManager } from '../../server/DevServerManager.js'; import { ProxyServer } from '../../proxy/ProxyServer.js'; -import { ErrorHandler } from '../../error/ErrorHandler.js'; -import { discoverWebapp, type DiscoveredWebapp } from '../../config/webappDiscovery.js'; +import { discoverWebapp, DEFAULT_DEV_COMMAND, type DiscoveredWebapp } from '../../config/webappDiscovery.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-webapp', 'webapp.dev'); @@ -80,10 +78,23 @@ export default class WebappDev extends SfCommand { * Uses interactive arrow-key selection (standard SF CLI pattern) */ private static async promptWebappSelection(webapps: DiscoveredWebapp[]): Promise { - const choices = webapps.map((webapp) => ({ - name: `${webapp.manifest.name} - ${webapp.manifest.label} (${webapp.relativePath})`, - value: webapp, - })); + const WARNING = '\u26A0\uFE0F'; // ⚠️ + + const choices = webapps.map((webapp) => { + if (webapp.hasManifest) { + // Has manifest - show name only + return { + name: webapp.name, + value: webapp, + }; + } else { + // No manifest - show warning symbol + return { + name: `${webapp.name} - ${WARNING} No Manifest`, + value: webapp, + }; + } + }); return select({ message: messages.getMessage('prompt.select-webapp'), @@ -108,7 +119,7 @@ export default class WebappDev extends SfCommand { // Step 1: Discover and select webapp this.logger.debug('Discovering webapplication.json manifest(s)...'); - const { webapp: discoveredWebapp, allWebapps } = await discoverWebapp(flags.name); + const { webapp: discoveredWebapp, allWebapps, autoSelected } = await discoverWebapp(flags.name); // Handle multiple webapps case - prompt user to select let selectedWebapp: DiscoveredWebapp; @@ -118,73 +129,114 @@ export default class WebappDev extends SfCommand { selectedWebapp = await WebappDev.promptWebappSelection(allWebapps); } else { selectedWebapp = discoveredWebapp; - } - const manifestPath = selectedWebapp.path; - const manifestDir = dirname(manifestPath); + // Show info message if webapp was auto-selected because user is inside its folder + if (autoSelected) { + this.log(messages.getMessage('info.webapp-auto-selected', [selectedWebapp.name])); + } + } - this.logger.debug(`Using webapp: ${selectedWebapp.manifest.name} at ${selectedWebapp.relativePath}`); + // The webapp directory path (where the webapp lives) + const webappDir = selectedWebapp.path; - // Step 2: Load and watch manifest - this.manifestWatcher = new ManifestWatcher({ manifestPath, watch: true }); + this.logger.debug(`Using webapp: ${selectedWebapp.name} at ${selectedWebapp.relativePath}`); - this.manifestWatcher.initialize(); - manifest = this.manifestWatcher.getManifest(); + // Step 2: Handle manifest-based vs no-manifest webapps + if (selectedWebapp.hasManifest && selectedWebapp.manifestPath) { + // Webapp has manifest - load and watch it + this.manifestWatcher = new ManifestWatcher({ + manifestPath: selectedWebapp.manifestPath, + watch: true, + }); - if (!manifest) { - throw ErrorHandler.createManifestNotFoundError(); - } + this.manifestWatcher.initialize(); + manifest = this.manifestWatcher.getManifest(); + + // Check if manifest is effectively empty (no dev configuration) + // Note: manifest is guaranteed non-null here since initialize() throws on failure + 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.log(messages.getMessage('info.using-webapp', [manifest.name, selectedWebapp.relativePath])); - this.logger.debug(`Manifest loaded: ${manifest.name}`); + // Use selectedWebapp.name (already calculated with folder name fallback during discovery) + this.log(messages.getMessage('info.using-webapp', [selectedWebapp.name, selectedWebapp.relativePath])); + this.logger.debug(`Manifest loaded: ${selectedWebapp.name}`); - // Setup manifest change handler - this.manifestWatcher.on('change', (event) => { - this.log(messages.getMessage('info.manifest-changed', [event.type])); - if (event.type === 'changed' && event.manifest) { - this.log(messages.getMessage('info.manifest-reloaded')); + // Setup manifest change handler + this.manifestWatcher.on('change', (event) => { + this.log(messages.getMessage('info.manifest-changed', [event.type])); + if (event.type === 'changed' && event.manifest) { + this.log(messages.getMessage('info.manifest-reloaded')); - // Check for dev.url changes (can be updated dynamically) - const oldDevUrl = manifest?.dev?.url; - const newDevUrl = event.manifest.dev?.url; + // Check for dev.url changes (can be updated dynamically) + const oldDevUrl = manifest?.dev?.url; + const newDevUrl = event.manifest.dev?.url; - if (newDevUrl && oldDevUrl !== newDevUrl) { - this.log(messages.getMessage('info.dev-url-changed', [newDevUrl])); - this.proxyServer?.updateDevServerUrl(newDevUrl); - } + if (newDevUrl && oldDevUrl !== newDevUrl) { + this.log(messages.getMessage('info.dev-url-changed', [newDevUrl])); + this.proxyServer?.updateDevServerUrl(newDevUrl); + } - // Check for dev.command changes (cannot be changed while running) - if (event.manifest.dev?.command && event.manifest.dev.command !== manifest?.dev?.command) { - this.warn(messages.getMessage('warning.dev-command-changed', [event.manifest.dev.command])); - } + // Check for dev.command changes (cannot be changed while running) + if (event.manifest.dev?.command && event.manifest.dev.command !== manifest?.dev?.command) { + this.warn(messages.getMessage('warning.dev-command-changed', [event.manifest.dev.command])); + } - // Update proxy server with new manifest (for routing changes) - this.proxyServer?.updateManifest(event.manifest); + // Update proxy server with new manifest (for routing changes) + this.proxyServer?.updateManifest(event.manifest); - // Update manifest reference to reflect all changes - manifest = event.manifest; - } - }); + // Update manifest reference to reflect all changes + manifest = event.manifest; + } + }); - this.manifestWatcher.on('error', (error: SfError) => { - this.warn(messages.getMessage('error.manifest-watch-failed', [error.message])); - }); + this.manifestWatcher.on('error', (error: SfError) => { + 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])); + } - // Step 2: Determine dev server URL + // Step 3: Determine dev server URL - // Priority: --url flag > manifest dev.url > spawn dev.command + // Priority: --url flag > manifest dev.url > manifest dev.command > default command (for no-manifest) if (flags.url) { devServerUrl = flags.url; this.logger.debug(`Using explicit dev server URL: ${devServerUrl}`); - } else if (manifest.dev?.url) { + } else if (manifest?.dev?.url) { devServerUrl = manifest.dev.url; this.logger.debug(`Using dev server URL from manifest: ${devServerUrl}`); - } else if (manifest.dev?.command) { - // Start dev server from the directory containing webapplication.json - this.logger.debug(`Starting dev server with command: ${manifest.dev.command}`); + } 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])); + } + + // Start dev server from the webapp directory + this.logger.debug(`Starting dev server with command: ${devCommand}`); this.devServerManager = new DevServerManager({ - command: manifest.dev.command, - cwd: manifestDir, + command: devCommand, + cwd: webappDir, }); // Setup dev server event handlers @@ -195,15 +247,12 @@ export default class WebappDev extends SfCommand { }); this.devServerManager.on('error', (error: SfError | DevServerError) => { - // Check if this is a parsed dev server error (has DevServerError-specific fields) + // 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 is a DevServerError with parsed stderr - this.warn(messages.getMessage('error.dev-server-failed', [error.title])); this.proxyServer?.setActiveDevServerError(error); - } else { - // Generic SfError - this.warn(messages.getMessage('error.dev-server-failed', [error.message])); } + this.logger?.debug(`Dev server error: ${error.message}`); }); this.devServerManager.on('exit', () => { @@ -215,7 +264,13 @@ export default class WebappDev extends SfCommand { // Wait for dev server to be ready devServerUrl = await new Promise((resolve, reject) => { const timeout = setTimeout(() => { - reject(ErrorHandler.createDevServerTimeoutError(30)); + 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) => { @@ -228,8 +283,6 @@ export default class WebappDev extends SfCommand { reject(error); }); }); - } else { - throw ErrorHandler.createDevServerCommandRequiredError(); } // Step 3: Get org info for authentication @@ -244,7 +297,7 @@ export default class WebappDev extends SfCommand { devServerUrl, salesforceInstanceUrl, port: flags.port, - manifest, + manifest: manifest ?? undefined, orgAlias: orgUsername, }); @@ -254,7 +307,7 @@ export default class WebappDev extends SfCommand { // Listen for dev server status changes (minimal output) this.proxyServer.on('dev-server-up', (url: string) => { - this.log(messages.getMessage('info.dev-server-detected', [url])); + this.logger?.debug(messages.getMessage('info.dev-server-detected', [url])); }); this.proxyServer.on('dev-server-down', (url: string) => { @@ -275,9 +328,7 @@ export default class WebappDev extends SfCommand { // Display usage instructions this.log(''); - this.log(messages.getMessage('info.ready-for-development')); - this.log(messages.getMessage('info.proxy-url', [proxyUrl])); - this.log(messages.getMessage('info.dev-server-url', [devServerUrl ?? 'N/A'])); + this.log(messages.getMessage('info.ready-for-development', [proxyUrl, devServerUrl ?? 'N/A'])); this.log(messages.getMessage('info.press-ctrl-c')); this.log(''); @@ -322,7 +373,13 @@ export default class WebappDev extends SfCommand { throw error; } - throw ErrorHandler.wrapError(error, 'Failed to start webapp dev command'); + // Wrap unknown errors + const errorMessage = error instanceof Error ? error.message : String(error); + 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', + ]); } } @@ -347,7 +404,7 @@ export default class WebappDev extends SfCommand { }); if (response.ok) { - this.log(messages.getMessage('info.dev-server-healthy', [devServerUrl])); + this.logger?.debug(messages.getMessage('info.dev-server-healthy', [devServerUrl])); } else { this.warn(messages.getMessage('warning.dev-server-not-responding', [devServerUrl, String(response.status)])); } diff --git a/src/config/ManifestWatcher.ts b/src/config/ManifestWatcher.ts index 8e385a9..9612527 100644 --- a/src/config/ManifestWatcher.ts +++ b/src/config/ManifestWatcher.ts @@ -36,7 +36,7 @@ export type ManifestChangeEvent = { /** * Configuration options for ManifestWatcher */ -export type ManifestWatcherOptions = { +type ManifestWatcherOptions = { /** * Path to the webapplication.json manifest file * Defaults to webapplication.json in the current working directory @@ -59,7 +59,7 @@ export type ManifestWatcherOptions = { /** * Events emitted by ManifestWatcher */ -export type ManifestWatcherEvents = { +type ManifestWatcherEvents = { change: (event: ManifestChangeEvent) => void; error: (error: SfError) => void; ready: (manifest: WebAppManifest) => void; @@ -70,10 +70,10 @@ export type ManifestWatcherEvents = { * * Features: * - Loads webapplication.json from project root - * - Basic validation of required fields * - Watches for file changes and emits events * - Provides helpful error messages * - Supports hot-reload without restarting the proxy + * - No strict validation - all fields are optional for dev mode */ export class ManifestWatcher extends EventEmitter { // 1. Instance fields @@ -93,49 +93,7 @@ export class ManifestWatcher extends EventEmitter { }; } - // 3. Static methods (must come before instance methods) - /** - * Validates required fields in the manifest - * Basic validation - detailed schema validation may be added later - * - * @param manifest - The parsed manifest data - * @throws SfError if required fields are missing - */ - private static validateManifest(manifest: WebAppManifest): void { - const errors: string[] = []; - - if (!manifest.name) { - errors.push('name'); - } - - if (!manifest.label) { - errors.push('label'); - } - - if (!manifest.version) { - errors.push('version'); - } - - if (!manifest.outputDir) { - errors.push('outputDir'); - } - - if (errors.length > 0) { - throw new SfError( - `webapplication.json missing required field${errors.length > 1 ? 's' : ''}: ${errors.join(', ')}`, - 'ManifestValidationError', - [ - 'Ensure all required fields are present in webapplication.json:', - ' - name: Unique identifier (e.g., "customerPortal")', - ' - label: Display name (e.g., "Customer Portal")', - ' - version: Semantic version (e.g., "1.0.0")', - ' - outputDir: Build output directory (e.g., "dist")', - ] - ); - } - } - - // 4. Public instance methods + // 3. Public instance methods /** * Initializes the ManifestWatcher * Loads the manifest file and optionally starts watching for changes @@ -281,10 +239,7 @@ export class ManifestWatcher extends EventEmitter { ]); } - // Basic validation of required fields - ManifestWatcher.validateManifest(parsed); - - // Store the validated manifest + // Store the manifest (no strict validation - fields are optional for dev mode) this.manifest = parsed; } diff --git a/src/config/manifest.ts b/src/config/manifest.ts index c3de9f7..0e0f06e 100644 --- a/src/config/manifest.ts +++ b/src/config/manifest.ts @@ -14,9 +14,6 @@ * limitations under the License. */ -import { readFile } from 'node:fs/promises'; -import { SfError } from '@salesforce/core'; - // Re-export base types from @salesforce/webapp-experimental package export type { WebAppManifest as BaseWebAppManifest, @@ -47,72 +44,3 @@ export type WebAppManifest = BaseWebAppManifest & { /** Development configuration (plugin-specific) */ dev?: DevConfig; }; - -/** - * Validate required fields in webapp manifest - * Basic validation - schema validation may be added later - * - * @param manifest - The manifest object to validate - * @throws SfError if any required field is missing - */ -function validateManifest(manifest: WebAppManifest): void { - const errors: string[] = []; - - if (!manifest.name) { - errors.push('name'); - } - - if (!manifest.label) { - errors.push('label'); - } - - if (!manifest.version) { - errors.push('version'); - } - - if (!manifest.outputDir) { - errors.push('outputDir'); - } - - if (errors.length > 0) { - throw new SfError( - `webapplication.json missing required field${errors.length > 1 ? 's' : ''}: ${errors.join(', ')}`, - 'ManifestValidationError' - ); - } -} - -/** - * Load and parse webapplication.json manifest - * - * @param manifestPath - Path to the webapplication.json file - * @returns Promise resolving to the parsed manifest - * @throws SfError if manifest file not found or validation fails - */ -export async function loadManifest(manifestPath: string): Promise { - try { - const content = await readFile(manifestPath, 'utf-8'); - const manifest = JSON.parse(content) as WebAppManifest; - - validateManifest(manifest); - - return manifest; - } catch (error) { - if (error instanceof SfError) { - throw error; - } - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - throw new SfError( - `webapplication.json not found at: ${manifestPath}. Create a webapplication.json file in your project root.`, - 'ManifestNotFoundError' - ); - } - if (error instanceof SyntaxError) { - throw new SfError(`webapplication.json contains invalid JSON: ${error.message}`, 'ManifestParseError'); - } - throw new SfError( - `Failed to load webapplication.json: ${error instanceof Error ? error.message : String(error)}`, - 'ManifestLoadError' - ); - } -} diff --git a/src/config/types.ts b/src/config/types.ts index 7caf738..13ab046 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -31,21 +31,6 @@ export type WebAppDevResult = { devServerUrl: string; }; -/** - * Dev server process status - * Information about the running dev server process - */ -export type DevServerStatus = { - /** Whether the dev server is running */ - running: boolean; - /** Detected or configured URL of the dev server */ - url?: string; - /** Process ID if running */ - pid?: number; - /** Error message if failed to start */ - error?: string; -}; - /** * Dev server configuration options * Options for starting and managing the dev server process @@ -61,23 +46,6 @@ export type DevServerOptions = { startupTimeout?: number; }; -/** - * Dev server event types - * Events emitted by DevServerManager for lifecycle tracking - */ -export type DevServerEvents = { - /** Emitted when dev server is ready and URL is detected */ - ready: (url: string) => void; - /** Emitted when dev server process exits */ - exit: (code: number | null, signal: string | null) => void; - /** Emitted when dev server encounters an error */ - error: (error: Error | DevServerError) => void; - /** Emitted when dev server outputs to stdout (when SF_LOG_LEVEL=debug) */ - stdout: (data: string) => void; - /** Emitted when dev server outputs to stderr */ - stderr: (data: string) => void; -}; - /** * Parsed dev server error with context and suggestions * Used to provide user-friendly error messages in the browser diff --git a/src/config/webappDiscovery.ts b/src/config/webappDiscovery.ts index e7023cf..3c756f0 100644 --- a/src/config/webappDiscovery.ts +++ b/src/config/webappDiscovery.ts @@ -15,24 +15,40 @@ */ import { readdir, readFile } from 'node:fs/promises'; -import { join, relative } from 'node:path'; +import { basename, dirname, join, relative } from 'node:path'; import { SfError } from '@salesforce/core'; import type { WebAppManifest } from './manifest.js'; /** - * Discovered webapp manifest with its file path + * 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) + */ +const WEBAPPLICATIONS_FOLDER_PATTERN = /^webapplications$/i; + +/** + * Discovered webapp with its directory path and optional manifest */ export type DiscoveredWebapp = { - /** Absolute path to the webapplication.json file */ + /** Absolute path to the webapp directory */ path: string; - /** Relative path from cwd to the webapplication.json file */ + /** Relative path from cwd to the webapp directory */ relativePath: string; - /** Parsed manifest content */ - manifest: WebAppManifest; + /** Parsed manifest content (null if no webapplication.json found) */ + manifest: WebAppManifest | null; + /** Webapp name (from manifest.name 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; }; /** - * Directories to exclude when searching for webapplication.json files + * Directories to exclude when searching for webapplications folder */ const EXCLUDED_DIRECTORIES = new Set([ 'node_modules', @@ -50,7 +66,7 @@ const EXCLUDED_DIRECTORIES = new Set([ ]); /** - * Maximum depth to search for webapplication.json files + * Maximum depth to search for webapplications folder */ const MAX_SEARCH_DEPTH = 10; @@ -62,159 +78,337 @@ function shouldExcludeDirectory(dirName: string): boolean { } /** - * Try to parse a webapplication.json file and validate basic structure + * Check if a folder name matches "webapplications" (case-insensitive) + */ +function isWebapplicationsFolder(folderName: string): boolean { + return WEBAPPLICATIONS_FOLDER_PATTERN.test(folderName); +} + +/** + * Try to parse a webapplication.json file. + * Accepts any valid JSON object - missing fields will use defaults. */ async function tryParseWebappManifest(filePath: string): Promise { try { const content = await readFile(filePath, 'utf-8'); const manifest = JSON.parse(content) as WebAppManifest; - // Basic validation - must have at least a name field - if (manifest && typeof manifest.name === 'string' && manifest.name.trim()) { + // Accept any valid JSON object (missing fields will use defaults) + if (manifest && typeof manifest === 'object') { return manifest; } return null; } catch { - // Invalid JSON or read error - skip this file + // Invalid JSON or read error - no manifest return null; } } /** - * Recursively search for webapplication.json files in a directory + * Recursively search for the webapplications folder (case-insensitive) * * @param dir - Directory to search in - * @param cwd - Original working directory for relative path calculation * @param depth - Current search depth - * @returns Array of discovered webapps + * @returns Path to webapplications folder or null if not found */ -async function searchDirectory(dir: string, cwd: string, depth: number = 0): Promise { +async function findWebapplicationsFolderRecursive(dir: string, depth: number = 0): Promise { if (depth > MAX_SEARCH_DEPTH) { - return []; + return null; } try { const entries = await readdir(dir, { withFileTypes: true }); - // Separate files and directories for parallel processing - const webappJsonFiles = entries.filter((e) => e.isFile() && e.name === 'webapplication.json'); + // 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)); - // Process webapplication.json files in parallel - const manifestPromises = webappJsonFiles.map(async (entry) => { - const fullPath = join(dir, entry.name); - const manifest = await tryParseWebappManifest(fullPath); + const results = await Promise.all( + subdirectories.map((subdir) => findWebapplicationsFolderRecursive(join(dir, subdir.name), depth + 1)) + ); + + // Return the first non-null result + return results.find((result) => result !== null) ?? null; + } catch { + // Permission denied or other read error - skip this directory + return null; + } +} + +/** + * Check if we're inside a webapplications folder by traversing upward through parent directories. + * + * This handles cases where the user runs the command from inside a webapp folder: + * + * Example 1: Running from /project/webapplications/my-app/src/ + * Traverses: src -> my-app -> webapplications (found!) + * Returns: { webappsFolder: "/project/webapplications", currentWebappName: "my-app" } + * + * Example 2: Running from /project/webapplications/my-app/ + * Checks parent: webapplications (found!) + * Returns: { webappsFolder: "/project/webapplications", currentWebappName: "my-app" } + * + * Example 3: Running from /project/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) + * + * @param dir - Directory to start from + * @returns Object with webapplications folder path and current webapp name, or null if not found + */ +function findWebapplicationsFolderUpward( + dir: string +): { webappsFolder: string; currentWebappName: string | null } | null { + let currentDir = dir; + let childDir: string | null = null; // Tracks the previous dir as we move up + const maxUpwardDepth = 10; + let depth = 0; + + // Walk up the directory tree looking for "webapplications" folder + while (depth < maxUpwardDepth) { + const dirName = basename(currentDir); + const parentDir = dirname(currentDir); + + // Case: Current directory IS the webapplications folder + // e.g., cwd = /project/webapplications + if (isWebapplicationsFolder(dirName)) { + return { + webappsFolder: currentDir, + currentWebappName: childDir ? basename(childDir) : null, + }; + } + + // Case: Parent directory is the webapplications folder + // e.g., cwd = /project/webapplications/my-app (parent is webapplications) + if (isWebapplicationsFolder(basename(parentDir))) { + return { + webappsFolder: parentDir, + currentWebappName: dirName, // Current dir is the webapp folder name + }; + } + + // Reached filesystem root - stop searching + if (parentDir === currentDir) { + break; + } + + // Move up one level + childDir = currentDir; + currentDir = parentDir; + depth++; + } + + // Not inside a webapplications folder + return null; +} + +/** + * 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. + * + * @param webappsFolderPath - Absolute path to the webapplications folder + * @param cwd - Original working directory for relative path calculation + * @returns Array of discovered webapps + */ +async function discoverWebappsInFolder(webappsFolderPath: string, cwd: string): Promise { + try { + const entries = await readdir(webappsFolderPath, { withFileTypes: true }); + + // Get all subdirectories (each is a potential webapp) + const webappDirs = entries.filter((e) => e.isDirectory() && !shouldExcludeDirectory(e.name)); + + // Process each webapp directory in parallel + const webappPromises = webappDirs.map(async (entry): Promise => { + const webappPath = join(webappsFolderPath, entry.name); + const manifestFilePath = join(webappPath, 'webapplication.json'); + + // Try to load manifest + 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: fullPath, - relativePath: relative(cwd, fullPath) || 'webapplication.json', + 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 null; }); - // Process subdirectories in parallel - const subdirPromises = subdirectories.map((entry) => searchDirectory(join(dir, entry.name), cwd, depth + 1)); - - // Wait for all parallel operations - const [manifestResults, subdirResults] = await Promise.all([ - Promise.all(manifestPromises), - Promise.all(subdirPromises), - ]); - - // Combine results, filtering out nulls from manifest parsing - const results: DiscoveredWebapp[] = manifestResults.filter((result): result is DiscoveredWebapp => result !== null); - for (const subResults of subdirResults) { - results.push(...subResults); - } - - return results; + return await Promise.all(webappPromises); } catch { - // Permission denied or other read error - skip this directory + // Permission denied or other read error return []; } } /** - * Find all webapplication.json files in a directory and its subdirectories + * Result of finding all webapps, includes hint for auto-selection + */ +type FindAllWebappsResult = { + /** All discovered webapps */ + webapps: DiscoveredWebapp[]; + /** Name of webapp user is currently inside (for auto-selection), null if not inside any */ + currentWebappName: string | null; + /** Whether the webapplications folder was found (even if empty) */ + webappsFolderFound: boolean; +}; + +/** + * Find all webapps in the webapplications folder. + * Also returns a hint if the user is currently inside a specific webapp folder. * * @param cwd - Directory to search from (defaults to process.cwd()) - * @returns Array of discovered webapps sorted by path + * @returns Object with discovered webapps and currentWebappName hint for auto-selection */ -export async function findWebappManifests(cwd: string = process.cwd()): Promise { - const results = await searchDirectory(cwd, cwd); +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; + + if (upwardResult) { + webappsFolder = upwardResult.webappsFolder; + currentWebappName = upwardResult.currentWebappName; + } else { + // Step 2: Search downward if not found upward + webappsFolder = await findWebapplicationsFolderRecursive(cwd); + } - // Sort by relative path for consistent ordering - return results.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + if (!webappsFolder) { + return { webapps: [], currentWebappName: null, webappsFolderFound: false }; + } + + // Discover all webapps in the folder + const webapps = await discoverWebappsInFolder(webappsFolder, cwd); + + // Sort by name for consistent ordering + return { + webapps: webapps.sort((a, b) => a.name.localeCompare(b.name)), + currentWebappName, + webappsFolderFound: true, + }; } /** - * Find a specific webapp by its manifest name field - * - * @param name - The webapp name to search for (matches manifest.name field) - * @param cwd - Directory to search from (defaults to process.cwd()) - * @returns The discovered webapp or null if not found + * Result of webapp discovery */ -export async function findWebappByName(name: string, cwd: string = process.cwd()): Promise { - const allWebapps = await findWebappManifests(cwd); - return allWebapps.find((webapp) => webapp.manifest.name === name) ?? null; -} +type DiscoverWebappResult = { + /** The selected/discovered webapp (null if user needs to select) */ + webapp: DiscoveredWebapp | null; + /** All discovered webapps */ + allWebapps: DiscoveredWebapp[]; + /** Whether the webapp was auto-selected because user is inside its folder */ + autoSelected: boolean; +}; /** - * Get a single webapp manifest, handling the various discovery scenarios + * 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) * * @param name - Optional webapp name to search for * @param cwd - Directory to search from - * @returns Object containing the discovered webapp and all found webapps (for selection UI) + * @returns Object containing the discovered webapp, all webapps, and autoSelected flag * @throws SfError if no webapps found or named webapp not found */ export async function discoverWebapp( name: string | undefined, cwd: string = process.cwd() -): Promise<{ webapp: DiscoveredWebapp | null; allWebapps: DiscoveredWebapp[] }> { - const allWebapps = await findWebappManifests(cwd); +): Promise { + const { webapps: allWebapps, currentWebappName, webappsFolderFound } = await findAllWebapps(cwd); // No webapps found if (allWebapps.length === 0) { - throw new SfError( - 'No webapplication.json found in the current directory or subdirectories.\n' + - 'Create a webapplication.json file in your project root to get started.', - 'WebappNotFoundError' - ); + if (webappsFolderFound) { + // Folder exists but is empty + throw new SfError( + 'Found "webapplications" folder but no webapps inside it.\n' + + 'Create webapp subdirectories inside the "webapplications" folder to get started.\n\n' + + 'Expected structure:\n' + + ' webapplications/\n' + + ' ├── my-app-1/\n' + + ' │ └── webapplication.json (optional)\n' + + ' └── my-app-2/', + 'WebappNotFoundError' + ); + } else { + // Folder doesn't exist + 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/', + 'WebappNotFoundError' + ); + } } - // If name is provided, find the specific webapp + // Priority 1: If name is provided via --name flag, find that specific webapp if (name) { - const webapp = allWebapps.find((w) => w.manifest.name === name); + const webapp = allWebapps.find((w) => w.name === name); if (!webapp) { - const availableNames = allWebapps.map((w) => ` - ${w.manifest.name} (${w.relativePath})`).join('\n'); + const WARNING = '\u26A0\uFE0F'; // ⚠️ + + const availableNames = allWebapps + .map((w) => ` - ${w.name} - (Path:${w.relativePath})${w.hasManifest ? '' : ` - ${WARNING} No Manifest`}`) + .join('\n'); throw new SfError( `No webapp found with name "${name}".\n\nAvailable webapps:\n${availableNames}`, 'WebappNameNotFoundError' ); } - return { webapp, allWebapps }; + return { webapp, allWebapps, autoSelected: false }; } - // No name provided - check if there's only one webapp + // 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) { + return { webapp, allWebapps, autoSelected: true }; + } + } + + // Priority 3: If only one webapp exists, auto-select it if (allWebapps.length === 1) { - return { webapp: allWebapps[0], allWebapps }; + return { webapp: allWebapps[0], allWebapps, autoSelected: false }; } // Multiple webapps found - return null to indicate selection is needed - return { webapp: null, allWebapps }; -} - -/** - * Format webapp choices for display in selection prompt - * - * @param webapps - Array of discovered webapps - * @returns Formatted choices with name and path info - */ -export function formatWebappChoices(webapps: DiscoveredWebapp[]): Array<{ name: string; value: DiscoveredWebapp }> { - return webapps.map((webapp) => ({ - name: `${webapp.manifest.name} - ${webapp.manifest.label} (${webapp.relativePath})`, - value: webapp, - })); + return { webapp: null, allWebapps, autoSelected: false }; } diff --git a/src/error/DevServerErrorParser.ts b/src/error/DevServerErrorParser.ts index a2c482b..4d1009b 100644 --- a/src/error/DevServerErrorParser.ts +++ b/src/error/DevServerErrorParser.ts @@ -130,6 +130,19 @@ const ERROR_PATTERNS: ErrorPattern[] = [ }, }, + // Command not found - dependencies not installed + { + pattern: /command not found|not recognized as.*command/i, + type: 'missing-module', + title: 'Dependencies Not Installed', + getMessage: (stderr): string => { + const cmdMatch = stderr.match(/(?:sh:|bash:)\s*(\S+):\s*command not found/i); + const command = cmdMatch?.[1] ?? 'required command'; + return `Command '${command}' not found. Project dependencies may not be installed.`; + }, + getSuggestions: (): string[] => ['Run: npm install', 'Or: yarn install', 'Then try running the dev command again'], + }, + // Generic fallback - matches everything { pattern: /.*/, @@ -180,35 +193,6 @@ export class DevServerErrorParser { return this.createGenericError(stderr, exitCode, signal); } - /** - * Check if an error should trigger automatic restart - * Some errors are transient, others are permanent - * - * @param error - Parsed dev server error - * @returns True if error might be resolved by restart - */ - public static shouldRetry(error: DevServerError): boolean { - // Don't retry these permanent error types - const permanentErrors: Array = [ - 'syntax-error', - 'missing-module', - 'file-not-found', - 'permission-error', - ]; - - return !permanentErrors.includes(error.type); - } - - /** - * Get a concise summary line for logging - * - * @param error - Parsed dev server error - * @returns One-line summary string - */ - public static getSummary(error: DevServerError): string { - return `${error.title}: ${error.message}`; - } - /** * Extract the most relevant lines from stderr * Returns the last N lines, filtered for noise diff --git a/src/error/ErrorHandler.ts b/src/error/ErrorHandler.ts deleted file mode 100644 index 3da078d..0000000 --- a/src/error/ErrorHandler.ts +++ /dev/null @@ -1,417 +0,0 @@ -/* - * Copyright 2026, Salesforce, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { SfError } from '@salesforce/core'; - -/** - * Standardized error codes for webapp dev command - */ -export enum WebAppErrorCode { - // Authentication errors - TOKEN_EXPIRED = 'TokenExpiredError', - ORG_NOT_FOUND = 'OrgNotFoundError', - AUTH_FAILED = 'AuthenticationFailedError', - TOKEN_REFRESH_FAILED = 'TokenRefreshFailedError', - - // Manifest errors - MANIFEST_NOT_FOUND = 'ManifestNotFoundError', - MANIFEST_INVALID = 'ManifestValidationError', - MANIFEST_PARSE_ERROR = 'ManifestParseError', - - // Dev server errors - DEV_SERVER_START_FAILED = 'DevServerStartFailedError', - DEV_SERVER_TIMEOUT = 'DevServerTimeoutError', - DEV_SERVER_CRASHED = 'DevServerCrashedError', - DEV_SERVER_COMMAND_REQUIRED = 'DevServerCommandRequiredError', - - // Proxy errors - PORT_IN_USE = 'PortInUseError', - PROXY_START_FAILED = 'ProxyStartFailedError', - TARGET_UNREACHABLE = 'TargetUnreachableError', - - // Network errors - NETWORK_ERROR = 'NetworkError', - CONNECTION_REFUSED = 'ConnectionRefusedError', - TIMEOUT_ERROR = 'TimeoutError', - - // Runtime errors - RUNTIME_ERROR = 'RuntimeError', - UNCAUGHT_EXCEPTION = 'UncaughtException', - UNHANDLED_REJECTION = 'UnhandledRejection', - STACK_TRACE_ERROR = 'StackTraceError', -} - -/** - * ErrorHandler provides standardized error messages with actionable suggestions - * for all webapp dev command error scenarios. - */ -export class ErrorHandler { - /** - * Create an error for expired authentication token - * - * @param orgAlias - The org alias that has expired authentication - * @returns SfError with user-friendly message and suggestions - */ - public static createTokenExpiredError(orgAlias: string): SfError { - return new SfError(`Your org authentication has expired for '${orgAlias}'.`, WebAppErrorCode.TOKEN_EXPIRED, [ - `Run 'sf org login web -o ${orgAlias}' to re-authenticate`, - "Or run 'sf org login web' to log in to a new org", - 'You can check your current org status with: sf org display', - ]); - } - - /** - * Create an error for org not found - * - * @param orgAlias - The org alias that was not found - * @returns SfError with user-friendly message and suggestions - */ - public static createOrgNotFoundError(orgAlias: string): SfError { - return new SfError(`Org '${orgAlias}' not found in your authenticated orgs.`, WebAppErrorCode.ORG_NOT_FOUND, [ - 'Check available orgs with: sf org list', - `Log in to the org with: sf org login web -a ${orgAlias}`, - 'Make sure the org alias is spelled correctly', - ]); - } - - /** - * Create an error for authentication failure - * - * @param orgAlias - The org alias where authentication failed - * @param details - Optional details about the failure - * @returns SfError with user-friendly message and suggestions - */ - public static createAuthFailedError(orgAlias: string, details?: string): SfError { - const message = details - ? `Authentication failed for org '${orgAlias}': ${details}` - : `Authentication failed for org '${orgAlias}'.`; - - return new SfError(message, WebAppErrorCode.AUTH_FAILED, [ - `Check your org status with: sf org display -o ${orgAlias}`, - `Re-authenticate with: sf org login web -o ${orgAlias}`, - 'Ensure your Salesforce org is accessible and your credentials are valid', - ]); - } - - /** - * Create an error for token refresh failure - * - * @param orgAlias - The org alias where token refresh failed - * @returns SfError with user-friendly message and suggestions - */ - public static createTokenRefreshFailedError(orgAlias: string): SfError { - return new SfError(`Failed to refresh access token for org '${orgAlias}'.`, WebAppErrorCode.TOKEN_REFRESH_FAILED, [ - `Re-authenticate with: sf org login web -r -o ${orgAlias}`, - 'The refresh token may have expired or been revoked', - 'Check your Salesforce org session settings', - ]); - } - - /** - * Create an error for missing webapplication.json manifest - * - * @returns SfError with user-friendly message and suggestions - */ - public static createManifestNotFoundError(): SfError { - return new SfError('webapplication.json not found in the current directory.', WebAppErrorCode.MANIFEST_NOT_FOUND, [ - 'Create a webapplication.json file in your project root', - 'Make sure you are in the correct project directory', - 'The webapplication.json file should be in the project root', - ]); - } - - /** - * Create an error for invalid webapplication.json manifest - * - * @param validationErrors - Array of validation error messages - * @returns SfError with user-friendly message and suggestions - */ - public static createManifestValidationError(validationErrors: string[]): SfError { - const errorList = validationErrors.map((err) => ` • ${err}`).join('\n'); - - return new SfError(`Web application manifest validation failed:\n${errorList}`, WebAppErrorCode.MANIFEST_INVALID, [ - 'Check the webapplication.json file for syntax errors', - 'Ensure all required fields are present: name, label, version, outputDir', - 'Refer to the schema documentation for valid field formats', - ]); - } - - /** - * Create an error for JSON parse errors in webapplication.json - * - * @param parseError - The original parse error message - * @returns SfError with user-friendly message and suggestions - */ - public static createManifestParseError(parseError: string): SfError { - return new SfError(`Failed to parse webapplication.json: ${parseError}`, WebAppErrorCode.MANIFEST_PARSE_ERROR, [ - 'Check for JSON syntax errors (missing commas, brackets, quotes)', - 'Validate your JSON with a JSON validator tool', - 'Make sure the file is saved with UTF-8 encoding', - ]); - } - - /** - * Create an error for dev server start failure - * - * @param command - The command that failed to start - * @param errorMessage - The error message from the failed command - * @returns SfError with user-friendly message and suggestions - */ - public static createDevServerStartFailedError(command: string, errorMessage?: string): SfError { - const message = errorMessage - ? `Dev server failed to start: ${errorMessage}` - : `Dev server failed to start with command: ${command}`; - - return new SfError(message, WebAppErrorCode.DEV_SERVER_START_FAILED, [ - 'Check the dev.command in your webapplication.json is correct', - 'Make sure all dependencies are installed (run: npm install or yarn install)', - 'Verify the command works when run manually in the terminal', - 'Check the dev server logs for more details', - ]); - } - - /** - * Create an error for dev server timeout - * - * @param timeoutSeconds - The timeout duration in seconds - * @returns SfError with user-friendly message and suggestions - */ - public static createDevServerTimeoutError(timeoutSeconds: number): SfError { - return new SfError( - `Dev server did not start within ${timeoutSeconds} seconds.`, - WebAppErrorCode.DEV_SERVER_TIMEOUT, - [ - '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', - 'Increase the startup timeout if your dev server is slow to start', - ] - ); - } - - /** - * Create an error for dev server crash - * - * @param exitCode - The exit code of the crashed process - * @param signal - The signal that killed the process (if any) - * @returns SfError with user-friendly message and suggestions - */ - public static createDevServerCrashedError(exitCode: number | null, signal: string | null): SfError { - const details = signal ? `signal ${signal}` : `exit code ${String(exitCode)}`; - return new SfError(`Dev server crashed unexpectedly (${details}).`, WebAppErrorCode.DEV_SERVER_CRASHED, [ - 'Check the dev server logs for error messages', - 'Verify all dependencies are properly installed', - 'Try running the dev server command manually to diagnose the issue', - 'The dev server will attempt to restart automatically', - ]); - } - - /** - * Create an error for missing dev server command - * - * @returns SfError with user-friendly message and suggestions - */ - public static createDevServerCommandRequiredError(): SfError { - return new SfError('Dev server command or URL is required.', WebAppErrorCode.DEV_SERVER_COMMAND_REQUIRED, [ - 'Add a "dev.command" field to your webapplication.json (e.g., "npm run dev")', - 'Or provide a --url flag to specify the dev server URL', - 'Example: sf webapp dev --url http://localhost:5173', - ]); - } - - /** - * Create an error for port already in use - * - * @param port - The port that is already in use - * @returns SfError with user-friendly message and suggestions - */ - public static createPortInUseError(port: number): SfError { - const alternativePort = port + 1; - return new SfError(`Port ${port} is already in use.`, WebAppErrorCode.PORT_IN_USE, [ - `Try a different port with: sf webapp dev --port ${alternativePort}`, - 'Check if another proxy server or application is running on this port', - `On macOS/Linux, find the process using: lsof -i :${port}`, - `On Windows, find the process using: netstat -ano | findstr :${port}`, - ]); - } - - /** - * Create an error for proxy start failure - * - * @param errorMessage - The error message from the proxy failure - * @returns SfError with user-friendly message and suggestions - */ - public static createProxyStartFailedError(errorMessage: string): SfError { - return new SfError(`Failed to start proxy server: ${errorMessage}`, WebAppErrorCode.PROXY_START_FAILED, [ - 'Check if the port is available', - 'Verify network permissions and firewall settings', - 'Try running with sudo/administrator privileges if needed', - ]); - } - - /** - * Create an error for target unreachable - * - * @param target - The target URL that is unreachable - * @param reason - The reason why the target is unreachable - * @returns SfError with user-friendly message and suggestions - */ - public static createTargetUnreachableError(target: string, reason?: string): SfError { - const message = reason ? `Cannot reach ${target}: ${reason}` : `Cannot reach ${target}`; - - const suggestions = - target.includes('salesforce.com') || target.includes('force.com') - ? [ - 'Check your internet connection', - 'Verify the Salesforce org is accessible', - 'Check if there are any Salesforce service outages', - 'Verify your firewall is not blocking the connection', - ] - : [ - 'Make sure the dev server is running', - `Verify the dev server URL is correct: ${target}`, - 'Check if the dev server started successfully', - 'Try accessing the URL directly in your browser', - ]; - - return new SfError(message, WebAppErrorCode.TARGET_UNREACHABLE, suggestions); - } - - /** - * Create an error for network errors - * - * @param operation - The operation that failed due to network error - * @param errorMessage - The network error message - * @returns SfError with user-friendly message and suggestions - */ - public static createNetworkError(operation: string, errorMessage?: string): SfError { - const message = errorMessage - ? `Network error during ${operation}: ${errorMessage}` - : `Network error during ${operation}`; - - return new SfError(message, WebAppErrorCode.NETWORK_ERROR, [ - 'Check your internet connection', - 'Verify your network proxy settings if behind a corporate firewall', - 'Try again in a few moments', - 'Check if the target service is accessible', - ]); - } - - /** - * Create an error for connection refused - * - * @param target - The target that refused the connection - * @returns SfError with user-friendly message and suggestions - */ - public static createConnectionRefusedError(target: string): SfError { - return new SfError(`Connection refused by ${target}`, WebAppErrorCode.CONNECTION_REFUSED, [ - 'Make sure the target service is running', - 'Verify the URL and port are correct', - 'Check firewall settings', - 'The service may be temporarily unavailable', - ]); - } - - /** - * Create an error for timeout - * - * @param operation - The operation that timed out - * @param timeoutSeconds - The timeout duration in seconds - * @returns SfError with user-friendly message and suggestions - */ - public static createTimeoutError(operation: string, timeoutSeconds: number): SfError { - return new SfError(`${operation} timed out after ${timeoutSeconds} seconds`, WebAppErrorCode.TIMEOUT_ERROR, [ - 'The operation took longer than expected', - 'Check your network connection speed', - 'Try increasing the timeout if this happens frequently', - 'Verify the target service is responding', - ]); - } - - /** - * Sanitize error messages to remove sensitive information - * - * @param message - The original error message - * @returns Sanitized error message with tokens and credentials removed - */ - public static sanitizeErrorMessage(message: string): string { - // Remove access tokens (Bearer tokens, session IDs) - let sanitized = message.replace(/Bearer\s+[A-Za-z0-9._-]+/gi, 'Bearer [REDACTED]'); - sanitized = sanitized.replace(/access_token[=:]\s*[A-Za-z0-9._-]+/gi, 'access_token=[REDACTED]'); - sanitized = sanitized.replace(/sid[=:]\s*[A-Za-z0-9._-]+/gi, 'sid=[REDACTED]'); - - // Remove potential passwords - sanitized = sanitized.replace(/password[=:]\s*[^\s&]+/gi, 'password=[REDACTED]'); - sanitized = sanitized.replace(/client_secret[=:]\s*[^\s&]+/gi, 'client_secret=[REDACTED]'); - - // Remove refresh tokens - sanitized = sanitized.replace(/refresh_token[=:]\s*[A-Za-z0-9._-]+/gi, 'refresh_token=[REDACTED]'); - - return sanitized; - } - - /** - * Check if an error is a network-related error - * - * @param error - The error to check - * @returns True if the error is network-related - */ - public static isNetworkError(error: Error): boolean { - const networkErrorCodes = ['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', 'ECONNRESET', 'ENETUNREACH', 'EHOSTUNREACH']; - - // Check error code - if ('code' in error && typeof error.code === 'string') { - return networkErrorCodes.includes(error.code); - } - - // Check error message - const message = error.message.toLowerCase(); - return ( - message.includes('network') || - message.includes('connection') || - message.includes('timeout') || - message.includes('econnrefused') - ); - } - - /** - * Convert a generic error to an SfError with appropriate context - * - * @param error - The original error - * @param context - Context about where the error occurred - * @returns SfError with proper formatting and suggestions - */ - public static wrapError(error: unknown, context: string): SfError { - if (error instanceof SfError) { - return error as SfError; - } - - const errorObj = error instanceof Error ? error : new Error(String(error)); - const sanitizedMessage = ErrorHandler.sanitizeErrorMessage(errorObj.message); - - // Check for specific error types - if (ErrorHandler.isNetworkError(errorObj)) { - return ErrorHandler.createNetworkError(context, sanitizedMessage); - } - - // Generic error wrapping - return new SfError(`${context}: ${sanitizedMessage}`, '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/proxy/ProxyServer.ts b/src/proxy/ProxyServer.ts index 546e2cd..d184689 100644 --- a/src/proxy/ProxyServer.ts +++ b/src/proxy/ProxyServer.ts @@ -33,7 +33,7 @@ import { ErrorPageRenderer } from '../templates/ErrorPageRenderer.js'; /** * Configuration for the proxy server */ -export type ProxyServerConfig = { +type ProxyServerConfig = { port: number; devServerUrl: string; salesforceInstanceUrl: string; @@ -42,18 +42,6 @@ export type ProxyServerConfig = { host?: string; }; -/** - * Proxy server statistics - */ -export type ProxyStats = { - requestCount: number; - salesforceRequests: number; - devServerRequests: number; - webSocketUpgrades: number; - errors: number; - startTime: Date; -}; - /** * ProxyServer manages the HTTP proxy that sits between the web application * and both the Salesforce instance and the local development server. @@ -65,13 +53,11 @@ export class ProxyServer extends EventEmitter { private readonly wsProxy: httpProxy; private readonly errorPageRenderer: ErrorPageRenderer; private server: Server | null = null; - private readonly stats: ProxyStats; private isCodeBuilder = false; private healthCheckInterval: NodeJS.Timeout | null = null; private devServerStatus: 'unknown' | 'up' | 'down' | 'error' = 'unknown'; private readonly workspaceScript: string; private activeDevServerError: DevServerError | null = null; - private errorClearTimeout: NodeJS.Timeout | null = null; private readonly activeConnections: Set = new Set(); private proxyHandler: ProxyHandler | null = null; private orgInfo: OrgInfo | undefined; @@ -83,14 +69,6 @@ export class ProxyServer extends EventEmitter { this.logger = Logger.childFromRoot('ProxyServer'); this.errorPageRenderer = new ErrorPageRenderer(); this.workspaceScript = ProxyServer.detectWorkspaceScript(); - this.stats = { - requestCount: 0, - salesforceRequests: 0, - devServerRequests: 0, - webSocketUpgrades: 0, - errors: 0, - startTime: new Date(), - }; this.isCodeBuilder = ProxyServer.detectCodeBuilder(); @@ -138,32 +116,12 @@ export class ProxyServer extends EventEmitter { } } - public getDevServerStatus(): string { - return this.devServerStatus; - } - public getProxyUrl(): string { const host = this.getBindHost(); const displayHost = host === '0.0.0.0' || host === '127.0.0.1' ? 'localhost' : host; return `http://${displayHost}:${this.config.port}`; } - public getStats(): ProxyStats { - return { ...this.stats }; - } - - public hasActiveDevServerError(): boolean { - return this.activeDevServerError !== null; - } - - public isCodeBuilderEnvironment(): boolean { - return this.isCodeBuilder; - } - - public isRunning(): boolean { - return this.server !== null && this.server.listening; - } - public setActiveDevServerError(error: DevServerError): void { this.activeDevServerError = error; this.devServerStatus = 'error'; @@ -201,7 +159,6 @@ export class ProxyServer extends EventEmitter { this.handleRequest(req, res).catch((error) => { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error(`Request handling error: ${errorMessage}`); - this.stats.errors++; }); }); @@ -211,7 +168,6 @@ export class ProxyServer extends EventEmitter { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error(`WebSocket upgrade error: ${errorMessage}`); - this.stats.errors++; socket.end(); } }); @@ -273,11 +229,6 @@ export class ProxyServer extends EventEmitter { this.healthCheckInterval = null; } - if (this.errorClearTimeout) { - clearTimeout(this.errorClearTimeout); - this.errorClearTimeout = null; - } - for (const socket of this.activeConnections) { socket.destroy(); } @@ -377,7 +328,6 @@ export class ProxyServer extends EventEmitter { } private handleProxyError(error: Error, req: IncomingMessage, res: ServerResponse | NodeJS.Socket): void { - this.stats.errors++; const url = req.url ?? '/'; this.logger.error(`Proxy error for ${url}: ${error.message}`); @@ -393,8 +343,6 @@ export class ProxyServer extends EventEmitter { } private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { - this.stats.requestCount++; - const url = req.url ?? '/'; const method = req.method ?? 'GET'; this.logger.debug(`[${method}] ${url}`); @@ -411,12 +359,6 @@ export class ProxyServer extends EventEmitter { } if (this.proxyHandler) { - if (url.includes('/services')) { - this.stats.salesforceRequests++; - } else { - this.stats.devServerRequests++; - } - // Package handles all errors internally and returns proper HTTP responses await this.proxyHandler(req, res); } else { @@ -427,8 +369,6 @@ export class ProxyServer extends EventEmitter { } private handleWebSocketUpgrade(req: IncomingMessage, socket: NodeJS.Socket, head: Buffer): void { - this.stats.webSocketUpgrades++; - const url = req.url ?? '/'; this.logger.debug(`[WebSocket] Upgrade request: ${url}`); diff --git a/src/server/DevServerManager.ts b/src/server/DevServerManager.ts index d62c1dc..bbbe344 100644 --- a/src/server/DevServerManager.ts +++ b/src/server/DevServerManager.ts @@ -17,7 +17,7 @@ import { EventEmitter } from 'node:events'; import { spawn, type ChildProcess } from 'node:child_process'; import { Logger, SfError } from '@salesforce/core'; -import type { DevServerOptions, DevServerStatus } from '../config/types.js'; +import type { DevServerOptions } from '../config/types.js'; import { DevServerErrorParser } from '../error/DevServerErrorParser.js'; /** @@ -299,32 +299,6 @@ export class DevServerManager extends EventEmitter { }); } - /** - * Gets the current status of the dev server - * - * @returns DevServerStatus object with current state - */ - public getStatus(): DevServerStatus { - // For explicit URL mode (no process), consider running if ready - // For spawned process mode, check process state - const running = this.isReady && (this.process === null || !this.process.killed); - - return { - running, - url: this.detectedUrl ?? undefined, - pid: this.process?.pid, - }; - } - - /** - * Gets the detected or explicit URL of the dev server - * - * @returns The dev server URL, or null if not yet detected - */ - public getUrl(): string | null { - return this.detectedUrl; - } - /** * Sets up event handlers for the spawned process * @@ -455,8 +429,11 @@ export class DevServerManager extends EventEmitter { this.logger.error(`Dev server error: ${parsedError.title}`); this.logger.debug(`Error type: ${parsedError.type}`); - // Emit parsed error - this.emit('error', parsedError); + // Convert to SfError for proper error handling + // Use just the message (not title) since title will be shown separately + const sfError = new SfError(parsedError.message, 'DevServerError', parsedError.suggestions); + + this.emit('error', sfError); } // Reset state diff --git a/test/commands/webapp/dev.test.ts b/test/commands/webapp/dev.test.ts index f776e03..2d320ea 100644 --- a/test/commands/webapp/dev.test.ts +++ b/test/commands/webapp/dev.test.ts @@ -16,8 +16,6 @@ import { expect } from 'chai'; import { TestContext } from '@salesforce/core/testSetup'; -import { SfError } from '@salesforce/core'; -import { ErrorHandler } from '../../../src/error/ErrorHandler.js'; import type { WebAppManifest, WebAppDevResult } from '../../../src/config/types.js'; describe('webapp:dev command integration', () => { @@ -92,36 +90,6 @@ describe('webapp:dev command integration', () => { }); }); - describe('Error Handling', () => { - it('should create proper manifest not found error', () => { - const error = ErrorHandler.createManifestNotFoundError(); - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal('ManifestNotFoundError'); - expect(error.message).to.include('webapplication.json not found'); - }); - - it('should create proper dev server command required error', () => { - const error = ErrorHandler.createDevServerCommandRequiredError(); - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal('DevServerCommandRequiredError'); - expect(error.message).to.include('Dev server command or URL is required'); - }); - - it('should create proper port in use error', () => { - const error = ErrorHandler.createPortInUseError(4545); - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal('PortInUseError'); - expect(error.message).to.include('Port 4545 is already in use'); - }); - - it('should create proper auth failed error', () => { - const error = ErrorHandler.createAuthFailedError('test@example.com'); - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal('AuthenticationFailedError'); - expect(error.message).to.include('test@example.com'); - }); - }); - describe('Configuration Validation', () => { it('should validate manifest with dev.url', () => { const manifest: WebAppManifest = { diff --git a/test/config/ManifestWatcher.test.ts b/test/config/ManifestWatcher.test.ts index ffb3615..d8d7533 100644 --- a/test/config/ManifestWatcher.test.ts +++ b/test/config/ManifestWatcher.test.ts @@ -143,104 +143,82 @@ describe('ManifestWatcher', () => { }); }); - describe('Basic Validation', () => { - it('should reject manifest with missing required field: name', async () => { - const invalid = { ...validManifest }; - delete (invalid as Partial).name; - - writeFileSync(testManifestPath, JSON.stringify(invalid, null, 2)); - - const watcher = new ManifestWatcher({ manifestPath: testManifestPath, watch: false }); - - try { - watcher.initialize(); - expect.fail('Should have thrown an error'); - } catch (error) { - expect(error).to.be.instanceOf(SfError); - expect((error as SfError).name).to.equal('ManifestValidationError'); - expect((error as SfError).message).to.include('name'); - } - - await watcher.stop(); - }); - - it('should reject manifest with missing required field: label', async () => { - const invalid = { ...validManifest }; - delete (invalid as Partial).label; + describe('Partial Manifest Support', () => { + it('should accept manifest with only dev.command (no name, label, version, outputDir)', async () => { + const partialManifest = { + dev: { + command: 'npm run dev', + }, + }; - writeFileSync(testManifestPath, JSON.stringify(invalid, null, 2)); + writeFileSync(testManifestPath, JSON.stringify(partialManifest, null, 2)); const watcher = new ManifestWatcher({ manifestPath: testManifestPath, watch: false }); + watcher.initialize(); - try { - watcher.initialize(); - expect.fail('Should have thrown an error'); - } catch (error) { - expect(error).to.be.instanceOf(SfError); - expect((error as SfError).name).to.equal('ManifestValidationError'); - expect((error as SfError).message).to.include('label'); - } + const manifest = watcher.getManifest(); + expect(manifest).to.exist; + expect(manifest?.dev?.command).to.equal('npm run dev'); + expect(manifest?.name).to.be.undefined; + expect(manifest?.label).to.be.undefined; await watcher.stop(); }); - it('should reject manifest with missing required field: version', async () => { - const invalid = { ...validManifest }; - delete (invalid as Partial).version; + it('should accept manifest with only dev.url', async () => { + const partialManifest = { + dev: { + url: 'http://localhost:5173', + }, + }; - writeFileSync(testManifestPath, JSON.stringify(invalid, null, 2)); + writeFileSync(testManifestPath, JSON.stringify(partialManifest, null, 2)); const watcher = new ManifestWatcher({ manifestPath: testManifestPath, watch: false }); + watcher.initialize(); - try { - watcher.initialize(); - expect.fail('Should have thrown an error'); - } catch (error) { - expect(error).to.be.instanceOf(SfError); - expect((error as SfError).name).to.equal('ManifestValidationError'); - expect((error as SfError).message).to.include('version'); - } + const manifest = watcher.getManifest(); + expect(manifest).to.exist; + expect(manifest?.dev?.url).to.equal('http://localhost:5173'); await watcher.stop(); }); - it('should reject manifest with missing required field: outputDir', async () => { - const invalid = { ...validManifest }; - delete (invalid as Partial).outputDir; + it('should accept empty manifest object', async () => { + const emptyManifest = {}; - writeFileSync(testManifestPath, JSON.stringify(invalid, null, 2)); + writeFileSync(testManifestPath, JSON.stringify(emptyManifest, null, 2)); const watcher = new ManifestWatcher({ manifestPath: testManifestPath, watch: false }); + watcher.initialize(); - try { - watcher.initialize(); - expect.fail('Should have thrown an error'); - } catch (error) { - expect(error).to.be.instanceOf(SfError); - expect((error as SfError).name).to.equal('ManifestValidationError'); - expect((error as SfError).message).to.include('outputDir'); - } + const manifest = watcher.getManifest(); + expect(manifest).to.exist; + expect(manifest?.name).to.be.undefined; + expect(manifest?.dev).to.be.undefined; await watcher.stop(); }); - it('should reject manifest with multiple missing required fields', async () => { - const invalid = { version: '1.0.0' }; + it('should accept manifest with name but missing other fields', async () => { + const partialManifest = { + name: 'myApp', + dev: { + command: 'npm run dev', + }, + }; - writeFileSync(testManifestPath, JSON.stringify(invalid, null, 2)); + writeFileSync(testManifestPath, JSON.stringify(partialManifest, null, 2)); const watcher = new ManifestWatcher({ manifestPath: testManifestPath, watch: false }); + watcher.initialize(); - try { - watcher.initialize(); - expect.fail('Should have thrown an error'); - } catch (error) { - expect(error).to.be.instanceOf(SfError); - expect((error as SfError).name).to.equal('ManifestValidationError'); - expect((error as SfError).message).to.include('name'); - expect((error as SfError).message).to.include('label'); - expect((error as SfError).message).to.include('outputDir'); - } + const manifest = watcher.getManifest(); + expect(manifest).to.exist; + expect(manifest?.name).to.equal('myApp'); + expect(manifest?.label).to.be.undefined; + expect(manifest?.version).to.be.undefined; + expect(manifest?.outputDir).to.be.undefined; await watcher.stop(); }); diff --git a/test/config/webappDiscovery.test.ts b/test/config/webappDiscovery.test.ts new file mode 100644 index 0000000..5b9aac0 --- /dev/null +++ b/test/config/webappDiscovery.test.ts @@ -0,0 +1,176 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { 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 { DEFAULT_DEV_COMMAND, discoverWebapp } from '../../src/config/webappDiscovery.js'; + +describe('webappDiscovery', () => { + const $$ = new TestContext(); + const testDir = join(process.cwd(), '.test-webapp-discovery'); + + beforeEach(() => { + // Create test directory + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + // Clean up test directory + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + $$.restore(); + }); + + describe('DEFAULT_DEV_COMMAND', () => { + it('should be npm run dev', () => { + expect(DEFAULT_DEV_COMMAND).to.equal('npm run dev'); + }); + }); + + describe('discoverWebapp', () => { + it('should throw error if no webapplications folder found', async () => { + 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'); + } + }); + + it('should throw error if webapplications folder exists but is empty', async () => { + const webappsPath = join(testDir, 'webapplications'); + mkdirSync(webappsPath, { recursive: true }); + + 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('Found "webapplications" folder but no webapps inside it'); + expect((error as SfError).message).to.not.include('No webapplications folder found'); + } + }); + + 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 }); + + const result = await discoverWebapp('app-b', testDir); + + expect(result.webapp?.name).to.equal('app-b'); + expect(result.autoSelected).to.be.false; + expect(result.allWebapps).to.have.length(2); + }); + + it('should throw error if named webapp not found', async () => { + const webappsPath = join(testDir, 'webapplications'); + mkdirSync(join(webappsPath, 'my-app'), { recursive: true }); + + try { + await discoverWebapp('non-existent', testDir); + expect.fail('Should have thrown an error'); + } catch (error) { + expect(error).to.be.instanceOf(SfError); + expect((error as SfError).name).to.equal('WebappNameNotFoundError'); + expect((error as SfError).message).to.include('No webapp found with name'); + expect((error as SfError).message).to.include('my-app'); + } + }); + + 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 }); + + const result = await discoverWebapp(undefined, myAppPath); + + expect(result.webapp?.name).to.equal('my-app'); + expect(result.autoSelected).to.be.true; + }); + + it('should auto-select webapp when inside subfolder', async () => { + const webappsPath = join(testDir, 'webapplications'); + const myAppPath = join(webappsPath, 'my-app'); + const srcPath = join(myAppPath, 'src'); + mkdirSync(srcPath, { recursive: true }); + mkdirSync(join(webappsPath, 'other-app'), { recursive: true }); + + const result = await discoverWebapp(undefined, srcPath); + + expect(result.webapp?.name).to.equal('my-app'); + expect(result.autoSelected).to.be.true; + }); + + it('should auto-select by folder name when manifest name differs', 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' })); + + const result = await discoverWebapp(undefined, myAppPath); + + expect(result.webapp?.name).to.equal('ManifestName'); + 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 }); + + const result = await discoverWebapp(undefined, testDir); + + expect(result.webapp?.name).to.equal('only-app'); + expect(result.autoSelected).to.be.false; + }); + + 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 }); + + const result = await discoverWebapp(undefined, testDir); + + expect(result.webapp).to.be.null; + expect(result.autoSelected).to.be.false; + expect(result.allWebapps).to.have.length(2); + }); + + it('should prioritize --name flag over auto-selection', async () => { + const webappsPath = join(testDir, 'webapplications'); + const currentApp = join(webappsPath, 'current-app'); + mkdirSync(currentApp, { recursive: true }); + mkdirSync(join(webappsPath, 'other-app'), { recursive: true }); + + const result = await discoverWebapp('other-app', currentApp); + + expect(result.webapp?.name).to.equal('other-app'); + expect(result.autoSelected).to.be.false; + }); + }); +}); diff --git a/test/error/DevServerErrorParser.test.ts b/test/error/DevServerErrorParser.test.ts index 7ed9d6b..e525267 100644 --- a/test/error/DevServerErrorParser.test.ts +++ b/test/error/DevServerErrorParser.test.ts @@ -98,6 +98,38 @@ Error: ENOENT: no such file or directory, open 'package.json' expect(result.suggestions).to.have.length.greaterThan(0); }); + it('should parse command not found errors', () => { + const stderr = ` +> my-app@1.0.0 dev +> vite --mode development + +sh: vite: command not found + `; + + const result = DevServerErrorParser.parseError(stderr, 127, null); + + expect(result.type).to.equal('missing-module'); + expect(result.title).to.equal('Dependencies Not Installed'); + expect(result.message).to.include('vite'); + expect(result.message).to.include('not found'); + expect(result.suggestions).to.have.length.greaterThan(0); + expect(result.suggestions.some((s) => s.includes('npm install'))).to.be.true; + }); + + it('should handle various command not found formats', () => { + const stderrFormats = [ + 'sh: vite: command not found', + 'bash: npm: command not found', + '/bin/sh: node: command not found', + ]; + + for (const stderr of stderrFormats) { + const result = DevServerErrorParser.parseError(stderr, 127, null); + expect(result.type).to.equal('missing-module'); + expect(result.title).to.equal('Dependencies Not Installed'); + } + }); + it('should handle unknown errors with fallback', () => { const stderr = ` Some random error that doesn't match any pattern @@ -174,48 +206,4 @@ npm ERR! code 1 } }); }); - - describe('shouldRetry', () => { - it('should not retry permanent errors', () => { - const permanentErrors = [ - { type: 'syntax-error' as const, title: '', message: '', stderrLines: [], suggestions: [] }, - { type: 'missing-module' as const, title: '', message: '', stderrLines: [], suggestions: [] }, - { type: 'file-not-found' as const, title: '', message: '', stderrLines: [], suggestions: [] }, - { type: 'permission-error' as const, title: '', message: '', stderrLines: [], suggestions: [] }, - ]; - - for (const error of permanentErrors) { - expect(DevServerErrorParser.shouldRetry(error)).to.be.false; - } - }); - - it('should retry transient errors', () => { - const transientErrors = [ - { type: 'port-conflict' as const, title: '', message: '', stderrLines: [], suggestions: [] }, - { type: 'unknown' as const, title: '', message: '', stderrLines: [], suggestions: [] }, - ]; - - for (const error of transientErrors) { - expect(DevServerErrorParser.shouldRetry(error)).to.be.true; - } - }); - }); - - describe('getSummary', () => { - it('should generate concise summary', () => { - const error = { - type: 'port-conflict' as const, - title: 'Port Already in Use', - message: 'Port 5173 is already in use', - stderrLines: [], - suggestions: [], - }; - - const summary = DevServerErrorParser.getSummary(error); - - expect(summary).to.equal('Port Already in Use: Port 5173 is already in use'); - expect(summary).to.not.include('suggestions'); - expect(summary).to.not.include('stderr'); - }); - }); }); diff --git a/test/error/ErrorHandler.test.ts b/test/error/ErrorHandler.test.ts deleted file mode 100644 index cfa4716..0000000 --- a/test/error/ErrorHandler.test.ts +++ /dev/null @@ -1,409 +0,0 @@ -/* - * Copyright 2026, Salesforce, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { expect } from 'chai'; -import { SfError } from '@salesforce/core'; -import { ErrorHandler, WebAppErrorCode } from '../../src/error/ErrorHandler.js'; - -describe('ErrorHandler', () => { - describe('Authentication Errors', () => { - it('should create token expired error with suggestions', () => { - const error = ErrorHandler.createTokenExpiredError('myorg'); - - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal(WebAppErrorCode.TOKEN_EXPIRED); - expect(error.message).to.include('expired'); - expect(error.message).to.include('myorg'); - expect(error.actions).to.have.lengthOf(3); - expect(error.actions?.[0]).to.include('sf org login web'); - }); - - it('should create org not found error with suggestions', () => { - const error = ErrorHandler.createOrgNotFoundError('testorg'); - - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal(WebAppErrorCode.ORG_NOT_FOUND); - expect(error.message).to.include('testorg'); - expect(error.message).to.include('not found'); - expect(error.actions).to.have.lengthOf(3); - expect(error.actions?.[0]).to.include('sf org list'); - }); - - it('should create auth failed error without details', () => { - const error = ErrorHandler.createAuthFailedError('myorg'); - - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal(WebAppErrorCode.AUTH_FAILED); - expect(error.message).to.include('Authentication failed'); - expect(error.message).to.include('myorg'); - expect(error.actions).to.have.lengthOf(3); - }); - - it('should create auth failed error with details', () => { - const error = ErrorHandler.createAuthFailedError('myorg', 'Invalid credentials'); - - expect(error).to.be.instanceOf(SfError); - expect(error.message).to.include('Invalid credentials'); - }); - - it('should create token refresh failed error', () => { - const error = ErrorHandler.createTokenRefreshFailedError('myorg'); - - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal(WebAppErrorCode.TOKEN_REFRESH_FAILED); - expect(error.message).to.include('refresh'); - expect(error.message).to.include('myorg'); - expect(error.actions?.[0]).to.include('Re-authenticate'); - }); - }); - - describe('Manifest Errors', () => { - it('should create manifest not found error', () => { - const error = ErrorHandler.createManifestNotFoundError(); - - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal(WebAppErrorCode.MANIFEST_NOT_FOUND); - expect(error.message).to.include('webapplication.json'); - expect(error.message).to.include('not found'); - expect(error.actions?.[0]).to.include('Create a webapplication.json'); - }); - - it('should create manifest validation error with multiple issues', () => { - const validationErrors = ['name is required', 'version must be semantic', 'outputDir is missing']; - - const error = ErrorHandler.createManifestValidationError(validationErrors); - - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal(WebAppErrorCode.MANIFEST_INVALID); - expect(error.message).to.include('validation failed'); - expect(error.message).to.include('name is required'); - expect(error.message).to.include('version must be semantic'); - expect(error.message).to.include('outputDir is missing'); - }); - - it('should create manifest parse error', () => { - const error = ErrorHandler.createManifestParseError('Unexpected token } in JSON at position 123'); - - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal(WebAppErrorCode.MANIFEST_PARSE_ERROR); - expect(error.message).to.include('Failed to parse'); - expect(error.message).to.include('Unexpected token'); - expect(error.actions?.[0]).to.include('JSON syntax errors'); - }); - }); - - describe('Dev Server Errors', () => { - it('should create dev server start failed error without error message', () => { - const error = ErrorHandler.createDevServerStartFailedError('npm run dev'); - - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal(WebAppErrorCode.DEV_SERVER_START_FAILED); - expect(error.message).to.include('failed to start'); - expect(error.message).to.include('npm run dev'); - expect(error.actions?.[1]).to.include('npm install'); - }); - - it('should create dev server start failed error with error message', () => { - const error = ErrorHandler.createDevServerStartFailedError('npm run dev', 'ENOENT: command not found'); - - expect(error).to.be.instanceOf(SfError); - expect(error.message).to.include('ENOENT'); - }); - - it('should create dev server timeout error', () => { - const error = ErrorHandler.createDevServerTimeoutError(30); - - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal(WebAppErrorCode.DEV_SERVER_TIMEOUT); - expect(error.message).to.include('30 seconds'); - expect(error.message).to.include('did not start'); - }); - - it('should create dev server crashed error with exit code', () => { - const error = ErrorHandler.createDevServerCrashedError(1, null); - - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal(WebAppErrorCode.DEV_SERVER_CRASHED); - expect(error.message).to.include('crashed'); - expect(error.message).to.include('exit code 1'); - }); - - it('should create dev server crashed error with signal', () => { - const error = ErrorHandler.createDevServerCrashedError(null, 'SIGKILL'); - - expect(error).to.be.instanceOf(SfError); - expect(error.message).to.include('signal SIGKILL'); - }); - - it('should create dev server command required error', () => { - const error = ErrorHandler.createDevServerCommandRequiredError(); - - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal(WebAppErrorCode.DEV_SERVER_COMMAND_REQUIRED); - expect(error.message).to.include('command or URL is required'); - expect(error.actions?.[0]).to.include('dev.command'); - }); - }); - - describe('Proxy Errors', () => { - it('should create port in use error with alternative suggestion', () => { - const error = ErrorHandler.createPortInUseError(4545); - - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal(WebAppErrorCode.PORT_IN_USE); - expect(error.message).to.include('4545'); - expect(error.message).to.include('already in use'); - expect(error.actions?.[0]).to.include('4546'); - }); - - it('should create proxy start failed error', () => { - const error = ErrorHandler.createProxyStartFailedError('EACCES: permission denied'); - - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal(WebAppErrorCode.PROXY_START_FAILED); - expect(error.message).to.include('Failed to start proxy'); - expect(error.message).to.include('EACCES'); - }); - - it('should create target unreachable error for Salesforce', () => { - const error = ErrorHandler.createTargetUnreachableError('https://myorg.salesforce.com', 'Connection timeout'); - - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal(WebAppErrorCode.TARGET_UNREACHABLE); - expect(error.message).to.include('Cannot reach'); - expect(error.message).to.include('salesforce.com'); - expect(error.message).to.include('Connection timeout'); - expect(error.actions?.[0]).to.include('internet connection'); - }); - - it('should create target unreachable error for dev server', () => { - const error = ErrorHandler.createTargetUnreachableError('http://localhost:5173'); - - expect(error).to.be.instanceOf(SfError); - expect(error.message).to.include('localhost:5173'); - expect(error.actions?.[0]).to.include('dev server is running'); - }); - }); - - describe('Network Errors', () => { - it('should create generic network error', () => { - const error = ErrorHandler.createNetworkError('API call'); - - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal(WebAppErrorCode.NETWORK_ERROR); - expect(error.message).to.include('Network error'); - expect(error.message).to.include('API call'); - expect(error.actions?.[0]).to.include('internet connection'); - }); - - it('should create network error with message', () => { - const error = ErrorHandler.createNetworkError('fetching data', 'DNS resolution failed'); - - expect(error).to.be.instanceOf(SfError); - expect(error.message).to.include('DNS resolution failed'); - }); - - it('should create connection refused error', () => { - const error = ErrorHandler.createConnectionRefusedError('http://localhost:3000'); - - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal(WebAppErrorCode.CONNECTION_REFUSED); - expect(error.message).to.include('Connection refused'); - expect(error.message).to.include('localhost:3000'); - }); - - it('should create timeout error', () => { - const error = ErrorHandler.createTimeoutError('HTTP request', 60); - - expect(error).to.be.instanceOf(SfError); - expect(error.name).to.equal(WebAppErrorCode.TIMEOUT_ERROR); - expect(error.message).to.include('timed out'); - expect(error.message).to.include('60 seconds'); - }); - }); - - describe('Security & Sanitization', () => { - it('should sanitize Bearer tokens from error messages', () => { - const message = 'Request failed with Authorization: Bearer abc123xyz456.def789ghi012_jkl345-mno678'; - const sanitized = ErrorHandler.sanitizeErrorMessage(message); - - expect(sanitized).to.not.include('abc123xyz456'); - expect(sanitized).to.include('Bearer [REDACTED]'); - }); - - it('should sanitize access tokens from error messages', () => { - const message = 'Error: access_token=00D5g000001ABC!ARsomeTokenHere123'; - const sanitized = ErrorHandler.sanitizeErrorMessage(message); - - expect(sanitized).to.not.include('00D5g000001ABC!ARsomeTokenHere123'); - expect(sanitized).to.include('access_token=[REDACTED]'); - }); - - it('should sanitize session IDs from error messages', () => { - const message = 'Session error: sid=00D5g000001sessionId123'; - const sanitized = ErrorHandler.sanitizeErrorMessage(message); - - expect(sanitized).to.not.include('00D5g000001sessionId123'); - expect(sanitized).to.include('sid=[REDACTED]'); - }); - - it('should sanitize passwords from error messages', () => { - const message = 'Login failed with password=mySecret123'; - const sanitized = ErrorHandler.sanitizeErrorMessage(message); - - expect(sanitized).to.not.include('mySecret123'); - expect(sanitized).to.include('password=[REDACTED]'); - }); - - it('should sanitize client secrets from error messages', () => { - const message = 'OAuth error: client_secret=superSecretKey456'; - const sanitized = ErrorHandler.sanitizeErrorMessage(message); - - expect(sanitized).to.not.include('superSecretKey456'); - expect(sanitized).to.include('client_secret=[REDACTED]'); - }); - - it('should sanitize refresh tokens from error messages', () => { - const message = 'Token refresh failed: refresh_token=5Aep861.abc_xyz-123'; - const sanitized = ErrorHandler.sanitizeErrorMessage(message); - - expect(sanitized).to.not.include('5Aep861.abc_xyz-123'); - expect(sanitized).to.include('refresh_token=[REDACTED]'); - }); - - it('should handle messages without sensitive data', () => { - const message = 'Connection timeout error'; - const sanitized = ErrorHandler.sanitizeErrorMessage(message); - - expect(sanitized).to.equal(message); - }); - - it('should sanitize multiple tokens in the same message', () => { - const message = 'Auth failed: Bearer abc123 and access_token=xyz789 with refresh_token=def456'; - const sanitized = ErrorHandler.sanitizeErrorMessage(message); - - expect(sanitized).to.not.include('abc123'); - expect(sanitized).to.not.include('xyz789'); - expect(sanitized).to.not.include('def456'); - expect(sanitized).to.include('[REDACTED]'); - }); - }); - - describe('Error Detection & Wrapping', () => { - it('should detect network errors by code', () => { - const error = new Error('Connection failed') as Error & { code: string }; - error.code = 'ECONNREFUSED'; - - expect(ErrorHandler.isNetworkError(error)).to.be.true; - }); - - it('should detect network errors by message', () => { - const error = new Error('Network timeout occurred'); - - expect(ErrorHandler.isNetworkError(error)).to.be.true; - }); - - it('should not detect non-network errors', () => { - const error = new Error('Invalid JSON syntax'); - - expect(ErrorHandler.isNetworkError(error)).to.be.false; - }); - - it('should wrap SfError without modification', () => { - const originalError = new SfError('Test error', 'TestError'); - const wrapped = ErrorHandler.wrapError(originalError, 'test context'); - - expect(wrapped).to.equal(originalError); - }); - - it('should wrap network errors with appropriate handler', () => { - const error = new Error('Connection refused') as Error & { code: string }; - error.code = 'ECONNREFUSED'; - - const wrapped = ErrorHandler.wrapError(error, 'API call'); - - expect(wrapped).to.be.instanceOf(SfError); - expect(wrapped.name).to.equal(WebAppErrorCode.NETWORK_ERROR); - expect(wrapped.message).to.include('API call'); - }); - - it('should wrap generic errors with context', () => { - const error = new Error('Something went wrong'); - const wrapped = ErrorHandler.wrapError(error, 'processing request'); - - expect(wrapped).to.be.instanceOf(SfError); - expect(wrapped.message).to.include('processing request'); - expect(wrapped.message).to.include('Something went wrong'); - }); - - it('should handle non-Error objects', () => { - const wrapped = ErrorHandler.wrapError('string error', 'test operation'); - - expect(wrapped).to.be.instanceOf(SfError); - expect(wrapped.message).to.include('test operation'); - expect(wrapped.message).to.include('string error'); - }); - - it('should sanitize wrapped error messages', () => { - const error = new Error('Failed with Bearer abc123xyz456'); - const wrapped = ErrorHandler.wrapError(error, 'authentication'); - - expect(wrapped.message).to.not.include('abc123xyz456'); - expect(wrapped.message).to.include('[REDACTED]'); - }); - }); - - describe('Error Codes', () => { - it('should use consistent error codes', () => { - expect(WebAppErrorCode.TOKEN_EXPIRED).to.equal('TokenExpiredError'); - expect(WebAppErrorCode.ORG_NOT_FOUND).to.equal('OrgNotFoundError'); - expect(WebAppErrorCode.MANIFEST_NOT_FOUND).to.equal('ManifestNotFoundError'); - expect(WebAppErrorCode.DEV_SERVER_TIMEOUT).to.equal('DevServerTimeoutError'); - expect(WebAppErrorCode.PORT_IN_USE).to.equal('PortInUseError'); - expect(WebAppErrorCode.NETWORK_ERROR).to.equal('NetworkError'); - }); - }); - - describe('Action Suggestions', () => { - it('should provide actionable suggestions for all errors', () => { - const errors = [ - ErrorHandler.createTokenExpiredError('org'), - ErrorHandler.createOrgNotFoundError('org'), - ErrorHandler.createManifestNotFoundError(), - ErrorHandler.createDevServerTimeoutError(30), - ErrorHandler.createPortInUseError(4545), - ErrorHandler.createNetworkError('operation'), - ]; - - for (const error of errors) { - expect(error.actions).to.exist; - expect(error.actions).to.have.length.greaterThan(0); - // All actions should be strings - for (const action of error.actions ?? []) { - expect(action).to.be.a('string'); - expect(action.length).to.be.greaterThan(0); - } - } - }); - - it('should provide command suggestions in actions', () => { - const error = ErrorHandler.createTokenExpiredError('myorg'); - const actionsString = error.actions?.join(' ') ?? ''; - - expect(actionsString).to.include('sf org login web'); - }); - }); -}); diff --git a/test/proxy/ProxyServer.test.ts b/test/proxy/ProxyServer.test.ts index f02a133..916b61f 100644 --- a/test/proxy/ProxyServer.test.ts +++ b/test/proxy/ProxyServer.test.ts @@ -17,7 +17,6 @@ import { expect } from 'chai'; import { TestContext } from '@salesforce/core/testSetup'; import { ProxyServer } from '../../src/proxy/ProxyServer.js'; -import type { ProxyServerConfig } from '../../src/proxy/ProxyServer.js'; describe('ProxyServer', () => { const $$ = new TestContext(); @@ -32,102 +31,24 @@ describe('ProxyServer', () => { describe('Construction', () => { it('should create proxy server with valid configuration', () => { - const config: ProxyServerConfig = { + const proxy = new ProxyServer({ port: 4545, devServerUrl: 'http://localhost:5173', salesforceInstanceUrl: 'https://test.salesforce.com', - }; - - const proxy = new ProxyServer(config); + }); expect(proxy).to.be.instanceOf(ProxyServer); - expect(proxy.isRunning()).to.be.false; - }); - - it('should initialize statistics', () => { - const config: ProxyServerConfig = { - port: 4545, - devServerUrl: 'http://localhost:5173', - salesforceInstanceUrl: 'https://test.salesforce.com', - }; - - const proxy = new ProxyServer(config); - const stats = proxy.getStats(); - - expect(stats.requestCount).to.equal(0); - expect(stats.salesforceRequests).to.equal(0); - expect(stats.devServerRequests).to.equal(0); - expect(stats.webSocketUpgrades).to.equal(0); - expect(stats.errors).to.equal(0); - expect(stats.startTime).to.be.instanceOf(Date); - }); - }); - - describe('Code Builder Detection', () => { - it('should detect Code Builder environment from SBQQ_STUDIO_WORKSPACE', () => { - process.env.SBQQ_STUDIO_WORKSPACE = '/workspace'; - - const config: ProxyServerConfig = { - port: 4545, - devServerUrl: 'http://localhost:5173', - salesforceInstanceUrl: 'https://test.salesforce.com', - }; - - const proxy = new ProxyServer(config); - - expect(proxy.isCodeBuilderEnvironment()).to.be.true; - }); - - it('should detect Code Builder environment from SALESFORCE_PROJECT_ID', () => { - process.env.SALESFORCE_PROJECT_ID = 'project-123'; - - const config: ProxyServerConfig = { - port: 4545, - devServerUrl: 'http://localhost:5173', - salesforceInstanceUrl: 'https://test.salesforce.com', - }; - - const proxy = new ProxyServer(config); - - expect(proxy.isCodeBuilderEnvironment()).to.be.true; - }); - - it('should detect Code Builder environment from CODE_BUILDER_SESSION', () => { - process.env.CODE_BUILDER_SESSION = 'session-456'; - - const config: ProxyServerConfig = { - port: 4545, - devServerUrl: 'http://localhost:5173', - salesforceInstanceUrl: 'https://test.salesforce.com', - }; - - const proxy = new ProxyServer(config); - - expect(proxy.isCodeBuilderEnvironment()).to.be.true; - }); - - it('should not detect Code Builder in normal environment', () => { - const config: ProxyServerConfig = { - port: 4545, - devServerUrl: 'http://localhost:5173', - salesforceInstanceUrl: 'https://test.salesforce.com', - }; - - const proxy = new ProxyServer(config); - - expect(proxy.isCodeBuilderEnvironment()).to.be.false; }); }); describe('Network Interface Configuration', () => { it('should use localhost (127.0.0.1) by default in normal environment', () => { - const config: ProxyServerConfig = { + const proxy = new ProxyServer({ port: 4545, devServerUrl: 'http://localhost:5173', salesforceInstanceUrl: 'https://test.salesforce.com', - }; + }); - const proxy = new ProxyServer(config); const url = proxy.getProxyUrl(); expect(url).to.include('localhost'); @@ -137,29 +58,26 @@ describe('ProxyServer', () => { it('should use 0.0.0.0 in Code Builder environment', () => { process.env.CODE_BUILDER_SESSION = 'session-123'; - const config: ProxyServerConfig = { + const proxy = new ProxyServer({ port: 4545, devServerUrl: 'http://localhost:5173', salesforceInstanceUrl: 'https://test.salesforce.com', - }; + }); - const proxy = new ProxyServer(config); const url = proxy.getProxyUrl(); // Display URL should use localhost even when bound to 0.0.0.0 expect(url).to.equal('http://localhost:4545'); - expect(proxy.isCodeBuilderEnvironment()).to.be.true; }); it('should respect explicit host configuration', () => { - const config: ProxyServerConfig = { + const proxy = new ProxyServer({ port: 4545, devServerUrl: 'http://localhost:5173', salesforceInstanceUrl: 'https://test.salesforce.com', host: '192.168.1.100', - }; + }); - const proxy = new ProxyServer(config); const url = proxy.getProxyUrl(); expect(url).to.equal('http://192.168.1.100:4545'); @@ -168,14 +86,13 @@ describe('ProxyServer', () => { it('should override Code Builder host with explicit configuration', () => { process.env.CODE_BUILDER_SESSION = 'session-123'; - const config: ProxyServerConfig = { + const proxy = new ProxyServer({ port: 4545, devServerUrl: 'http://localhost:5173', salesforceInstanceUrl: 'https://test.salesforce.com', host: '127.0.0.1', - }; + }); - const proxy = new ProxyServer(config); const url = proxy.getProxyUrl(); // Even explicit 127.0.0.1 is displayed as localhost for consistency @@ -184,71 +101,22 @@ describe('ProxyServer', () => { }); describe('Server Lifecycle', () => { - it('should report not running initially', () => { - const config: ProxyServerConfig = { - port: 4545, - devServerUrl: 'http://localhost:5173', - salesforceInstanceUrl: 'https://test.salesforce.com', - }; - - const proxy = new ProxyServer(config); - - expect(proxy.isRunning()).to.be.false; - }); - it('should generate correct proxy URL', () => { - const config: ProxyServerConfig = { + const proxy = new ProxyServer({ port: 8080, devServerUrl: 'http://localhost:5173', salesforceInstanceUrl: 'https://test.salesforce.com', - }; + }); - const proxy = new ProxyServer(config); const url = proxy.getProxyUrl(); expect(url).to.equal('http://localhost:8080'); }); }); - describe('Statistics Tracking', () => { - it('should track request statistics', () => { - const config: ProxyServerConfig = { - port: 4545, - devServerUrl: 'http://localhost:5173', - salesforceInstanceUrl: 'https://test.salesforce.com', - }; - - const proxy = new ProxyServer(config); - const stats = proxy.getStats(); - - expect(stats).to.have.property('requestCount'); - expect(stats).to.have.property('salesforceRequests'); - expect(stats).to.have.property('devServerRequests'); - expect(stats).to.have.property('webSocketUpgrades'); - expect(stats).to.have.property('errors'); - expect(stats).to.have.property('startTime'); - }); - - it('should return a copy of stats (immutable)', () => { - const config: ProxyServerConfig = { - port: 4545, - devServerUrl: 'http://localhost:5173', - salesforceInstanceUrl: 'https://test.salesforce.com', - }; - - const proxy = new ProxyServer(config); - const stats1 = proxy.getStats(); - stats1.requestCount = 999; - - const stats2 = proxy.getStats(); - - expect(stats2.requestCount).to.equal(0); - }); - }); - describe('Configuration Validation', () => { it('should accept manifest configuration', () => { - const config: ProxyServerConfig = { + const proxy = new ProxyServer({ port: 4545, devServerUrl: 'http://localhost:5173', salesforceInstanceUrl: 'https://test.salesforce.com', @@ -258,53 +126,43 @@ describe('ProxyServer', () => { version: '1.0.0', outputDir: 'dist', }, - }; - - const proxy = new ProxyServer(config); + }); expect(proxy).to.be.instanceOf(ProxyServer); }); it('should accept org alias configuration', () => { - const config: ProxyServerConfig = { + const proxy = new ProxyServer({ port: 4545, devServerUrl: 'http://localhost:5173', salesforceInstanceUrl: 'https://test.salesforce.com', orgAlias: 'my-org', - }; - - const proxy = new ProxyServer(config); + }); expect(proxy).to.be.instanceOf(ProxyServer); }); it('should work with minimal configuration', () => { - const config: ProxyServerConfig = { + const proxy = new ProxyServer({ port: 4545, devServerUrl: 'http://localhost:5173', salesforceInstanceUrl: 'https://test.salesforce.com', - }; - - const proxy = new ProxyServer(config); + }); expect(proxy).to.be.instanceOf(ProxyServer); expect(proxy.getProxyUrl()).to.be.a('string'); - expect(proxy.getStats()).to.be.an('object'); }); }); describe('Multiple Environment Scenarios', () => { it('should handle VSCode local development', () => { // No Code Builder env vars - const config: ProxyServerConfig = { + const proxy = new ProxyServer({ port: 4545, devServerUrl: 'http://localhost:5173', salesforceInstanceUrl: 'https://test.salesforce.com', - }; - - const proxy = new ProxyServer(config); + }); - expect(proxy.isCodeBuilderEnvironment()).to.be.false; expect(proxy.getProxyUrl()).to.equal('http://localhost:4545'); }); @@ -312,27 +170,22 @@ describe('ProxyServer', () => { process.env.SBQQ_STUDIO_WORKSPACE = '/workspace'; process.env.SALESFORCE_PROJECT_ID = 'project-123'; - const config: ProxyServerConfig = { + const proxy = new ProxyServer({ port: 4545, devServerUrl: 'http://localhost:5173', salesforceInstanceUrl: 'https://test.salesforce.com', - }; - - const proxy = new ProxyServer(config); + }); - expect(proxy.isCodeBuilderEnvironment()).to.be.true; expect(proxy.getProxyUrl()).to.equal('http://localhost:4545'); }); it('should handle custom network configuration', () => { - const config: ProxyServerConfig = { + const proxy = new ProxyServer({ port: 3000, devServerUrl: 'http://localhost:8080', salesforceInstanceUrl: 'https://custom.salesforce.com', host: '0.0.0.0', - }; - - const proxy = new ProxyServer(config); + }); expect(proxy.getProxyUrl()).to.equal('http://localhost:3000'); }); @@ -340,13 +193,11 @@ describe('ProxyServer', () => { describe('Dynamic Configuration Updates', () => { it('should update dev server URL', () => { - const config: ProxyServerConfig = { + const proxy = new ProxyServer({ port: 4545, devServerUrl: 'http://localhost:5173', salesforceInstanceUrl: 'https://test.salesforce.com', - }; - - const proxy = new ProxyServer(config); + }); // Update dev server URL proxy.updateDevServerUrl('http://localhost:5174'); @@ -357,13 +208,11 @@ describe('ProxyServer', () => { }); it('should not update if URL is the same', () => { - const config: ProxyServerConfig = { + const proxy = new ProxyServer({ port: 4545, devServerUrl: 'http://localhost:5173', salesforceInstanceUrl: 'https://test.salesforce.com', - }; - - const proxy = new ProxyServer(config); + }); // Update with same URL (should be a no-op) proxy.updateDevServerUrl('http://localhost:5173'); @@ -372,7 +221,7 @@ describe('ProxyServer', () => { }); it('should update manifest configuration', () => { - const config: ProxyServerConfig = { + const proxy = new ProxyServer({ port: 4545, devServerUrl: 'http://localhost:5173', salesforceInstanceUrl: 'https://test.salesforce.com', @@ -382,9 +231,7 @@ describe('ProxyServer', () => { version: '1.0.0', outputDir: 'dist', }, - }; - - const proxy = new ProxyServer(config); + }); // Update manifest with routing config proxy.updateManifest({ @@ -402,17 +249,14 @@ describe('ProxyServer', () => { }); describe('Dev Server Error State', () => { - it('should track active dev server error', () => { - const config: ProxyServerConfig = { + it('should set and clear active dev server error', () => { + const proxy = new ProxyServer({ port: 4545, devServerUrl: 'http://localhost:5173', salesforceInstanceUrl: 'https://test.salesforce.com', - }; - - const proxy = new ProxyServer(config); - - expect(proxy.hasActiveDevServerError()).to.be.false; + }); + // Set error proxy.setActiveDevServerError({ type: 'port-conflict', title: 'Port Conflict', @@ -421,11 +265,10 @@ describe('ProxyServer', () => { suggestions: ['Stop other dev servers'], }); - expect(proxy.hasActiveDevServerError()).to.be.true; - + // Clear error proxy.clearActiveDevServerError(); - expect(proxy.hasActiveDevServerError()).to.be.false; + expect(proxy).to.be.instanceOf(ProxyServer); }); }); }); diff --git a/test/server/DevServerManager.test.ts b/test/server/DevServerManager.test.ts index 7922972..a253b62 100644 --- a/test/server/DevServerManager.test.ts +++ b/test/server/DevServerManager.test.ts @@ -17,7 +17,6 @@ import { expect } from 'chai'; import { SfError } from '@salesforce/core'; import { DevServerManager } from '../../src/server/DevServerManager.js'; -import type { DevServerStatus } from '../../src/config/types.js'; describe('DevServerManager', () => { let manager: DevServerManager | null = null; @@ -46,11 +45,6 @@ describe('DevServerManager', () => { manager.on('ready', (url: string) => { try { expect(url).to.equal('http://localhost:5173'); - expect(manager?.getUrl()).to.equal('http://localhost:5173'); - const status: DevServerStatus | undefined = manager?.getStatus(); - expect(status?.running).to.be.true; - expect(status?.url).to.equal('http://localhost:5173'); - expect(status?.pid).to.be.undefined; // No process spawned done(); } catch (error) { done(error); @@ -60,17 +54,14 @@ describe('DevServerManager', () => { void manager.start(); }); - it('should return ready status immediately with explicit URL', (done) => { + it('should emit ready event immediately with explicit URL', (done) => { manager = new DevServerManager({ explicitUrl: 'http://localhost:3000', }); - manager.on('ready', () => { + manager.on('ready', (url: string) => { try { - const status: DevServerStatus | undefined = manager?.getStatus(); - expect(status?.running).to.be.true; - expect(status?.url).to.equal('http://localhost:3000'); - expect(status?.pid).to.be.undefined; // No process spawned + expect(url).to.equal('http://localhost:3000'); done(); } catch (error) { done(error); @@ -235,7 +226,7 @@ describe('DevServerManager', () => { describe.skip('Process Management', () => { // Skipped: These tests spawn real processes which can cause timing issues in CI/CD - it('should get status with URL and pid when running', function (done) { + it('should emit ready event with URL when process outputs URL', function (done) { this.timeout(3000); manager = new DevServerManager({ @@ -246,13 +237,10 @@ describe('DevServerManager', () => { const timeout = setTimeout(() => done(new Error('Test timeout')), 2000); - manager.on('ready', () => { + manager.on('ready', (url: string) => { clearTimeout(timeout); try { - const status: DevServerStatus | undefined = manager?.getStatus(); - expect(status?.running).to.be.true; - expect(status?.url).to.equal('http://localhost:5555'); - expect(status?.pid).to.be.a('number'); + expect(url).to.equal('http://localhost:5555'); done(); } catch (error) { done(error); @@ -273,9 +261,6 @@ describe('DevServerManager', () => { manager.on('ready', (url: string) => { try { expect(url).to.equal('http://localhost:9999'); - // Process should not be spawned - const status: DevServerStatus | undefined = manager?.getStatus(); - expect(status?.pid).to.be.undefined; done(); } catch (error) { done(error); @@ -353,11 +338,8 @@ describe('DevServerManager', () => { setTimeout(resolve, 100); }); - // Stop the manager + // Stop the manager - should not throw await manager.stop(); - - const status: DevServerStatus = manager.getStatus(); - expect(status.running).to.be.false; }); it('should emit exit event when process stops', async function () {