Skip to content

Commit aa3c6f2

Browse files
feat: add E2E NUT tests for webapp dev command @W-21111429@
- Add Tier 1 (no auth) and Tier 2 (CLI validation) tests for dev.nut.ts - Add devPort.nut.ts for --port and --proxy-port flag tests - Add devWithUrl.nut.ts for --url, proxy detection, and Vite integration tests - Add shared helpers: devServerUtils (spawn, port reservation) and webappProjectUtils - Add _cleanup.nut.ts for test session cleanup - Add .env.template for NUT credentials (TESTKIT_AUTH_URL, etc.) - Update GitHub Actions to run NUTs on ubuntu-latest and windows-latest - Fix Windows CI: use spawnWebappDev, taskkill, --sfdx-url-file auth - Extend dev.ts to support OPEN_BROWSER=false for headless test runs Made-with: Cursor
1 parent eedcb5f commit aa3c6f2

12 files changed

Lines changed: 1127 additions & 43 deletions

File tree

.env.template

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# ── NUT Credentials ──────────────────────────────────────────────
2+
# Only ONE of the two options below is needed.
3+
#
4+
# Option A: AUTH_URL (simplest — recommended for getting started)
5+
# 1. sf org login web --alias nut-org
6+
# 2. sf org display -o nut-org --json | jq -r .result.sfdxAuthUrl
7+
# 3. Paste the value below
8+
# TESTKIT_AUTH_URL=
9+
10+
# Option B: JWT (no token expiration — recommended for CI)
11+
# TESTKIT_JWT_KEY=
12+
# TESTKIT_JWT_CLIENT_ID=
13+
# TESTKIT_HUB_USERNAME=
14+
# TESTKIT_HUB_INSTANCE=https://login.salesforce.com
15+
16+
# ── Optional ─────────────────────────────────────────────────────
17+
# Path to sf CLI executable (defaults to global sf in PATH)
18+
# TESTKIT_EXECUTABLE_PATH=
19+
20+
# Prevent browser from opening during tests
21+
OPEN_BROWSER=false

.github/workflows/test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ jobs:
2424
fail-fast: false
2525
with:
2626
os: ${{ matrix.os }}
27+
retries: 3

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ node_modules
4444
# --
4545
# put files here you don't want cleaned with sf-clean
4646

47+
# local NUT credentials
48+
.env
49+
4750
# os specific files
4851
.DS_Store
4952
.idea

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@salesforce/plugin-command-reference": "^3.1.77",
2525
"@types/http-proxy": "^1.17.14",
2626
"@types/micromatch": "^4.0.10",
27+
"dotenv": "^17.3.1",
2728
"eslint-plugin-sf-plugin": "^1.20.33",
2829
"oclif": "^4.22.68",
2930
"ts-node": "^10.9.2",
@@ -82,7 +83,9 @@
8283
"prepack": "sf-prepack",
8384
"prepare": "sf-install",
8485
"test": "wireit",
85-
"test:nuts": "nyc mocha \"**/*.nut.ts\" --slow 4500 --timeout 600000 --parallel",
86+
"test:nuts": "nyc mocha \"**/*.nut.ts\" --slow 4500 --timeout 600000 --parallel=false",
87+
"test:nuts:local": "nyc mocha \"**/*.nut.ts\" --slow 4500 --timeout 600000 --parallel=false --require dotenv/config",
88+
"test:nut:local": "nyc mocha --slow 4500 --timeout 600000 --require dotenv/config",
8689
"test:only": "wireit",
8790
"version": "oclif readme"
8891
},

src/commands/webapp/dev.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -291,10 +291,14 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {
291291
} else if (flags.url) {
292292
// User explicitly passed --url; assume server is already running at that URL
293293
// Fail immediately if unreachable (don't start dev server)
294-
throw new SfError(messages.getMessage('error.dev-url-unreachable-with-flag', [resolvedUrl]), 'DevServerUrlError', [
295-
`Ensure your dev server is running at ${resolvedUrl}`,
296-
'Remove --url to use dev.command to start the server automatically',
297-
]);
294+
throw new SfError(
295+
messages.getMessage('error.dev-url-unreachable-with-flag', [resolvedUrl]),
296+
'DevServerUrlError',
297+
[
298+
`Ensure your dev server is running at ${resolvedUrl}`,
299+
'Remove --url to use dev.command to start the server automatically',
300+
]
301+
);
298302
} else if (manifest?.dev?.url && !manifest?.dev?.command?.trim()) {
299303
// dev.url in manifest but no dev.command - don't start (we can't control the port)
300304
throw new SfError(messages.getMessage('error.dev-url-unreachable', [resolvedUrl]), 'DevServerUrlError', [
@@ -439,7 +443,9 @@ export default class WebappDev extends SfCommand<WebAppDevResult> {
439443
await this.proxyServer.start();
440444
} catch (error) {
441445
const err = error as NodeJS.ErrnoException;
442-
if (err.code === 'EADDRINUSE') {
446+
const isAddrInUse =
447+
err.code === 'EADDRINUSE' || (error instanceof SfError && error.name === 'PortInUseError');
448+
if (isAddrInUse) {
443449
if (portExplicitlyConfigured) {
444450
throw new SfError(messages.getMessage('error.port-in-use', [String(port)]), 'PortInUseError');
445451
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright 2026, Salesforce, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { readdirSync, rmSync } from 'node:fs';
18+
import { join } from 'node:path';
19+
20+
/**
21+
* Root-level cleanup: remove any test_session_* directories left behind.
22+
*
23+
* TestSession.clean() normally deletes these, but they can persist when:
24+
* - rm fails (e.g. Windows file locks from spawned processes)
25+
* - Process is killed before after() runs (timeout, SIGKILL)
26+
* - TESTKIT_SAVE_ARTIFACTS is set
27+
*
28+
* This hook runs after all NUTs complete as a fallback.
29+
*/
30+
after(() => {
31+
const cwd = process.cwd();
32+
try {
33+
for (const name of readdirSync(cwd)) {
34+
if (name.startsWith('test_session_')) {
35+
try {
36+
rmSync(join(cwd, name), { recursive: true, force: true });
37+
} catch {
38+
/* ignore per-dir failures */
39+
}
40+
}
41+
}
42+
} catch {
43+
/* ignore */
44+
}
45+
});

test/commands/webapp/dev.nut.ts

Lines changed: 175 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -14,63 +14,201 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { writeFileSync, unlinkSync, existsSync } from 'node:fs';
18-
import { join } from 'node:path';
17+
import { execSync } from 'node:child_process';
1918
import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit';
2019
import { expect } from 'chai';
20+
import {
21+
createProject,
22+
createProjectWithWebapp,
23+
createEmptyWebappsDir,
24+
createWebappDirWithoutMeta,
25+
writeManifest,
26+
webappPath,
27+
ensureSfCli,
28+
authOrgViaUrl,
29+
} from './helpers/webappProjectUtils.js';
2130

22-
describe('webapp dev NUTs', () => {
31+
/* ------------------------------------------------------------------ *
32+
* Tier 1 — No Auth *
33+
* *
34+
* Validates flag-level parse errors that fire before any org or *
35+
* filesystem interaction. No credentials needed; always runs. *
36+
* ------------------------------------------------------------------ */
37+
describe('webapp dev NUTs — Tier 1 (no auth)', () => {
2338
let session: TestSession;
24-
const testWebappJson = {
25-
name: 'testWebApp',
26-
label: 'Test Web App',
27-
version: '1.0.0',
28-
outputDir: 'dist',
29-
dev: {
30-
url: 'http://localhost:5173',
31-
},
32-
};
3339

3440
before(async () => {
3541
session = await TestSession.create({ devhubAuthStrategy: 'NONE' });
3642
});
3743

3844
after(async () => {
3945
await session?.clean();
40-
// Clean up any test webapplication.json files
41-
const webappJsonPath = join(session?.dir ?? process.cwd(), 'webapplication.json');
42-
if (existsSync(webappJsonPath)) {
43-
unlinkSync(webappJsonPath);
44-
}
4546
});
4647

47-
it('should fail without target-org flag', () => {
48-
// Create webapplication.json for this test
49-
const webappJsonPath = join(session.dir, 'webapplication.json');
50-
writeFileSync(webappJsonPath, JSON.stringify(testWebappJson, null, 2));
51-
52-
const result = execCmd('webapp dev --name testWebApp --json', {
48+
// --target-org is declared as Flags.requiredOrg(). Running without it
49+
// must fail at parse time with NoDefaultEnvError before any other logic.
50+
it('should require --target-org', () => {
51+
const result = execCmd('webapp dev --json', {
5352
ensureExitCode: 1,
5453
cwd: session.dir,
5554
});
5655

5756
expect(result.jsonOutput?.name).to.equal('NoDefaultEnvError');
5857
expect(result.jsonOutput?.message).to.include('target-org');
58+
});
59+
});
60+
61+
/* ------------------------------------------------------------------ *
62+
* Tier 2 — CLI Validation (with auth) *
63+
* *
64+
* Validates webapp discovery errors and URL resolution errors. *
65+
* Auth is only needed so --target-org passes parsing; these tests *
66+
* exercise local filesystem/network checks — no live org calls. *
67+
* *
68+
* Requires TESTKIT_AUTH_URL. Fails when absent (tests are mandatory). *
69+
* ------------------------------------------------------------------ */
70+
describe('webapp dev NUTs — Tier 2 CLI validation', () => {
71+
let session: TestSession;
72+
let targetOrg: string;
73+
74+
before(async function () {
75+
if (!process.env.TESTKIT_AUTH_URL) {
76+
throw new Error(
77+
'TESTKIT_AUTH_URL is required for Tier 2 tests. Set it in .env (local) or CI secrets (GitHub Actions).'
78+
);
79+
}
80+
81+
session = await TestSession.create({ devhubAuthStrategy: 'NONE' });
82+
ensureSfCli();
83+
targetOrg = authOrgViaUrl();
84+
});
5985

60-
// Clean up
61-
unlinkSync(webappJsonPath);
86+
after(async () => {
87+
await session?.clean();
6288
});
6389

64-
// Note: Additional error scenario tests (manifest validation, dev server config)
65-
// require authenticated orgs, which may not be available in all CI/CD environments.
66-
// These scenarios are covered by unit tests instead.
67-
//
68-
// Scenarios covered in unit tests:
69-
// - Missing webapplication.json manifest (ManifestWatcher.test.ts)
70-
// - Invalid webapplication.json schema (ManifestWatcher.test.ts)
71-
// - Malformed JSON syntax (ManifestWatcher.test.ts)
72-
// - Missing dev server config (DevServerManager.test.ts)
73-
//
74-
// For local testing with authenticated orgs, use manual validation scripts in:
75-
// docs/manual-tests/test-webapp-dev-command.ts
90+
// ── Discovery errors ──────────────────────────────────────────
91+
92+
// Project has no webapplications folder at all → WebappNotFoundError.
93+
it('should error when no webapp found (project only, no webapps)', () => {
94+
const projectDir = createProject(session, 'noWebappProject');
95+
96+
const result = execCmd(`webapp dev --target-org ${targetOrg} --json`, {
97+
ensureExitCode: 1,
98+
cwd: projectDir,
99+
});
100+
101+
expect(result.jsonOutput?.name).to.equal('WebappNotFoundError');
102+
});
103+
104+
// Project has webapp "realApp" but --name asks for "NonExistent" → WebappNameNotFoundError.
105+
it('should error when --name does not match any webapp', () => {
106+
const projectDir = createProjectWithWebapp(session, 'nameNotFound', 'realApp');
107+
108+
const result = execCmd(`webapp dev --name NonExistent --target-org ${targetOrg} --json`, {
109+
ensureExitCode: 1,
110+
cwd: projectDir,
111+
});
112+
113+
expect(result.jsonOutput?.name).to.equal('WebappNameNotFoundError');
114+
});
115+
116+
// cwd is inside webapp "appA" but --name asks for "appB" → WebappNameConflictError.
117+
// Discovery treats this as ambiguous intent and rejects it.
118+
it('should error on --name conflict when inside a different webapp', () => {
119+
const projectDir = createProjectWithWebapp(session, 'nameConflict', 'appA');
120+
execSync('sf webapp generate --name appB', { cwd: projectDir, stdio: 'pipe' });
121+
122+
const cwdInsideAppA = webappPath(projectDir, 'appA');
123+
124+
const result = execCmd(`webapp dev --name appB --target-org ${targetOrg} --json`, {
125+
ensureExitCode: 1,
126+
cwd: cwdInsideAppA,
127+
});
128+
129+
expect(result.jsonOutput?.name).to.equal('WebappNameConflictError');
130+
});
131+
132+
// webapplications/ folder exists but is empty → WebappNotFoundError.
133+
it('should error when webapplications folder is empty', () => {
134+
const projectDir = createProject(session, 'emptyWebapps');
135+
createEmptyWebappsDir(projectDir);
136+
137+
const result = execCmd(`webapp dev --target-org ${targetOrg} --json`, {
138+
ensureExitCode: 1,
139+
cwd: projectDir,
140+
});
141+
142+
expect(result.jsonOutput?.name).to.equal('WebappNotFoundError');
143+
});
144+
145+
// webapplications/orphanApp/ exists but has no .webapplication-meta.xml → not a valid webapp.
146+
it('should error when webapp dir has no .webapplication-meta.xml', () => {
147+
const projectDir = createProject(session, 'noMeta');
148+
createWebappDirWithoutMeta(projectDir, 'orphanApp');
149+
150+
const result = execCmd(`webapp dev --target-org ${targetOrg} --json`, {
151+
ensureExitCode: 1,
152+
cwd: projectDir,
153+
});
154+
155+
expect(result.jsonOutput?.name).to.equal('WebappNotFoundError');
156+
});
157+
158+
// ── Auto-selection ────────────────────────────────────────────
159+
160+
// When cwd is inside webapplications/myApp/, discovery auto-selects that
161+
// webapp without --name. The command proceeds past discovery and fails at
162+
// URL resolution (no dev server running) — confirming auto-select worked.
163+
it('should auto-select webapp when run from inside its directory', () => {
164+
const projectDir = createProjectWithWebapp(session, 'autoSelect', 'myApp');
165+
166+
writeManifest(projectDir, 'myApp', {
167+
dev: { url: 'http://localhost:5179' },
168+
});
169+
170+
const cwdInsideApp = webappPath(projectDir, 'myApp');
171+
172+
// No --name flag; cwd is inside the webapp directory.
173+
// Discovery auto-selects myApp, then the command fails at URL check
174+
// (nothing running on 5179). DevServerUrlError proves discovery succeeded.
175+
const result = execCmd(`webapp dev --target-org ${targetOrg} --json`, {
176+
ensureExitCode: 1,
177+
cwd: cwdInsideApp,
178+
});
179+
180+
expect(result.jsonOutput?.name).to.equal('DevServerUrlError');
181+
});
182+
183+
// ── URL / dev server errors ───────────────────────────────────
184+
185+
// --url explicitly provided but nothing is listening → DevServerUrlError.
186+
// The command refuses to start a dev server when --url is given.
187+
it('should error when --url is unreachable', () => {
188+
const projectDir = createProjectWithWebapp(session, 'urlUnreachable', 'myApp');
189+
190+
const result = execCmd(`webapp dev --name myApp --url http://localhost:5179 --target-org ${targetOrg} --json`, {
191+
ensureExitCode: 1,
192+
cwd: projectDir,
193+
});
194+
195+
expect(result.jsonOutput?.name).to.equal('DevServerUrlError');
196+
});
197+
198+
// Manifest has dev.url but no dev.command → command can't start the server
199+
// itself and the URL is unreachable → DevServerUrlError.
200+
it('should error when dev.url is unreachable and no dev.command', () => {
201+
const projectDir = createProjectWithWebapp(session, 'urlNoCmd', 'myApp');
202+
203+
writeManifest(projectDir, 'myApp', {
204+
dev: { url: 'http://localhost:5179' },
205+
});
206+
207+
const result = execCmd(`webapp dev --name myApp --target-org ${targetOrg} --json`, {
208+
ensureExitCode: 1,
209+
cwd: projectDir,
210+
});
211+
212+
expect(result.jsonOutput?.name).to.equal('DevServerUrlError');
213+
});
76214
});

0 commit comments

Comments
 (0)