diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..d80deb8 --- /dev/null +++ b/.env.template @@ -0,0 +1,21 @@ +# ── NUT Credentials ────────────────────────────────────────────── +# Only ONE of the two options below is needed. +# +# Option A: AUTH_URL (simplest — recommended for getting started) +# 1. sf org login web --alias nut-org +# 2. sf org display -o nut-org --json | jq -r .result.sfdxAuthUrl +# 3. Paste the value below +# TESTKIT_AUTH_URL= + +# Option B: JWT (no token expiration — recommended for CI) +# TESTKIT_JWT_KEY= +# TESTKIT_JWT_CLIENT_ID= +# TESTKIT_HUB_USERNAME= +# TESTKIT_HUB_INSTANCE=https://login.salesforce.com + +# ── Optional ───────────────────────────────────────────────────── +# Path to sf CLI executable (defaults to global sf in PATH) +# TESTKIT_EXECUTABLE_PATH= + +# Prevent browser from opening during tests +OPEN_BROWSER=false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 04639c0..0b054b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,3 +24,4 @@ jobs: fail-fast: false with: os: ${{ matrix.os }} + retries: 3 diff --git a/.gitignore b/.gitignore index 2fbeb2c..62c329a 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ node_modules # -- # put files here you don't want cleaned with sf-clean +# local NUT credentials +.env + # os specific files .DS_Store .idea diff --git a/package.json b/package.json index ce77c40..f206611 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@salesforce/plugin-command-reference": "^3.1.77", "@types/http-proxy": "^1.17.14", "@types/micromatch": "^4.0.10", + "dotenv": "^17.3.1", "eslint-plugin-sf-plugin": "^1.20.33", "oclif": "^4.22.68", "ts-node": "^10.9.2", @@ -82,7 +83,9 @@ "prepack": "sf-prepack", "prepare": "sf-install", "test": "wireit", - "test:nuts": "nyc mocha \"**/*.nut.ts\" --slow 4500 --timeout 600000 --parallel", + "test:nuts": "mocha \"**/*.nut.ts\" --slow 4500 --timeout 300000 --parallel=false", + "test:nuts:local": "node -r dotenv/config ./node_modules/.bin/nyc mocha \"**/*.nut.ts\" --slow 4500 --timeout 300000 --parallel=false", + "test:nut:local": "node -r dotenv/config ./node_modules/.bin/nyc mocha --slow 4500 --timeout 300000", "test:only": "wireit", "version": "oclif readme" }, diff --git a/src/commands/webapp/dev.ts b/src/commands/webapp/dev.ts index 61ec49e..2cc476b 100644 --- a/src/commands/webapp/dev.ts +++ b/src/commands/webapp/dev.ts @@ -291,10 +291,14 @@ export default class WebappDev extends SfCommand { } else if (flags.url) { // User explicitly passed --url; assume server is already running at that URL // Fail immediately if unreachable (don't start dev server) - throw new SfError(messages.getMessage('error.dev-url-unreachable-with-flag', [resolvedUrl]), 'DevServerUrlError', [ - `Ensure your dev server is running at ${resolvedUrl}`, - 'Remove --url to use dev.command to start the server automatically', - ]); + throw new SfError( + messages.getMessage('error.dev-url-unreachable-with-flag', [resolvedUrl]), + 'DevServerUrlError', + [ + `Ensure your dev server is running at ${resolvedUrl}`, + 'Remove --url to use dev.command to start the server automatically', + ] + ); } else if (manifest?.dev?.url && !manifest?.dev?.command?.trim()) { // dev.url in manifest but no dev.command - don't start (we can't control the port) throw new SfError(messages.getMessage('error.dev-url-unreachable', [resolvedUrl]), 'DevServerUrlError', [ @@ -439,7 +443,9 @@ export default class WebappDev extends SfCommand { await this.proxyServer.start(); } catch (error) { const err = error as NodeJS.ErrnoException; - if (err.code === 'EADDRINUSE') { + const isAddrInUse = + err.code === 'EADDRINUSE' || (error instanceof SfError && error.name === 'PortInUseError'); + if (isAddrInUse) { if (portExplicitlyConfigured) { throw new SfError(messages.getMessage('error.port-in-use', [String(port)]), 'PortInUseError'); } diff --git a/src/error/DevServerErrorParser.ts b/src/error/DevServerErrorParser.ts index f2d380f..4651d99 100644 --- a/src/error/DevServerErrorParser.ts +++ b/src/error/DevServerErrorParser.ts @@ -132,11 +132,11 @@ const ERROR_PATTERNS: ErrorPattern[] = [ // Command not found - dependencies not installed { - pattern: /command not found|not recognized as.*command/i, + pattern: /command not found|not recognized as.*command|:\s+not found/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 cmdMatch = stderr.match(/(?:sh:|bash:)\s*(?:\d+:\s*)?(\S+):\s*(?:command )?not found/i); const command = cmdMatch?.[1] ?? 'required command'; return `Command '${command}' not found. Project dependencies may not be installed.`; }, diff --git a/test/commands/webapp/_cleanup.nut.ts b/test/commands/webapp/_cleanup.nut.ts new file mode 100644 index 0000000..be7920c --- /dev/null +++ b/test/commands/webapp/_cleanup.nut.ts @@ -0,0 +1,45 @@ +/* + * 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 { readdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Root-level cleanup: remove any test_session_* directories left behind. + * + * TestSession.clean() normally deletes these, but they can persist when: + * - rm fails (e.g. Windows file locks from spawned processes) + * - Process is killed before after() runs (timeout, SIGKILL) + * - TESTKIT_SAVE_ARTIFACTS is set + * + * This hook runs after all NUTs complete as a fallback. + */ +after(() => { + const cwd = process.cwd(); + try { + for (const name of readdirSync(cwd)) { + if (name.startsWith('test_session_')) { + try { + rmSync(join(cwd, name), { recursive: true, force: true }); + } catch { + /* ignore per-dir failures */ + } + } + } + } catch { + /* ignore */ + } +}); diff --git a/test/commands/webapp/dev.nut.ts b/test/commands/webapp/dev.nut.ts index 9f8913f..b1cc3eb 100644 --- a/test/commands/webapp/dev.nut.ts +++ b/test/commands/webapp/dev.nut.ts @@ -14,22 +14,31 @@ * limitations under the License. */ -import { writeFileSync, unlinkSync, existsSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; +import { + createProject, + createProjectWithWebapp, + createProjectWithMultipleWebapps, + createEmptyWebappsDir, + createWebappDirWithoutMeta, + writeManifest, + webappPath, + ensureSfCli, + authOrgViaUrl, +} from './helpers/webappProjectUtils.js'; -describe('webapp dev NUTs', () => { +/* ------------------------------------------------------------------ * + * Tier 1 — No Auth * + * * + * Validates flag-level parse errors that fire before any org or * + * filesystem interaction. No credentials needed; always runs. * + * ------------------------------------------------------------------ */ +describe('webapp dev NUTs — Tier 1 (no auth)', () => { let session: TestSession; - const testWebappJson = { - name: 'testWebApp', - label: 'Test Web App', - version: '1.0.0', - outputDir: 'dist', - dev: { - url: 'http://localhost:5173', - }, - }; before(async () => { session = await TestSession.create({ devhubAuthStrategy: 'NONE' }); @@ -37,40 +46,265 @@ describe('webapp dev NUTs', () => { after(async () => { await session?.clean(); - // Clean up any test webapplication.json files - const webappJsonPath = join(session?.dir ?? process.cwd(), 'webapplication.json'); - if (existsSync(webappJsonPath)) { - unlinkSync(webappJsonPath); - } }); - it('should fail without target-org flag', () => { - // Create webapplication.json for this test - const webappJsonPath = join(session.dir, 'webapplication.json'); - writeFileSync(webappJsonPath, JSON.stringify(testWebappJson, null, 2)); - - const result = execCmd('webapp dev --name testWebApp --json', { + // --target-org is declared as Flags.requiredOrg(). Running without it + // must fail at parse time with NoDefaultEnvError before any other logic. + it('should require --target-org', () => { + const result = execCmd('webapp dev --json', { ensureExitCode: 1, cwd: session.dir, }); expect(result.jsonOutput?.name).to.equal('NoDefaultEnvError'); expect(result.jsonOutput?.message).to.include('target-org'); + }); +}); + +/* ------------------------------------------------------------------ * + * Tier 2 — CLI Validation (with auth) * + * * + * Validates webapp discovery errors and URL resolution errors. * + * Auth is only needed so --target-org passes parsing; these tests * + * exercise local filesystem/network checks — no live org calls. * + * * + * Requires TESTKIT_AUTH_URL. Fails when absent (tests are mandatory). * + * ------------------------------------------------------------------ */ +describe('webapp dev NUTs — Tier 2 CLI validation', () => { + let session: TestSession; + let targetOrg: string; + + before(async () => { + if (!process.env.TESTKIT_AUTH_URL) { + throw new Error( + 'TESTKIT_AUTH_URL is required for Tier 2 tests. Set it in .env (local) or CI secrets (GitHub Actions).' + ); + } + + session = await TestSession.create({ devhubAuthStrategy: 'NONE' }); + ensureSfCli(); + targetOrg = authOrgViaUrl(); + }); + + after(async () => { + await session?.clean(); + }); + + // ── Discovery errors ────────────────────────────────────────── + + // Project has no webapplications folder at all → WebappNotFoundError. + it('should error when no webapp found (project only, no webapps)', () => { + const projectDir = createProject(session, 'noWebappProject'); + + const result = execCmd(`webapp dev --target-org ${targetOrg} --json`, { + ensureExitCode: 1, + cwd: projectDir, + }); + + expect(result.jsonOutput?.name).to.equal('WebappNotFoundError'); + }); + + // Project has webapp "realApp" but --name asks for "NonExistent" → WebappNameNotFoundError. + it('should error when --name does not match any webapp', () => { + const projectDir = createProjectWithWebapp(session, 'nameNotFound', 'realApp'); + + const result = execCmd(`webapp dev --name NonExistent --target-org ${targetOrg} --json`, { + ensureExitCode: 1, + cwd: projectDir, + }); + + expect(result.jsonOutput?.name).to.equal('WebappNameNotFoundError'); + }); + + // cwd is inside webapp "appA" but --name asks for "appB" → WebappNameConflictError. + // Discovery treats this as ambiguous intent and rejects it. + it('should error on --name conflict when inside a different webapp', () => { + const projectDir = createProjectWithWebapp(session, 'nameConflict', 'appA'); + execSync('sf webapp generate --name appB', { cwd: projectDir, stdio: 'pipe' }); + + const cwdInsideAppA = webappPath(projectDir, 'appA'); + + const result = execCmd(`webapp dev --name appB --target-org ${targetOrg} --json`, { + ensureExitCode: 1, + cwd: cwdInsideAppA, + }); + + expect(result.jsonOutput?.name).to.equal('WebappNameConflictError'); + }); + + // webapplications/ folder exists but is empty → WebappNotFoundError. + it('should error when webapplications folder is empty', () => { + const projectDir = createProject(session, 'emptyWebapps'); + createEmptyWebappsDir(projectDir); + + const result = execCmd(`webapp dev --target-org ${targetOrg} --json`, { + ensureExitCode: 1, + cwd: projectDir, + }); + + expect(result.jsonOutput?.name).to.equal('WebappNotFoundError'); + }); + + // webapplications/orphanApp/ exists but has no .webapplication-meta.xml → not a valid webapp. + it('should error when webapp dir has no .webapplication-meta.xml', () => { + const projectDir = createProject(session, 'noMeta'); + createWebappDirWithoutMeta(projectDir, 'orphanApp'); + + const result = execCmd(`webapp dev --target-org ${targetOrg} --json`, { + ensureExitCode: 1, + cwd: projectDir, + }); + + expect(result.jsonOutput?.name).to.equal('WebappNotFoundError'); + }); + + // ── Multiple webapps selection ──────────────────────────────── + + // Project has appA and appB. Using --name appA from project root selects + // that webapp and proceeds past discovery. Fails at DevServerUrlError + // (no dev server) — confirming named selection works with multiple webapps. + it('should use --name to select one webapp when multiple exist', () => { + const projectDir = createProjectWithMultipleWebapps(session, 'multiSelect', ['appA', 'appB']); + + writeManifest(projectDir, 'appA', { + dev: { url: 'http://localhost:5180' }, + }); + writeManifest(projectDir, 'appB', { + dev: { url: 'http://localhost:5181' }, + }); + + const result = execCmd(`webapp dev --name appA --target-org ${targetOrg} --json`, { + ensureExitCode: 1, + cwd: projectDir, + }); + + expect(result.jsonOutput?.name).to.equal('DevServerUrlError'); + }); + + // Project has appA and appB. Using --name appB selects the second webapp. + it('should use --name to select second webapp when multiple exist', () => { + const projectDir = createProjectWithMultipleWebapps(session, 'multiSelectB', ['appA', 'appB']); + + writeManifest(projectDir, 'appA', { + dev: { url: 'http://localhost:5182' }, + }); + writeManifest(projectDir, 'appB', { + dev: { url: 'http://localhost:5183' }, + }); + + const result = execCmd(`webapp dev --name appB --target-org ${targetOrg} --json`, { + ensureExitCode: 1, + cwd: projectDir, + }); + + expect(result.jsonOutput?.name).to.equal('DevServerUrlError'); + }); + + // ── Auto-selection ──────────────────────────────────────────── + + // When cwd is inside webapplications/myApp/, discovery auto-selects that + // webapp without --name. The command proceeds past discovery and fails at + // URL resolution (no dev server running) — confirming auto-select worked. + it('should auto-select webapp when run from inside its directory', () => { + const projectDir = createProjectWithWebapp(session, 'autoSelect', 'myApp'); - // Clean up - unlinkSync(webappJsonPath); - }); - - // Note: Additional error scenario tests (manifest validation, dev server config) - // require authenticated orgs, which may not be available in all CI/CD environments. - // These scenarios are covered by unit tests instead. - // - // Scenarios covered in unit tests: - // - Missing webapplication.json manifest (ManifestWatcher.test.ts) - // - Invalid webapplication.json schema (ManifestWatcher.test.ts) - // - Malformed JSON syntax (ManifestWatcher.test.ts) - // - Missing dev server config (DevServerManager.test.ts) - // - // For local testing with authenticated orgs, use manual validation scripts in: - // docs/manual-tests/test-webapp-dev-command.ts + writeManifest(projectDir, 'myApp', { + dev: { url: 'http://localhost:5179' }, + }); + + const cwdInsideApp = webappPath(projectDir, 'myApp'); + + // No --name flag; cwd is inside the webapp directory. + // Discovery auto-selects myApp, then the command fails at URL check + // (nothing running on 5179). DevServerUrlError proves discovery succeeded. + const result = execCmd(`webapp dev --target-org ${targetOrg} --json`, { + ensureExitCode: 1, + cwd: cwdInsideApp, + }); + + expect(result.jsonOutput?.name).to.equal('DevServerUrlError'); + }); + + // When multiple webapps exist and cwd is inside webapplications/appA/, + // discovery auto-selects appA without prompting. Proceeds past discovery + // and fails at URL resolution — confirming auto-select works with multiple. + it('should auto-select webapp when run from inside its directory (multiple webapps)', () => { + const projectDir = createProjectWithMultipleWebapps(session, 'autoSelectMulti', ['appA', 'appB']); + + writeManifest(projectDir, 'appA', { + dev: { url: 'http://localhost:5184' }, + }); + writeManifest(projectDir, 'appB', { + dev: { url: 'http://localhost:5185' }, + }); + + const cwdInsideAppA = webappPath(projectDir, 'appA'); + + const result = execCmd(`webapp dev --target-org ${targetOrg} --json`, { + ensureExitCode: 1, + cwd: cwdInsideAppA, + }); + + expect(result.jsonOutput?.name).to.equal('DevServerUrlError'); + }); + + // ── URL / dev server errors ─────────────────────────────────── + + // --url explicitly provided but nothing is listening → DevServerUrlError. + // The command refuses to start a dev server when --url is given. + it('should error when --url is unreachable', () => { + const projectDir = createProjectWithWebapp(session, 'urlUnreachable', 'myApp'); + + const result = execCmd(`webapp dev --name myApp --url http://localhost:5179 --target-org ${targetOrg} --json`, { + ensureExitCode: 1, + cwd: projectDir, + }); + + expect(result.jsonOutput?.name).to.equal('DevServerUrlError'); + }); + + // Manifest has dev.url but no dev.command → command can't start the server + // itself and the URL is unreachable → DevServerUrlError. + it('should error when dev.url is unreachable and no dev.command', () => { + const projectDir = createProjectWithWebapp(session, 'urlNoCmd', 'myApp'); + + writeManifest(projectDir, 'myApp', { + dev: { url: 'http://localhost:5179' }, + }); + + const result = execCmd(`webapp dev --name myApp --target-org ${targetOrg} --json`, { + ensureExitCode: 1, + cwd: projectDir, + }); + + expect(result.jsonOutput?.name).to.equal('DevServerUrlError'); + }); + + // ── Dev server startup errors ───────────────────────────────── + + // Webapp created but npm install never run → dev server fails because + // dependencies (e.g. vite) are not installed. The command should exit + // with a meaningful error that suggests installing dependencies. + // This mirrors the real user flow: generate → dev (without install). + it('should include a reason when dev server fails to start', () => { + const projectDir = createProjectWithWebapp(session, 'noInstall', 'myApp'); + const appDir = webappPath(projectDir, 'myApp'); + + writeFileSync( + join(appDir, 'package.json'), + JSON.stringify({ name: 'test-webapp', scripts: { dev: 'vite' } }) + ); + writeManifest(projectDir, 'myApp', { + dev: { command: 'npm run dev' }, + }); + + const result = execCmd(`webapp dev --name myApp --target-org ${targetOrg} --json`, { + ensureExitCode: 1, + cwd: projectDir, + }); + + expect(result.jsonOutput?.name).to.equal('DevServerError'); + const output = JSON.stringify(result.jsonOutput ?? {}); + expect(output).to.match(/Reason:\s*\S/); + }); }); diff --git a/test/commands/webapp/devPort.nut.ts b/test/commands/webapp/devPort.nut.ts new file mode 100644 index 0000000..5cbff70 --- /dev/null +++ b/test/commands/webapp/devPort.nut.ts @@ -0,0 +1,158 @@ +/* + * 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 type { Server } from 'node:net'; +import { TestSession } from '@salesforce/cli-plugins-testkit'; +import { expect } from 'chai'; +import { createProjectWithDevServer, ensureSfCli, authOrgViaUrl } from './helpers/webappProjectUtils.js'; +import { + occupyPort, + spawnWebappDev, + closeServer, + SUITE_TIMEOUT, + SPAWN_TIMEOUT, + SPAWN_FAIL_TIMEOUT, + type WebappDevHandle, +} from './helpers/devServerUtils.js'; + +/* ------------------------------------------------------------------ * + * Tier 2 — Port Handling * + * * + * Validates proxy port resolution logic: * + * - Explicit --port or dev.port occupied → PortInUseError * + * - Default port occupied → auto-increment to next available * + * - Explicit --port or dev.port available → proxy binds to it * + * * + * Requires TESTKIT_AUTH_URL. Fails when absent (tests are mandatory). * + * ------------------------------------------------------------------ */ + +const DEV_PORT = 18_910; +const PROXY_PORT = 18_920; + +describe('webapp dev NUTs — Tier 2 port handling', function () { + this.timeout(SUITE_TIMEOUT); + + let session: TestSession; + let targetOrg: string; + let blocker: Server | null = null; + let handle: WebappDevHandle | null = null; + + before(async () => { + if (!process.env.TESTKIT_AUTH_URL) { + throw new Error( + 'TESTKIT_AUTH_URL is required for Tier 2 tests. Set it in .env (local) or CI secrets (GitHub Actions).' + ); + } + + session = await TestSession.create({ devhubAuthStrategy: 'NONE' }); + ensureSfCli(); + targetOrg = authOrgViaUrl(); + }); + + afterEach(async () => { + if (handle) { + await handle.kill(); + handle = null; + } + await closeServer(blocker); + blocker = null; + }); + + after(async () => { + await session?.clean(); + }); + + // When --port is explicitly provided and that port is already occupied, + // the command must fail with PortInUseError (no auto-increment). + it('should throw PortInUseError when explicit --port is occupied', async () => { + blocker = await occupyPort(PROXY_PORT); + + const { projectDir } = createProjectWithDevServer(session, 'portConflict', 'myApp', DEV_PORT); + + try { + handle = await spawnWebappDev(['--name', 'myApp', '--port', String(PROXY_PORT), '--target-org', targetOrg], { + cwd: projectDir, + timeout: SPAWN_FAIL_TIMEOUT, + }); + expect.fail('Expected command to fail with PortInUseError'); + } catch (err) { + expect((err as Error).message).to.include('PortInUseError'); + } + }); + + // When dev.port is set in the manifest and that port is occupied, + // it is treated as an explicit configuration → PortInUseError (no auto-increment). + it('should throw PortInUseError when dev.port in manifest is occupied', async () => { + const { projectDir } = createProjectWithDevServer(session, 'manifestPort', 'myApp', DEV_PORT + 1, PROXY_PORT + 1); + + blocker = await occupyPort(PROXY_PORT + 1); + + try { + handle = await spawnWebappDev(['--name', 'myApp', '--target-org', targetOrg], { + cwd: projectDir, + timeout: SPAWN_FAIL_TIMEOUT, + }); + expect.fail('Expected command to fail with PortInUseError'); + } catch (err) { + expect((err as Error).message).to.include('PortInUseError'); + } + }); + + // When no --port or dev.port is configured, the default (4545) is used. + // If 4545 is occupied, the command silently tries the next port. + it('should auto-increment port when default port (4545) is occupied', async () => { + blocker = await occupyPort(4545); + + const { projectDir } = createProjectWithDevServer(session, 'portAutoInc', 'myApp', DEV_PORT + 2); + + handle = await spawnWebappDev(['--name', 'myApp', '--target-org', targetOrg], { + cwd: projectDir, + timeout: SPAWN_TIMEOUT, + }); + + const proxyPort = new URL(handle.proxyUrl).port; + expect(Number(proxyPort)).to.be.greaterThan(4545); + }); + + // --port flag with an available port → proxy binds exactly to that port. + it('should use custom --port when specified and available', async () => { + const customPort = PROXY_PORT + 5; + + const { projectDir } = createProjectWithDevServer(session, 'customPort', 'myApp', DEV_PORT + 3); + + handle = await spawnWebappDev(['--name', 'myApp', '--port', String(customPort), '--target-org', targetOrg], { + cwd: projectDir, + timeout: SPAWN_TIMEOUT, + }); + + expect(handle.proxyUrl).to.equal(`http://localhost:${customPort}`); + }); + + // dev.port in manifest with an available port (no --port flag) → + // proxy binds to the manifest-configured port. + it('should use dev.port from manifest when available', async () => { + const manifestPort = PROXY_PORT + 6; + + const { projectDir } = createProjectWithDevServer(session, 'manifestPortOk', 'myApp', DEV_PORT + 4, manifestPort); + + handle = await spawnWebappDev(['--name', 'myApp', '--target-org', targetOrg], { + cwd: projectDir, + timeout: SPAWN_TIMEOUT, + }); + + expect(handle.proxyUrl).to.equal(`http://localhost:${manifestPort}`); + }); +}); diff --git a/test/commands/webapp/devWithUrl.nut.ts b/test/commands/webapp/devWithUrl.nut.ts new file mode 100644 index 0000000..5c6d0a6 --- /dev/null +++ b/test/commands/webapp/devWithUrl.nut.ts @@ -0,0 +1,319 @@ +/* + * 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 type { Server as HttpServer } from 'node:http'; +import { TestSession } from '@salesforce/cli-plugins-testkit'; +import { expect } from 'chai'; +import { + createProjectWithDevServer, + createProjectWithWebapp, + writeManifest, + ensureSfCli, + authOrgViaUrl, +} from './helpers/webappProjectUtils.js'; +import { + spawnWebappDev, + startTestHttpServer, + startViteProxyServer, + closeServer, + SUITE_TIMEOUT, + SPAWN_TIMEOUT, + type WebappDevHandle, +} from './helpers/devServerUtils.js'; + +/* ------------------------------------------------------------------ * + * Tier 2 — URL / proxy integration tests * + * * + * All suites share a single TestSession (one auth call) and test * + * the three modes of dev server + proxy interaction: * + * 1. Full flow — dev.command starts server, standalone proxy boots * + * 2. Proxy-only — external server already running, proxy boots * + * 3. Vite proxy — Vite plugin handles proxy, standalone skipped * + * * + * Requires TESTKIT_AUTH_URL. Fails when absent (tests are mandatory). * + * ------------------------------------------------------------------ */ + +const FULL_FLOW_PORT = 18_900; +const PROXY_ONLY_PORT = 18_930; +const VITE_PORT = 18_940; + +describe('webapp dev NUTs — Tier 2 URL/proxy integration', function () { + this.timeout(SUITE_TIMEOUT); + + let session: TestSession; + let targetOrg: string; + let handle: WebappDevHandle | null = null; + let externalServer: HttpServer | null = null; + + before(async () => { + if (!process.env.TESTKIT_AUTH_URL) { + throw new Error( + 'TESTKIT_AUTH_URL is required for Tier 2 tests. Set it in .env (local) or CI secrets (GitHub Actions).' + ); + } + + session = await TestSession.create({ devhubAuthStrategy: 'NONE' }); + ensureSfCli(); + targetOrg = authOrgViaUrl(); + }); + + afterEach(async () => { + if (handle) { + await handle.kill(); + handle = null; + } + await closeServer(externalServer); + externalServer = null; + }); + + after(async () => { + await session?.clean(); + }); + + // ── Full flow (dev.command starts dev server) ──────────────────── + // Manifest has dev.command + dev.url. The command spawns the dev server, + // waits for it to become reachable, then starts the standalone proxy. + + describe('full flow', () => { + // Verifies the proxy starts and emits a localhost URL when dev.command + // successfully starts the dev server. + it('should start proxy when dev.command starts a dev server', async () => { + const { projectDir } = createProjectWithDevServer(session, 'fullFlow', 'myApp', FULL_FLOW_PORT); + + handle = await spawnWebappDev(['--name', 'myApp', '--target-org', targetOrg], { + cwd: projectDir, + timeout: SPAWN_TIMEOUT, + }); + + expect(handle.proxyUrl).to.be.a('string'); + expect(handle.proxyUrl).to.match(/^http:\/\/localhost:\d+$/); + }); + + // Verifies the proxy forwards requests to the dev server and returns + // the dev server's HTML content to the caller. + it('should serve proxied content from the dev server', async () => { + const { projectDir } = createProjectWithDevServer(session, 'proxyContent', 'myApp', FULL_FLOW_PORT + 1); + + handle = await spawnWebappDev(['--name', 'myApp', '--target-org', targetOrg], { + cwd: projectDir, + timeout: SPAWN_TIMEOUT, + }); + + const response = await fetch(handle.proxyUrl); + expect(response.status).to.equal(200); + + const body = await response.text(); + expect(body).to.include('Test Dev Server'); + }); + + // The command emits `{"url":"http://localhost:"}` on stderr + // as a machine-readable contract for IDE extensions to discover the proxy. + it('should emit JSON with proxy URL on stderr', async () => { + const { projectDir } = createProjectWithDevServer(session, 'jsonOutput', 'myApp', FULL_FLOW_PORT + 2); + + handle = await spawnWebappDev(['--name', 'myApp', '--target-org', targetOrg], { + cwd: projectDir, + timeout: SPAWN_TIMEOUT, + }); + + expect(handle.proxyUrl).to.match(/^http:\/\/localhost:\d+$/); + + const jsonLine = handle.stderr.split('\n').find((line) => { + try { + const parsed = JSON.parse(line.trim()) as Record; + return typeof parsed.url === 'string'; + } catch { + return false; + } + }); + expect(jsonLine).to.be.a('string'); + }); + + // When the manifest has no dev section (empty dev config), the command + // falls back to defaults: dev.command = "npm run dev", dev.url = localhost:5173. + // With a dev server already listening on 5173, the proxy should start. + it('should use defaults when manifest has empty dev config', async () => { + const defaultDevPort = 5173; + externalServer = await startTestHttpServer(defaultDevPort); + + const projectDir = createProjectWithWebapp(session, 'emptyManifest', 'myApp'); + writeManifest(projectDir, 'myApp', {}); + + handle = await spawnWebappDev(['--name', 'myApp', '--target-org', targetOrg], { + cwd: projectDir, + timeout: SPAWN_TIMEOUT, + }); + + expect(handle.proxyUrl).to.be.a('string'); + expect(handle.proxyUrl).to.match(/^http:\/\/localhost:\d+$/); + }); + }); + + // ── Proxy-only mode (external server already running) ──────────── + // The dev server is started externally (not by the command). The command + // only starts its standalone proxy pointing at the already-reachable URL. + + describe('proxy-only mode', () => { + // --url points to a running server → proxy boots on a different port, + // no dev server spawned by the command. + it('should start proxy when --url points to an already-running server', async () => { + externalServer = await startTestHttpServer(PROXY_ONLY_PORT); + + const projectDir = createProjectWithWebapp(session, 'proxyOnly', 'myApp'); + + handle = await spawnWebappDev( + ['--name', 'myApp', '--url', `http://localhost:${PROXY_ONLY_PORT}`, '--target-org', targetOrg], + { cwd: projectDir, timeout: SPAWN_TIMEOUT } + ); + + expect(handle.proxyUrl).to.be.a('string'); + expect(handle.proxyUrl).to.match(/^http:\/\/localhost:\d+$/); + const proxyPort = Number(new URL(handle.proxyUrl).port); + expect(proxyPort).to.not.equal(PROXY_ONLY_PORT); + }); + + // Verifies the proxy correctly forwards content from the external server. + it('should serve proxied content from the external server via --url', async () => { + externalServer = await startTestHttpServer(PROXY_ONLY_PORT + 1); + + const projectDir = createProjectWithWebapp(session, 'proxyOnlyContent', 'myApp'); + + handle = await spawnWebappDev( + ['--name', 'myApp', '--url', `http://localhost:${PROXY_ONLY_PORT + 1}`, '--target-org', targetOrg], + { cwd: projectDir, timeout: SPAWN_TIMEOUT } + ); + + const response = await fetch(handle.proxyUrl); + expect(response.status).to.equal(200); + + const body = await response.text(); + expect(body).to.include('Manual Dev Server'); + }); + + // dev.url in manifest is already reachable, no dev.command → + // command skips spawning a dev server and just starts the proxy. + it('should start proxy when dev.url in manifest is already reachable (no dev.command needed)', async () => { + externalServer = await startTestHttpServer(PROXY_ONLY_PORT + 2); + + const projectDir = createProjectWithWebapp(session, 'proxyOnlyManifest', 'myApp'); + writeManifest(projectDir, 'myApp', { + dev: { url: `http://localhost:${PROXY_ONLY_PORT + 2}` }, + }); + + handle = await spawnWebappDev(['--name', 'myApp', '--target-org', targetOrg], { + cwd: projectDir, + timeout: SPAWN_TIMEOUT, + }); + + expect(handle.proxyUrl).to.be.a('string'); + + const response = await fetch(handle.proxyUrl); + expect(response.status).to.equal(200); + + const body = await response.text(); + expect(body).to.include('Manual Dev Server'); + }); + + // Combines --url (external server) with --port (explicit proxy port). + // Verifies the proxy binds to the requested port in proxy-only mode. + it('should use custom --port with --url in proxy-only mode', async () => { + const customProxyPort = PROXY_ONLY_PORT + 10; + externalServer = await startTestHttpServer(PROXY_ONLY_PORT + 3); + + const projectDir = createProjectWithWebapp(session, 'proxyOnlyPort', 'myApp'); + + handle = await spawnWebappDev( + [ + '--name', 'myApp', + '--url', `http://localhost:${PROXY_ONLY_PORT + 3}`, + '--port', String(customProxyPort), + '--target-org', targetOrg, + ], + { cwd: projectDir, timeout: SPAWN_TIMEOUT } + ); + + expect(handle.proxyUrl).to.equal(`http://localhost:${customProxyPort}`); + + const response = await fetch(handle.proxyUrl); + expect(response.status).to.equal(200); + }); + }); + + // ── Vite proxy mode (dev server has built-in proxy) ────────────── + // When the dev server responds to ?sfProxyHealthCheck=true with the + // X-Salesforce-WebApp-Proxy header, the command skips the standalone + // proxy and uses the dev server URL directly. + + describe('Vite proxy mode', () => { + // Vite proxy header detected → emitted URL equals the dev server URL + // (standalone proxy is not started). + it('should skip standalone proxy when Vite proxy is detected', async () => { + externalServer = await startViteProxyServer(VITE_PORT); + + const projectDir = createProjectWithWebapp(session, 'viteProxy', 'myApp'); + writeManifest(projectDir, 'myApp', { + dev: { url: `http://localhost:${VITE_PORT}` }, + }); + + handle = await spawnWebappDev(['--name', 'myApp', '--target-org', targetOrg], { + cwd: projectDir, + timeout: SPAWN_TIMEOUT, + }); + + expect(handle.proxyUrl).to.equal(`http://localhost:${VITE_PORT}`); + }); + + // Verifies content is served from the Vite server without a proxy layer. + it('should serve content directly from Vite server (no standalone proxy)', async () => { + externalServer = await startViteProxyServer(VITE_PORT + 1); + + const projectDir = createProjectWithWebapp(session, 'viteContent', 'myApp'); + writeManifest(projectDir, 'myApp', { + dev: { url: `http://localhost:${VITE_PORT + 1}` }, + }); + + handle = await spawnWebappDev(['--name', 'myApp', '--target-org', targetOrg], { + cwd: projectDir, + timeout: SPAWN_TIMEOUT, + }); + + const response = await fetch(handle.proxyUrl); + expect(response.status).to.equal(200); + + const body = await response.text(); + expect(body).to.include('Vite Dev Server'); + }); + + // Server responds to health check but WITHOUT the X-Salesforce-WebApp-Proxy + // header → standalone proxy starts as usual (fallback path). + it('should start standalone proxy when server lacks Vite proxy header', async () => { + externalServer = await startTestHttpServer(VITE_PORT + 2); + + const projectDir = createProjectWithWebapp(session, 'noViteProxy', 'myApp'); + writeManifest(projectDir, 'myApp', { + dev: { url: `http://localhost:${VITE_PORT + 2}` }, + }); + + handle = await spawnWebappDev(['--name', 'myApp', '--target-org', targetOrg], { + cwd: projectDir, + timeout: SPAWN_TIMEOUT, + }); + + expect(handle.proxyUrl).to.not.equal(`http://localhost:${VITE_PORT + 2}`); + expect(handle.proxyUrl).to.match(/^http:\/\/localhost:\d+$/); + }); + }); +}); diff --git a/test/commands/webapp/helpers/devServerUtils.ts b/test/commands/webapp/helpers/devServerUtils.ts new file mode 100644 index 0000000..ce161cf --- /dev/null +++ b/test/commands/webapp/helpers/devServerUtils.ts @@ -0,0 +1,207 @@ +/* + * 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 { execSync, spawn, type ChildProcess } from 'node:child_process'; +import { createServer as createHttpServer, type Server as HttpServer } from 'node:http'; +import { join } from 'node:path'; +import { createServer, type Server } from 'node:net'; + +/* + * Port ranges reserved per test file (avoid collisions): + * devWithUrl.nut.ts — 18_900–18_909 (full flow), 18_930–18_939 (proxy-only), 18_940–18_949 (Vite) + * devPort.nut.ts — 18_910–18_919 (dev servers), 18_920–18_929 (proxy ports) + * + * Tests run sequentially (--parallel=false), but unique ports per test are + * still needed: the OS keeps closed sockets in TIME_WAIT briefly, and if + * parallelism is enabled later, overlapping ranges would cause EADDRINUSE. + */ + +/** Mocha suite-level timeout for describe blocks that spawn webapp dev. */ +export const SUITE_TIMEOUT = 180_000; + +/** Timeout for spawnWebappDev when the command is expected to start successfully. */ +export const SPAWN_TIMEOUT = 120_000; + +/** Shorter timeout for spawnWebappDev when the command is expected to fail quickly. */ +export const SPAWN_FAIL_TIMEOUT = 60_000; + +export type WebappDevHandle = { + /** The proxy URL emitted by the command on stderr as JSON `{"url":"..."}` */ + proxyUrl: string; + /** The underlying child process */ + process: ChildProcess; + /** Accumulated stderr output */ + stderr: string; + /** Gracefully kill the process tree */ + kill: () => Promise; +}; + +/** + * Spawn `sf webapp dev` asynchronously and wait for the JSON URL line on stderr. + * + * Uses `bin/dev.js` (same binary that `execCmd` uses) so we test the + * local plugin code, not whatever is installed globally. + */ +export function spawnWebappDev(args: string[], options: { cwd: string; timeout?: number }): Promise { + const binDev = join(process.cwd(), 'bin', 'dev.js'); + const proc = spawn( + process.execPath, + ['--loader', 'ts-node/esm', '--no-warnings=ExperimentalWarning', binDev, 'webapp', 'dev', ...args], + { + cwd: options.cwd, + stdio: ['pipe', 'pipe', 'pipe'], + // Unix: detached creates a process group so we can kill(-pid) the tree. + // Windows: detached opens a new console and breaks stdio piping; skip it + // since taskkill /t /f already handles tree cleanup. + ...(process.platform !== 'win32' && { detached: true }), + } + ); + + let stderrData = ''; + + const killProcessGroup = (signal: NodeJS.Signals = 'SIGTERM'): void => { + const pid = proc.pid; + if (pid == null) return; + try { + if (process.platform === 'win32') { + execSync(`taskkill /pid ${pid} /t /f`, { stdio: 'ignore' }); + } else { + process.kill(-pid, signal); + } + } catch { + /* already dead */ + } + }; + + return new Promise((resolve, reject) => { + const timeoutMs = options.timeout ?? SPAWN_TIMEOUT; + const timeoutId = setTimeout(() => { + killProcessGroup('SIGKILL'); + reject(new Error(`Timeout (${timeoutMs}ms) waiting for proxy URL.\nstderr:\n${stderrData}`)); + }, timeoutMs); + + proc.stderr?.on('data', (data: Buffer) => { + const text = data.toString(); + stderrData += text; + + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const json = JSON.parse(trimmed) as Record; + if (typeof json.url === 'string') { + clearTimeout(timeoutId); + resolve({ + proxyUrl: json.url, + process: proc, + stderr: stderrData, + kill: () => + new Promise((res) => { + if (proc.killed || proc.exitCode !== null) { + res(); + return; + } + const forceKillTimeout = setTimeout(() => { + killProcessGroup('SIGKILL'); + res(); + }, 5000); + proc.once('close', () => { + clearTimeout(forceKillTimeout); + res(); + }); + killProcessGroup('SIGTERM'); + }), + }); + } + } catch { + // Not a JSON line — ignore + } + } + }); + + proc.on('error', (err) => { + clearTimeout(timeoutId); + reject(err); + }); + + proc.on('close', (code) => { + clearTimeout(timeoutId); + if (code !== null && code !== 0) { + reject(new Error(`webapp dev exited with code ${code}.\nstderr:\n${stderrData}`)); + } + }); + }); +} + +/** + * Occupy a TCP port so that proxy bind attempts fail with EADDRINUSE. + * Returns the server handle — call `server.close()` to release. + */ +export function occupyPort(port: number): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.on('error', reject); + server.listen(port, '127.0.0.1', () => resolve(server)); + }); +} + +/** + * Start a plain HTTP server that serves static HTML content. + * Used for proxy-only mode tests where the dev server is already running. + */ +export function startTestHttpServer(port: number): Promise { + return new Promise((resolve, reject) => { + const server = createHttpServer((_, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

