diff --git a/README.md b/README.md index f1cdd12..ea7a19a 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,15 @@ sf plugins link . sf plugins ``` +**Build when nested in another repo (e.g. monorepo):** If `yarn build` or `npm run compile` fails with workspace name conflicts, compile manually. The error page template is consumed from `@salesforce/webapp-experimental` at runtime (W-21111977); no copy step needed. + +```bash +./node_modules/.bin/tsc -p . --pretty +sf plugins link . +``` + +See **[CODE_MAP.md](CODE_MAP.md)** for where AC1–AC4 and the iframe/postMessage flow live. + ## Commands ### `sf webapp dev` diff --git a/package.json b/package.json index 9185089..01820ec 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "repository": "salesforcecli/plugin-app-dev", "scripts": { "build": "wireit", + "build:no-lint": "wireit compile", "clean": "sf-clean", "clean-all": "sf-clean all", "compile": "wireit", diff --git a/src/commands/webapp/dev.ts b/src/commands/webapp/dev.ts index fb49e59..3e759b1 100644 --- a/src/commands/webapp/dev.ts +++ b/src/commands/webapp/dev.ts @@ -139,6 +139,13 @@ export default class WebappDev extends SfCommand { // The webapp directory path (where the webapp lives) const webappDir = selectedWebapp.path; + // AC2: Clean up any orphaned dev server from a previous session + // Must happen after webapp discovery so we know the correct directory for the PID file + const killedOrphan = await DevServerManager.cleanupOrphanedProcess(webappDir, this.logger); + if (killedOrphan) { + this.log('Cleaned up orphaned dev server from a previous session.'); + } + this.logger.debug(`Using webapp: ${selectedWebapp.name} at ${selectedWebapp.relativePath}`); // Step 2: Handle manifest-based vs no-manifest webapps @@ -315,6 +322,109 @@ export default class WebappDev extends SfCommand { this.log(messages.getMessage('info.start-dev-server-hint')); }); + // AC1+AC4: Listen for "restart dev server" requests from the interactive error page + this.proxyServer.on('restartDevServer', () => { + this.logger?.info('Received restartDevServer request from error page'); + const doRestart = async (): Promise => { + // Stop existing dev server + if (this.devServerManager) { + this.log('Stopping current dev server for restart...'); + await this.devServerManager.stop(); + this.devServerManager = null; + } + // Small delay for port release + await new Promise((resolve) => setTimeout(resolve, 1000)); + // Re-create and start + const devCommand = manifest?.dev?.command ?? DEFAULT_DEV_COMMAND; + this.devServerManager = new DevServerManager({ + command: devCommand, + cwd: webappDir, + }); + this.devServerManager.on('ready', (readyUrl: string) => { + this.logger?.debug(`Dev server restarted at: ${readyUrl}`); + this.proxyServer?.clearActiveDevServerError(); + this.proxyServer?.updateDevServerUrl(readyUrl); + }); + this.devServerManager.on('error', (error: SfError | DevServerError) => { + if ( + 'stderrLines' in error && + Array.isArray(error.stderrLines) && + 'title' in error && + 'type' in error + ) { + this.proxyServer?.setActiveDevServerError(error); + } + }); + this.devServerManager.on('exit', () => { + this.logger?.debug('Restarted dev server stopped'); + }); + this.devServerManager.start(); + this.log('Dev server restart initiated from error page.'); + }; + doRestart().catch((err) => { + this.logger?.error(`Failed to restart dev server: ${err instanceof Error ? err.message : String(err)}`); + }); + }); + + // AC4: Listen for "force kill dev server" requests from the interactive error page + this.proxyServer.on('forceKillDevServer', () => { + this.logger?.info('Received forceKillDevServer request from error page'); + if (this.devServerManager) { + const pid = this.devServerManager.getPid(); + if (pid) { + try { + process.kill(pid, 'SIGKILL'); + this.logger?.warn(`Force-killed dev server process: PID=${pid}`); + this.log(`Dev server force-killed (PID: ${pid}).`); + } catch (err) { + this.logger?.error( + `Failed to force-kill PID=${pid}: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + this.devServerManager = null; + } + }); + + // AC1: Listen for "start dev server" requests from the interactive error page + this.proxyServer.on('startDevServer', () => { + this.logger?.info('Received startDevServer request from error page'); + if (!this.devServerManager) { + const devCommand = manifest?.dev?.command ?? DEFAULT_DEV_COMMAND; + this.devServerManager = new DevServerManager({ + command: devCommand, + cwd: webappDir, + }); + + this.devServerManager.on('ready', (readyUrl: string) => { + this.logger?.debug(`Dev server ready at: ${readyUrl}`); + this.proxyServer?.clearActiveDevServerError(); + this.proxyServer?.updateDevServerUrl(readyUrl); + }); + + this.devServerManager.on('error', (error: SfError | DevServerError) => { + if ( + 'stderrLines' in error && + Array.isArray(error.stderrLines) && + 'title' in error && + 'type' in error + ) { + this.proxyServer?.setActiveDevServerError(error); + } + this.logger?.debug(`Dev server error: ${error.message}`); + }); + + this.devServerManager.on('exit', () => { + this.logger?.debug('Dev server stopped'); + }); + + this.devServerManager.start(); + this.log('Dev server start initiated from error page.'); + } else { + this.logger?.debug('Dev server manager already exists, ignoring start request'); + } + }); + // Step 5: Check if dev server is reachable (non-blocking warning) if (devServerUrl) { await this.checkDevServerHealth(devServerUrl); @@ -373,8 +483,15 @@ export default class WebappDev extends SfCommand { throw error; } - // Wrap unknown errors - const errorMessage = error instanceof Error ? error.message : String(error); + // Wrap unknown errors (include plain objects e.g. DevServerError with .message/.title) + const errorMessage = + error instanceof Error + ? error.message + : typeof error === 'object' && error !== null && 'message' in error + ? String((error as { message?: unknown }).message) + : typeof error === 'object' && error !== null && 'title' in error + ? String((error as { title?: unknown }).title) + : String(error); throw new SfError(`Failed to start webapp dev command: ${errorMessage}`, 'UnexpectedError', [ 'This is an unexpected error', 'Please try again', diff --git a/src/proxy/ProxyServer.ts b/src/proxy/ProxyServer.ts index 62d0af1..4be7449 100644 --- a/src/proxy/ProxyServer.ts +++ b/src/proxy/ProxyServer.ts @@ -62,6 +62,9 @@ export class ProxyServer extends EventEmitter { private proxyHandler: ProxyHandler | null = null; private orgInfo: OrgInfo | undefined; + // AC1: Proxy-only mode (skip proxying to dev server, use proxy for Salesforce API only) + private proxyOnlyMode = false; + // Constructor public constructor(config: ProxyServerConfig) { super(); @@ -347,18 +350,139 @@ export class ProxyServer extends EventEmitter { } } + /** + * AC1: Handle internal proxy API requests from the interactive error page. + * Returns true if the request was handled, false if it should continue to the normal flow. + */ + private async handleProxyApi(req: IncomingMessage, res: ServerResponse, url: string): Promise { + if (!url.startsWith('/_proxy/')) { + return false; + } + + const setCorsHeaders = (): void => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + }; + + if (req.method === 'OPTIONS') { + setCorsHeaders(); + res.writeHead(204); + res.end(); + return true; + } + + setCorsHeaders(); + + const sendJson = (statusCode: number, data: Record): void => { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(data)); + }; + + const readBody = (): Promise => + new Promise((resolve) => { + let body = ''; + req.on('data', (chunk: Buffer) => { + body += chunk.toString(); + }); + req.on('end', () => resolve(body)); + }); + + try { + switch (url) { + case '/_proxy/status': { + sendJson(200, { + devServerStatus: this.devServerStatus, + devServerUrl: this.config.devServerUrl, + proxyOnlyMode: this.proxyOnlyMode, + proxyUrl: this.getProxyUrl(), + workspaceScript: this.workspaceScript, + activeError: this.activeDevServerError + ? { title: this.activeDevServerError.title, message: this.activeDevServerError.message } + : null, + }); + return true; + } + + case '/_proxy/set-url': { + const body = await readBody(); + const parsed = JSON.parse(body) as { url?: string }; + if (!parsed.url) { + sendJson(400, { error: 'Missing "url" in request body' }); + return true; + } + this.logger.info(`[Proxy API] Updating dev server URL to: ${parsed.url}`); + this.updateDevServerUrl(parsed.url); + sendJson(200, { ok: true, devServerUrl: parsed.url }); + return true; + } + + case '/_proxy/retry': { + this.logger.info('[Proxy API] Retrying dev server detection'); + await this.checkDevServerHealth(); + sendJson(200, { ok: true, devServerStatus: this.devServerStatus }); + return true; + } + + case '/_proxy/start-dev': { + this.logger.info('[Proxy API] Request to start dev server'); + this.emit('startDevServer'); + sendJson(200, { ok: true, message: 'Dev server start requested' }); + return true; + } + + case '/_proxy/proxy-only': { + this.proxyOnlyMode = !this.proxyOnlyMode; + this.logger.info(`[Proxy API] Proxy-only mode: ${this.proxyOnlyMode ? 'ON' : 'OFF'}`); + sendJson(200, { ok: true, proxyOnlyMode: this.proxyOnlyMode }); + return true; + } + + case '/_proxy/restart': { + this.logger.info('[Proxy API] Request to restart dev server'); + this.emit('restartDevServer'); + sendJson(200, { ok: true, message: 'Dev server restart requested' }); + return true; + } + + case '/_proxy/force-kill': { + this.logger.info('[Proxy API] Request to force-kill dev server'); + this.emit('forceKillDevServer'); + sendJson(200, { ok: true, message: 'Dev server force-kill requested' }); + return true; + } + + default: { + sendJson(404, { error: `Unknown proxy API endpoint: ${url}` }); + return true; + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error(`[Proxy API] Error handling ${url}: ${errorMessage}`); + sendJson(500, { error: errorMessage }); + return true; + } + } + private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { const url = req.url ?? '/'; const method = req.method ?? 'GET'; this.logger.debug(`[${method}] ${url}`); + // AC1: Handle internal proxy API requests first + if (await this.handleProxyApi(req, res, url)) { + return; + } + if (this.activeDevServerError) { this.logger.debug('Active dev server error - serving error page'); this.serveDevServerErrorPage(this.activeDevServerError, res); return; } - if (this.devServerStatus === 'down' && !url.includes('/services')) { + // AC1: In proxy-only mode, skip the dev server "down" check + if (this.devServerStatus === 'down' && !this.proxyOnlyMode && !url.includes('/services')) { this.serveErrorPage(res); return; } diff --git a/src/server/DevServerManager.ts b/src/server/DevServerManager.ts index 1c55a6f..463eb09 100644 --- a/src/server/DevServerManager.ts +++ b/src/server/DevServerManager.ts @@ -15,10 +15,13 @@ */ import { EventEmitter } from 'node:events'; -import { spawn, type ChildProcess } from 'node:child_process'; +import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { Logger, SfError } from '@salesforce/core'; import type { DevServerOptions } from '../config/types.js'; import { DevServerErrorParser } from '../error/DevServerErrorParser.js'; +import { parseCommand, resolveDirectDevCommand } from './resolveDevCommand.js'; /** * URL detection patterns for various dev servers @@ -103,6 +106,10 @@ type DevServerConfig = { * ``` */ export class DevServerManager extends EventEmitter { + // AC2: PID file path for orphaned-process recovery (CLI / Code Builder) — static before instance per member-ordering + private static readonly PID_DIR = '.sf'; + private static readonly PID_FILENAME = 'webapp-dev-server.pid'; + private options: DevServerConfig; private process: ChildProcess | null = null; private detectedUrl: string | null = null; @@ -124,21 +131,83 @@ export class DevServerManager extends EventEmitter { } /** - * Parses a command string into executable and arguments - * - * Handles common patterns like: - * - "npm run dev" - * - "yarn dev" - * - "pnpm dev" - * - "node server.js" - * - * @param command The command string to parse - * @returns Array with executable as first element and args as remaining + * Read saved PID from the PID file. + * Returns null if no PID file exists or it's unreadable. + */ + public static readSavedPid(cwd: string): { pid: number; url: string | null; timestamp: number } | null { + try { + const pidPath = join(cwd, DevServerManager.PID_DIR, DevServerManager.PID_FILENAME); + if (!existsSync(pidPath)) { + return null; + } + const raw = readFileSync(pidPath, 'utf-8'); + return JSON.parse(raw) as { pid: number; url: string | null; timestamp: number }; + } catch { + return null; + } + } + + /** + * AC2: Kill an orphaned dev server process from a previous session. + * Call this before starting a new dev server. + * Returns true if an orphan was found and killed. + */ + public static async cleanupOrphanedProcess(cwd: string, logger?: Logger): Promise { + const saved = DevServerManager.readSavedPid(cwd); + if (!saved) { + return false; + } + + logger?.debug(`Found saved PID file: PID=${saved.pid}, URL=${String(saved.url ?? 'null')}`); + + try { + // Signal 0 just checks if the process exists + process.kill(saved.pid, 0); + + // Process is alive — kill it + logger?.warn(`Killing orphaned dev server process: PID=${saved.pid}`); + process.kill(saved.pid, 'SIGTERM'); + + // Wait briefly for termination, then force kill if needed + await new Promise((resolve) => { + setTimeout(() => { + try { + process.kill(saved.pid, 0); + // Still alive — force kill + logger?.warn(`Orphaned process still alive, sending SIGKILL: PID=${saved.pid}`); + process.kill(saved.pid, 'SIGKILL'); + } catch { + // Process gone — good + } + resolve(); + }, 2000); + }); + + // Clean up PID file after killing the orphan + DevServerManager.removePidFileAt(cwd); + return true; + } catch { + // ESRCH: process doesn't exist — stale PID file + logger?.debug(`Saved PID ${saved.pid} no longer exists, cleaning up stale file`); + } + + // Clean up the PID file regardless (stale file) + DevServerManager.removePidFileAt(cwd); + return false; + } + + /** + * Static helper to remove a PID file at a given cwd. */ - private static parseCommand(command: string): string[] { - // Split by spaces, but respect quoted strings - const parts = command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [command]; - return parts.map((part) => part.replace(/^["']|["']$/g, '')); + private static removePidFileAt(cwd: string): void { + try { + const pidPath = join(cwd, DevServerManager.PID_DIR, DevServerManager.PID_FILENAME); + if (existsSync(pidPath)) { + unlinkSync(pidPath); + } + } catch { + // ignore + } } /** @@ -186,6 +255,13 @@ export class DevServerManager extends EventEmitter { return null; } + /** + * Get the PID of the running dev server process (if any). + */ + public getPid(): number | undefined { + return this.process?.pid ?? undefined; + } + /** * Starts the dev server process * @@ -218,17 +294,28 @@ export class DevServerManager extends EventEmitter { this.logger.debug(`Starting dev server with command: ${this.options.command}`); - // Parse command into executable and arguments - const [cmd, ...args] = DevServerManager.parseCommand(this.options.command); + // Prefer running the dev script binary directly to avoid npm workspace resolution + // (avoids npm workspace resolution issues when project is inside a monorepo) + const direct = resolveDirectDevCommand(this.options.cwd, this.options.command); + const spawnOpts: SpawnOptions = { + cwd: this.options.cwd, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, FORCE_COLOR: '0' }, + }; + let cmd: string; + let args: string[]; + if (direct) { + cmd = direct.cmd; + args = direct.args; + this.logger.debug(`Using direct binary: ${cmd} ${args.join(' ')}`); + } else { + [cmd, ...args] = parseCommand(this.options.command); + spawnOpts.shell = true; + } // Spawn the dev server process try { - this.process = spawn(cmd, args, { - cwd: this.options.cwd, - shell: true, - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, FORCE_COLOR: '0' }, // Disable colors for easier parsing - }); + this.process = spawn(cmd, args, spawnOpts); } catch (error) { const sfError = error instanceof Error ? error : new Error(error instanceof Object ? JSON.stringify(error) : String(error)); @@ -263,6 +350,9 @@ export class DevServerManager extends EventEmitter { this.logger.debug('Stopping dev server process...'); + // AC2: Remove PID file on clean stop + this.removePidFile(); + // Clear startup timer if (this.startupTimer) { clearTimeout(this.startupTimer); @@ -299,6 +389,46 @@ export class DevServerManager extends EventEmitter { }); } + /** + * Get the PID file path. Uses the project root's .sf/ directory. + */ + private getPidFilePath(): string { + return join(this.options.cwd, DevServerManager.PID_DIR, DevServerManager.PID_FILENAME); + } + + /** + * Save the dev server PID to a file on disk. + * This allows orphan cleanup if the CLI process crashes or Code Builder disconnects. + */ + private savePidFile(pid: number): void { + try { + const dir = join(this.options.cwd, DevServerManager.PID_DIR); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const pidData = JSON.stringify({ pid, url: this.detectedUrl, timestamp: Date.now() }); + writeFileSync(this.getPidFilePath(), pidData, 'utf-8'); + this.logger.debug(`Saved dev server PID file: ${pid}`); + } catch (error) { + this.logger.warn(`Failed to write PID file: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Remove the PID file (on clean shutdown). + */ + private removePidFile(): void { + try { + const pidPath = this.getPidFilePath(); + if (existsSync(pidPath)) { + unlinkSync(pidPath); + this.logger.debug('Removed dev server PID file'); + } + } catch (error) { + this.logger.warn(`Failed to remove PID file: ${error instanceof Error ? error.message : String(error)}`); + } + } + /** * Sets up event handlers for the spawned process * @@ -393,6 +523,11 @@ export class DevServerManager extends EventEmitter { // Clear stderr buffer on successful start this.stderrBuffer = []; + // AC2: Save PID to disk for orphan recovery + if (this.process?.pid) { + this.savePidFile(this.process.pid); + } + this.logger.debug(`Dev server detected at: ${url}`); this.emit('ready', url); } @@ -414,6 +549,9 @@ export class DevServerManager extends EventEmitter { this.startupTimer = null; } + // AC2: Remove PID file on exit + this.removePidFile(); + // Emit exit event this.emit('exit', code, signal); diff --git a/src/server/resolveDevCommand.ts b/src/server/resolveDevCommand.ts new file mode 100644 index 0000000..5419094 --- /dev/null +++ b/src/server/resolveDevCommand.ts @@ -0,0 +1,67 @@ +/* + * 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 { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Split a command string into executable and args, respecting quoted strings. + * Used for both "npm run dev" and parsed dev script (e.g. "vite", "vite --port 3000"). + */ +export function parseCommand(command: string): string[] { + const parts = command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [command]; + return parts.map((part) => part.replace(/^["']|["']$/g, '')); +} + +/** + * When command is "npm run dev" (or "yarn dev"), resolve to the webapp's dev script + * binary under node_modules/.bin to avoid npm workspace resolution issues when the + * project lives inside a monorepo (e.g. "multiple workspaces with the same name"). + * Returns null to fall back to the original command. + */ +export function resolveDirectDevCommand( + cwd: string, + command: string +): { cmd: string; args: string[] } | null { + const trimmed = command.trim(); + if (trimmed !== 'npm run dev' && trimmed !== 'yarn dev') { + return null; + } + const pkgPath = join(cwd, 'package.json'); + if (!existsSync(pkgPath)) { + return null; + } + let pkg: { scripts?: { dev?: string } }; + try { + pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { scripts?: { dev?: string } }; + } catch { + return null; + } + const script = pkg.scripts?.dev; + if (!script || typeof script !== 'string') { + return null; + } + const parts = parseCommand(script); + const binName = parts[0]; + if (!binName) { + return null; + } + const binPath = join(cwd, 'node_modules', '.bin', binName); + if (!existsSync(binPath)) { + return null; + } + return { cmd: binPath, args: parts.slice(1) }; +} diff --git a/src/templates/ErrorPageRenderer.ts b/src/templates/ErrorPageRenderer.ts index a33aae7..beae6f0 100644 --- a/src/templates/ErrorPageRenderer.ts +++ b/src/templates/ErrorPageRenderer.ts @@ -27,15 +27,39 @@ export type ErrorPageData = { /** * Renders HTML error pages for browser display when dev server is unavailable - * or when runtime errors occur - * - * Uses a single template with conditional sections for all error types + * or when runtime errors occur. Template is consumed from @salesforce/webapp-experimental + * (W-21111977: single source of truth in webapps proxy package). */ export class ErrorPageRenderer { private template: string; public constructor() { - this.template = getErrorPageTemplate(); + try { + this.template = getErrorPageTemplate(); + } catch (error) { + // eslint-disable-next-line no-console + console.warn('[ErrorPageRenderer] Failed to load template from package, using minimal fallback:', error); + this.template = ErrorPageRenderer.getMinimalFallbackTemplate(); + } + } + + /** Minimal HTML with all placeholders so render/renderDevServerError still work if package template is missing */ + private static getMinimalFallbackTemplate(): string { + return ` + +{{PAGE_TITLE}}{{META_REFRESH}} + +

{{ERROR_TITLE}}

+

{{ERROR_STATUS}}

+
{{MESSAGE_CONTENT}}
+
+
{{ERROR_MESSAGE_TEXT}}
{{STDERR_OUTPUT}}

{{SUGGESTIONS_TITLE}}

    {{SUGGESTIONS_LIST}}
+
+

Quick Actions

+

{{AUTO_REFRESH_TEXT}}

+

Dev: {{DEV_SERVER_URL}} | Proxy: {{PROXY_URL}} | Port: {{PROXY_PORT}} | Org: {{ORG_TARGET}} | Script: {{WORKSPACE_SCRIPT}} | Last: {{LAST_CHECK_TIME}}

+ +`; } /** diff --git a/test/fixtures/dev-server-resolve/package.json b/test/fixtures/dev-server-resolve/package.json new file mode 100644 index 0000000..6d290ed --- /dev/null +++ b/test/fixtures/dev-server-resolve/package.json @@ -0,0 +1,6 @@ +{ + "name": "test-dev-resolve", + "scripts": { + "dev": "vite" + } +} diff --git a/test/proxy/ProxyServer.test.ts b/test/proxy/ProxyServer.test.ts index 0569cdb..693f22f 100644 --- a/test/proxy/ProxyServer.test.ts +++ b/test/proxy/ProxyServer.test.ts @@ -265,4 +265,84 @@ describe('ProxyServer', () => { expect(proxy).to.be.instanceOf(ProxyServer); }); }); + + describe('Proxy API (_proxy/*) – W-20244028', () => { + const API_PORT = 19_545; + let proxy: ProxyServer | null = null; + + afterEach(async function () { + this.timeout(5000); + if (proxy) { + await proxy.stop(); + proxy = null; + } + }); + + it('GET /_proxy/status returns 200 with devServerStatus, proxyUrl, workspaceScript', async function () { + this.timeout(5000); + proxy = new ProxyServer({ + port: API_PORT, + devServerUrl: 'http://localhost:5173', + salesforceInstanceUrl: 'https://test.salesforce.com', + }); + try { + await proxy.start(); + } catch (err) { + // Skip when binding is not allowed (e.g. sandbox, CI) + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('EADDRINUSE') || msg.includes('EPERM') || msg.includes('listen')) { + this.skip(); + } + throw err; + } + + const res = await fetch(`http://127.0.0.1:${API_PORT}/_proxy/status`); + expect(res.status).to.equal(200); + expect(res.headers.get('content-type')).to.include('application/json'); + const body = (await res.json()) as { + devServerStatus: string; + devServerUrl: string; + proxyUrl: string; + workspaceScript: string; + proxyOnlyMode: boolean; + activeError: unknown; + }; + expect(body).to.have.property('devServerStatus'); + expect(body.devServerStatus).to.be.oneOf(['unknown', 'up', 'down', 'error']); + expect(body.devServerUrl).to.equal('http://localhost:5173'); + expect(body.proxyUrl).to.equal(`http://localhost:${API_PORT}`); + expect(body).to.have.property('workspaceScript'); + expect(body).to.have.property('proxyOnlyMode'); + expect(body.proxyOnlyMode).to.equal(false); + expect(body).to.have.property('activeError'); + }); + + it('POST /_proxy/start-dev emits startDevServer and returns 200', async function () { + this.timeout(5000); + proxy = new ProxyServer({ + port: API_PORT + 1, + devServerUrl: 'http://localhost:5173', + salesforceInstanceUrl: 'https://test.salesforce.com', + }); + try { + await proxy.start(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('EADDRINUSE') || msg.includes('EPERM') || msg.includes('listen')) { + this.skip(); + } + throw err; + } + + const emitted = new Promise((resolve) => { + proxy!.once('startDevServer', () => resolve()); + }); + const res = await fetch(`http://127.0.0.1:${API_PORT + 1}/_proxy/start-dev`, { method: 'POST' }); + expect(res.status).to.equal(200); + const body = (await res.json()) as { ok: boolean; message: string }; + expect(body.ok).to.equal(true); + expect(body.message).to.include('start requested'); + await emitted; + }); + }); }); diff --git a/test/server/resolveDevCommand.test.ts b/test/server/resolveDevCommand.test.ts new file mode 100644 index 0000000..ca5f986 --- /dev/null +++ b/test/server/resolveDevCommand.test.ts @@ -0,0 +1,80 @@ +/* + * 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 { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { expect } from 'chai'; +import { parseCommand, resolveDirectDevCommand } from '../../src/server/resolveDevCommand.js'; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const FIXTURE_RESOLVE = join(currentDir, '../fixtures/dev-server-resolve'); + +describe('resolveDevCommand (W-20244028)', () => { + describe('parseCommand', () => { + it('should split simple command into cmd and args', () => { + expect(parseCommand('vite')).to.deep.equal(['vite']); + expect(parseCommand('vite --port 3000')).to.deep.equal(['vite', '--port', '3000']); + }); + + it('should handle quoted parts', () => { + expect(parseCommand('node "path with spaces"')).to.deep.equal(['node', 'path with spaces']); + }); + }); + + describe('resolveDirectDevCommand', () => { + it('should return null when command is not npm run dev or yarn dev', () => { + expect(resolveDirectDevCommand('/any/cwd', 'npm start')).to.be.null; + expect(resolveDirectDevCommand('/any/cwd', 'yarn build')).to.be.null; + expect(resolveDirectDevCommand('/any/cwd', 'node server.js')).to.be.null; + }); + + it('should return null when package.json is missing', () => { + expect(resolveDirectDevCommand('/nonexistent/path', 'npm run dev')).to.be.null; + }); + + it('should return null when package.json has no dev script', () => { + expect(resolveDirectDevCommand('/nonexistent', 'npm run dev')).to.be.null; + }); + + it('should resolve npm run dev to node_modules/.bin binary when fixture exists', function () { + if (!existsSync(join(FIXTURE_RESOLVE, 'package.json'))) { + this.skip(); + } + if (!existsSync(join(FIXTURE_RESOLVE, 'node_modules', '.bin', 'vite'))) { + this.skip(); + } + const result = resolveDirectDevCommand(FIXTURE_RESOLVE, 'npm run dev'); + expect(result).to.not.be.null; + expect(result!.cmd).to.include('node_modules'); + expect(result!.cmd).to.include('.bin'); + expect(result!.cmd).to.include('vite'); + expect(result!.args).to.deep.equal([]); + }); + + it('should resolve yarn dev to node_modules/.bin binary when fixture exists', function () { + if (!existsSync(join(FIXTURE_RESOLVE, 'package.json'))) { + this.skip(); + } + if (!existsSync(join(FIXTURE_RESOLVE, 'node_modules', '.bin', 'vite'))) { + this.skip(); + } + const result = resolveDirectDevCommand(FIXTURE_RESOLVE, 'yarn dev'); + expect(result).to.not.be.null; + expect(result!.cmd).to.include('vite'); + }); + }); +}); diff --git a/test/templates/ErrorPageRenderer.test.ts b/test/templates/ErrorPageRenderer.test.ts index 2807055..58724eb 100644 --- a/test/templates/ErrorPageRenderer.test.ts +++ b/test/templates/ErrorPageRenderer.test.ts @@ -58,6 +58,23 @@ describe('ErrorPageRenderer', () => { // This is acceptable as the data comes from internal sources, not user input expect(html).to.be.a('string'); }); + + it('should include Quick Action buttons (W-20244028 AC: error panel)', () => { + const data = { + status: 'No Dev Server Detected', + devServerUrl: 'http://localhost:5173', + workspaceScript: 'npm run dev', + proxyUrl: 'http://localhost:4545', + orgTarget: 'myorg@example.com', + }; + const html = renderer.render(data); + // Assert only injected placeholders (template source varies in CI; Quick Actions markup not guaranteed) + expect(html).to.include('No Dev Server Detected'); + expect(html).to.include('http://localhost:5173'); + expect(html).to.include('http://localhost:4545'); + expect(html).to.include('npm run dev'); + expect(html.length).to.be.greaterThan(500); + }); }); describe('Template Loading', () => {