Manual Dev Server

'); + }); + server.on('error', reject); + server.listen(port, '127.0.0.1', () => resolve(server)); + }); +} + +/** + * Start an HTTP server that mimics a Vite dev server with the + * WebAppProxyHandler plugin active. Responds to health check requests + * (`?sfProxyHealthCheck=true`) with `X-Salesforce-WebApp-Proxy: true`. + */ +export function startViteProxyServer(port: number): Promise { + return new Promise((resolve, reject) => { + const server = createHttpServer((req, res) => { + const url = new URL(req.url ?? '/', `http://localhost:${port}`); + if (url.searchParams.get('sfProxyHealthCheck') === 'true') { + res.writeHead(200, { + 'Content-Type': 'text/plain', + 'X-Salesforce-WebApp-Proxy': 'true', + }); + res.end('OK'); + return; + } + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

Vite Dev Server

'); + }); + server.on('error', reject); + server.listen(port, '127.0.0.1', () => resolve(server)); + }); +} + +/** Close an HTTP server and wait for it to finish. */ +export function closeServer(server: HttpServer | Server | null): Promise { + if (!server) return Promise.resolve(); + return new Promise((resolve) => { + server.close(() => resolve()); + }); +} diff --git a/test/commands/webapp/helpers/webappProjectUtils.ts b/test/commands/webapp/helpers/webappProjectUtils.ts new file mode 100644 index 0000000..dee8dfa --- /dev/null +++ b/test/commands/webapp/helpers/webappProjectUtils.ts @@ -0,0 +1,197 @@ +/* + * 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 { execSync } from 'node:child_process'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { TestSession } from '@salesforce/cli-plugins-testkit'; + +/** + * Relative path from project root to the webapplications folder. + * Mirrors WEBAPPLICATIONS_RELATIVE_PATH in src/config/webappDiscovery.ts. + */ +const WEBAPPS_PATH = join('force-app', 'main', 'default', 'webapplications'); + +/** + * Resolve the absolute path to a webapp directory within a project. + * If `webAppName` is omitted, returns the webapplications folder itself. + */ +export function webappPath(projectDir: string, webAppName?: string): string { + return webAppName ? join(projectDir, WEBAPPS_PATH, webAppName) : join(projectDir, WEBAPPS_PATH); +} + +/** + * Verify the global `sf` CLI is available and has the required commands. + * Must be called after TestSession.create() since the session sets a valid HOME. + */ +export function ensureSfCli(): void { + try { + execSync('sf project generate --help', { stdio: 'pipe', timeout: 30_000 }); + } catch { + throw new Error( + 'Global sf CLI with plugin-templates not found.\n' + + 'Install: npm install @salesforce/cli -g\n' + + 'CI installs @salesforce/cli@nightly via nut.yml.' + ); + } +} + +/** + * Authenticate an org via TESTKIT_AUTH_URL without requiring DevHub. + * Returns the authenticated username. + * + * Must be called once per TestSession since each session has its own + * mock home directory where auth files are stored. + */ +export function authOrgViaUrl(): string { + const authUrl = process.env.TESTKIT_AUTH_URL; + if (!authUrl) { + throw new Error('TESTKIT_AUTH_URL environment variable is not set.'); + } + + // Use --sfdx-url-file for cross-platform reliability + const tmpFile = join(tmpdir(), `testkit-auth-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`); + try { + writeFileSync(tmpFile, authUrl, 'utf8'); + const output = execSync(`sf org login sfdx-url --sfdx-url-file "${tmpFile}" --json`, { + stdio: 'pipe', + timeout: 60_000, + }).toString(); + const result = JSON.parse(output) as { result: { username: string } }; + return result.result.username; + } finally { + rmSync(tmpFile, { force: true }); + } +} + +/** + * Run `sf project generate --name ` inside the session directory. + * Returns the absolute path to the generated project root. + */ +export function createProject(session: TestSession, name: string): string { + execSync(`sf project generate --name ${name}`, { + cwd: session.dir, + stdio: 'pipe', + }); + return join(session.dir, name); +} + +/** + * Run `sf project generate` then `sf webapp generate --name ` inside + * the project. Returns the absolute path to the generated project root. + */ +export function createProjectWithWebapp(session: TestSession, projectName: string, webAppName: string): string { + const projectDir = createProject(session, projectName); + execSync(`sf webapp generate --name ${webAppName}`, { + cwd: projectDir, + stdio: 'pipe', + }); + return projectDir; +} + +/** + * Create a project with multiple webapps. Used to test selection flows when + * more than one webapp exists in a single SFDX project. + */ +export function createProjectWithMultipleWebapps( + session: TestSession, + projectName: string, + webAppNames: string[] +): string { + const projectDir = createProject(session, projectName); + for (const name of webAppNames) { + execSync(`sf webapp generate --name ${name}`, { + cwd: projectDir, + stdio: 'pipe', + }); + } + return projectDir; +} + +/** + * Create the `webapplications/` directory (empty — no webapps inside). + * Used to test "empty webapplications folder" scenario. + */ +export function createEmptyWebappsDir(projectDir: string): void { + mkdirSync(webappPath(projectDir), { recursive: true }); +} + +/** + * Create a webapp directory without the required `.webapplication-meta.xml`. + * Used to test "no metadata file" scenario. + */ +export function createWebappDirWithoutMeta(projectDir: string, name: string): void { + mkdirSync(webappPath(projectDir, name), { recursive: true }); +} + +/** + * Overwrite the `webapplication.json` manifest for a given webapp. + */ +export function writeManifest(projectDir: string, webAppName: string, manifest: Record): void { + writeFileSync(join(webappPath(projectDir, webAppName), 'webapplication.json'), JSON.stringify(manifest, null, 2)); +} + +/** + * Write a tiny Node.js HTTP server script into the webapp directory. + * Returns the command string suitable for `dev.command` in the manifest. + * + * The script is CommonJS (.cjs) to avoid ESM/shell quoting issues. + */ +export function createDevServerScript(webappDir: string, port: number): string { + const script = [ + "const http = require('http');", + 'const server = http.createServer((_, res) => {', + " res.writeHead(200, { 'Content-Type': 'text/html' });", + " res.end('

Test Dev Server

');", + '});', + `server.listen(${port}, () => {`, + ` console.log('listening on port ${port}');`, + '});', + ].join('\n'); + writeFileSync(join(webappDir, 'dev-server.cjs'), script); + return 'node dev-server.cjs'; +} + +/** + * Convenience: create a project with a webapp whose manifest includes a + * `dev.command` that starts a tiny HTTP server on `devPort`, and + * `dev.url` pointing to that port. Optionally sets `dev.port` (proxy port). + * + * Returns `{ projectDir, webappDir }`. + */ +export function createProjectWithDevServer( + session: TestSession, + projectName: string, + webAppName: string, + devPort: number, + proxyPort?: number +): { projectDir: string; webappDir: string } { + const projectDir = createProjectWithWebapp(session, projectName, webAppName); + const webappDir = webappPath(projectDir, webAppName); + + const devCommand = createDevServerScript(webappDir, devPort); + const dev: Record = { + url: `http://localhost:${devPort}`, + command: devCommand, + }; + if (proxyPort !== undefined) { + dev.port = proxyPort; + } + writeManifest(projectDir, webAppName, { dev }); + + return { projectDir, webappDir }; +} diff --git a/test/error/DevServerErrorParser.test.ts b/test/error/DevServerErrorParser.test.ts index e525267..844134b 100644 --- a/test/error/DevServerErrorParser.test.ts +++ b/test/error/DevServerErrorParser.test.ts @@ -121,6 +121,7 @@ sh: vite: command not found 'sh: vite: command not found', 'bash: npm: command not found', '/bin/sh: node: command not found', + 'sh: 1: vite: not found', ]; for (const stderr of stderrFormats) { @@ -130,6 +131,23 @@ sh: vite: command not found } }); + it('should parse dash-style command not found (Ubuntu /bin/sh)', () => { + const stderr = ` +> my-app@1.0.0 dev +> vite --mode development + +sh: 1: vite: 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.some((s) => s.includes('npm install'))).to.be.true; + }); + it('should handle unknown errors with fallback', () => { const stderr = ` Some random error that doesn't match any pattern diff --git a/yarn.lock b/yarn.lock index 8a816ad..58bb8c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3885,6 +3885,11 @@ dot-prop@^5.1.0: dependencies: is-obj "^2.0.0" +dotenv@^17.3.1: + version "17.3.1" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-17.3.1.tgz#2706f5b0165e45a1503348187b8468f87fe6aae2" + integrity sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA== + dunder-proto@^1.0.0, dunder-proto@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz"