Skip to content

Commit c0e46c1

Browse files
committed
test: integration test for the local cmp preview @W-18852148
1 parent 50a1fc9 commit c0e46c1

19 files changed

Lines changed: 3378 additions & 114 deletions

File tree

.env.template

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# JWT private key
2+
TESTKIT_JWT_KEY="-----BEGIN RSA PRIVATE KEY-----
3+
FAKEPRIVATEKEY1234567890abcdefg==
4+
-----END RSA PRIVATE KEY-----"
5+
6+
# Connected App Client ID for JWT auth
7+
TESTKIT_JWT_CLIENT_ID=FAKE_CLIENT_ID_123456
8+
9+
# Username of your Dev Hub org
10+
TESTKIT_HUB_USERNAME=test-user@example.com
11+
12+
# Path to the Salesforce CLI executable
13+
TESTKIT_EXECUTABLE_PATH=./node_modules/.bin/sf
14+
15+
# Instance URL of your Dev Hub org
16+
TESTKIT_HUB_INSTANCE=https://fake-devhub-instance.salesforce.com
17+
18+
# Prevent the dev server from opening the browser during tests
19+
OPEN_BROWSER=false

.gitignore

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,9 @@ bld
5656

5757
# sf cli
5858
.sf
59-
.sfdx
59+
.sfdx
60+
61+
stdout*
62+
stderr*
63+
64+
.env

README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,4 +304,68 @@ EXAMPLES
304304

305305
_See code: [src/commands/lightning/dev/site.ts](https://github.com/salesforcecli/plugin-lightning-dev/blob/4.0.4/src/commands/lightning/dev/site.ts)_
306306

307+
## Integration Testing
308+
309+
This plugin includes integration (NUT) tests for verifying the Lightning Dev Server functionality using SFDX projects and components. Test data like SFDX projects are created at runtime by the testkit.
310+
311+
### Prerequisites
312+
313+
**Connected App Setup**
314+
Follow the [Connected App Setup Guide](https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_auth_connected_app.htm) to:
315+
316+
- Create a connected app
317+
- Enable JWT OAuth
318+
- Configure callback URL and OAuth scopes
319+
320+
**JWT Credentials**
321+
Follow the [Private Key and Certificate Setup Guide](https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_auth_key_and_cert.htm) to:
322+
323+
- Generate a private key and certificate
324+
- Upload certificate to the connected app
325+
326+
Set the environment variables (copy from `.env.template` to `.env`):
327+
328+
- `TESTKIT_JWT_CLIENT_ID` - Your connected app client ID
329+
- `TESTKIT_JWT_KEY` - Your private key contents
330+
- `TESTKIT_HUB_INSTANCE` - Salesforce instance URL
331+
- `TESTKIT_ORG_USERNAME` - Your org username for testing
332+
- `OPEN_BROWSER` - Control browser opening (true/false)
333+
334+
### Running Tests
335+
336+
Run all or specific integration tests (NUTs) via:
337+
338+
```bash
339+
# Run all integration tests
340+
yarn test:nuts
341+
342+
# Run by category
343+
yarn test:nuts "test/commands/lightning/dev/component*.nut.ts"
344+
345+
# Run with environment variables
346+
OPEN_BROWSER=false NODE_ENV=production yarn test:nuts
347+
```
348+
349+
### Test Data Structure
350+
351+
The testkit creates SFDX projects and test data at runtime:
352+
353+
```
354+
test/
355+
├── testdata/
356+
│ ├── lwc/
357+
│ │ └── helloWorld/
358+
│ └── project-definition.json
359+
```
360+
361+
### Automation with TestKit
362+
363+
Tests use [@salesforce/cli-plugins-testkit](https://github.com/salesforcecli/cli-plugins-testkit) for:
364+
365+
- Temporary project creation at runtime
366+
- JWT-based org login
367+
- Cleanup after each run
368+
369+
No manual authentication is needed. Set the required environment variables once and run tests headlessly.
370+
307371
<!-- commandsstop -->

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,14 @@
2424
},
2525
"devDependencies": {
2626
"@oclif/plugin-command-snapshot": "^5.3.2",
27+
"@salesforce/cli": "^2.93.7",
2728
"@salesforce/cli-plugins-testkit": "^5.3.39",
2829
"@salesforce/dev-scripts": "^11.0.2",
2930
"@salesforce/plugin-command-reference": "^3.1.56",
3031
"@types/node-fetch": "^2.6.12",
3132
"@types/xml2js": "^0.4.14",
3233
"@typescript-eslint/eslint-plugin": "^6.21.0",
34+
"dotenv": "^16.5.0",
3335
"eslint": "^8.57.0",
3436
"eslint-config-prettier": "^9.1.0",
3537
"eslint-plugin-header": "^3.1.1",
@@ -257,7 +259,7 @@
257259
"exports": "./lib/index.js",
258260
"type": "module",
259261
"volta": {
260-
"node": "20.11.0",
262+
"node": "20.19.3",
261263
"yarn": "1.22.22"
262264
}
263265
}

src/commands/lightning/dev/component.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ export default class LightningDevComponent extends SfCommand<void> {
3232
char: 'c',
3333
default: false,
3434
}),
35+
port: Flags.integer({
36+
summary: 'Port number for the development server',
37+
char: 'p',
38+
default: 3000,
39+
}),
3540
// TODO should this be required or optional?
3641
// We don't technically need this if your components are simple / don't need any data from your org
3742
'target-org': Flags.optionalOrg(),
@@ -96,14 +101,15 @@ export default class LightningDevComponent extends SfCommand<void> {
96101

97102
const dirname = path.dirname(url.fileURLToPath(import.meta.url));
98103
const rootDir = path.resolve(dirname, '../../../..');
99-
const port = parseInt(process.env.PORT ?? '3000', 10);
104+
const port = flags.port;
100105

101106
await cmpDev({
102107
rootDir,
103108
mode: 'dev',
104109
port,
105110
name: name ? `c/${name}` : undefined,
106111
namespacePaths,
112+
open: process.env.OPEN_BROWSER ? process.env.OPEN_BROWSER === 'true' : false,
107113
});
108114
}
109115
}

src/shared/componentUtils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ export class ComponentUtils {
2626
return componentName.replace(/([A-Z])/g, ' $1').replace(/^./, (str) => str.toUpperCase());
2727
}
2828

29+
public static toKebabCase(str: string): string {
30+
return str
31+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2') // insert dash between camelCase boundaries
32+
.toLowerCase();
33+
}
34+
2935
public static async getNamespacePaths(project: SfProject): Promise<string[]> {
3036
const packageDirs = project.getPackageDirectories();
3137

src/shared/experience/expSite.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,8 @@ export class ExperienceSite {
258258
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
259259
response.data.pipe(fileStream);
260260

261-
await new Promise((resolve, reject) => {
262-
fileStream.on('finish', resolve);
261+
await new Promise<void>((resolve, reject) => {
262+
fileStream.on('finish', () => resolve());
263263
fileStream.on('error', reject);
264264
});
265265
this.saveMetadata(metadata);
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
* Copyright (c) 2025, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import path from 'node:path';
9+
import fs from 'node:fs';
10+
import { expect } from 'chai';
11+
import { TestSession } from '@salesforce/cli-plugins-testkit';
12+
import dotenv from 'dotenv';
13+
import axios from 'axios';
14+
import { ComponentUtils } from '../../../../src/shared/componentUtils.js';
15+
import { createSfdxProject, createLwcComponent } from './helpers/projectSetup.js';
16+
import { startLightningDevServer } from './helpers/devServerUtils.js';
17+
18+
dotenv.config();
19+
20+
const INSTANCE_URL = process.env.TESTKIT_HUB_INSTANCE;
21+
const TEST_TIMEOUT_MS = 60_000;
22+
const STARTUP_DELAY_MS = 5000;
23+
const DEV_SERVER_PORT = 3000;
24+
25+
describe('LWC Local Preview Integration', () => {
26+
let session: TestSession;
27+
let componentName: string;
28+
let projectDir: string;
29+
30+
before(async () => {
31+
componentName = 'helloWorld';
32+
33+
session = await TestSession.create({ devhubAuthStrategy: 'JWT' });
34+
35+
const timestamp = Date.now();
36+
projectDir = path.join(session.dir, `lwc-project-${timestamp}`);
37+
fs.mkdirSync(projectDir, { recursive: true });
38+
39+
await Promise.all([
40+
createSfdxProject(projectDir, INSTANCE_URL ?? ''),
41+
createLwcComponent(projectDir, componentName),
42+
]);
43+
});
44+
45+
after(async () => {
46+
await session?.clean();
47+
});
48+
49+
it('should start lightning dev server and respond to /c-hello-world/ URL', async function () {
50+
this.timeout(TEST_TIMEOUT_MS);
51+
52+
let stderrOutput = '';
53+
let stdoutOutput = '';
54+
let exitedEarly = false;
55+
let exitCode: number | null = null;
56+
57+
const serverProcess = startLightningDevServer(projectDir, componentName);
58+
59+
serverProcess.stderr?.on('data', (data: Buffer) => {
60+
stderrOutput += data.toString();
61+
});
62+
63+
serverProcess.stdout?.on('data', (data: Buffer) => {
64+
stdoutOutput += data.toString();
65+
});
66+
67+
serverProcess.on('exit', (code: number) => {
68+
exitedEarly = true;
69+
exitCode = code;
70+
});
71+
72+
serverProcess.on('error', (error) => {
73+
exitedEarly = true;
74+
stderrOutput += `Process error: ${String(error)}\n`;
75+
});
76+
77+
// Wait for server startup
78+
await new Promise((r) => setTimeout(r, STARTUP_DELAY_MS));
79+
80+
// Test the kebab-case component URL with /c- prefix
81+
const componentKebabName = ComponentUtils.toKebabCase(componentName);
82+
const componentUrl = `http://localhost:${DEV_SERVER_PORT}/c-${componentKebabName}/`;
83+
let componentHttpSuccess = false;
84+
85+
try {
86+
const componentResponse = await axios.get(componentUrl, { timeout: 2000 });
87+
componentHttpSuccess = componentResponse.status === 200;
88+
} catch (error) {
89+
const err = error as { message?: string };
90+
stderrOutput += `Component URL HTTP request failed: ${err.message ?? 'Unknown error'}\n`;
91+
componentHttpSuccess = false;
92+
}
93+
94+
// Clean up
95+
try {
96+
if (serverProcess.pid && process.kill(serverProcess.pid, 0)) {
97+
process.kill(serverProcess.pid, 'SIGKILL');
98+
}
99+
} catch (error) {
100+
const err = error as NodeJS.ErrnoException;
101+
if (err.code !== 'ESRCH') throw error;
102+
}
103+
104+
// Stderr error check
105+
const criticalPatterns = [
106+
'FATAL',
107+
'Cannot find module',
108+
'ENOENT',
109+
'Unable to find component',
110+
'command lightning:dev:component not found',
111+
];
112+
const hasCriticalError = criticalPatterns.some((pattern) => stderrOutput.includes(pattern));
113+
114+
expect(
115+
exitedEarly,
116+
`Dev server exited early with code ${exitCode}. Full stderr: ${stderrOutput}. Full stdout: ${stdoutOutput}`
117+
).to.be.false;
118+
expect(hasCriticalError, `Critical stderr output detected:\n${stderrOutput}`).to.be.false;
119+
expect(
120+
componentHttpSuccess,
121+
`Dev server did not respond with HTTP 200 for component URL. Tried URL: ${componentUrl}`
122+
).to.be.true;
123+
});
124+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* Copyright (c) 2025, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import path from 'node:path';
9+
import { spawn, ChildProcess } from 'node:child_process';
10+
import { fileURLToPath } from 'node:url';
11+
12+
const currentFile = fileURLToPath(import.meta.url);
13+
const currentDir = path.dirname(currentFile);
14+
const pluginRoot = path.resolve(currentDir, '../../../../..');
15+
16+
export const startLightningDevServer = (projectDir: string, componentName: string): ChildProcess => {
17+
const devScriptPath = path.join(pluginRoot, 'bin', 'run.js');
18+
19+
return spawn('node', [devScriptPath, 'lightning', 'dev', 'component', '--name', componentName], {
20+
cwd: projectDir,
21+
env: { ...process.env, NODE_ENV: 'production', PORT: '3000', OPEN_BROWSER: process.env.OPEN_BROWSER ?? 'false' },
22+
});
23+
};
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright (c) 2025, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import path from 'node:path';
9+
import fs from 'node:fs';
10+
import { fileURLToPath } from 'node:url';
11+
12+
const currentFile = fileURLToPath(import.meta.url);
13+
const currentDir = path.dirname(currentFile);
14+
15+
const TEMPLATE_DIR = path.resolve(currentDir, '../testdata/lwc/helloWorld');
16+
const SCRATCH_DEF_PATH = path.resolve(currentDir, '../testdata/project-definition.json');
17+
18+
let templateCache: { js: string; html: string; meta: string } | null = null;
19+
20+
const loadTemplateContent = async (): Promise<{ js: string; html: string; meta: string }> => {
21+
if (!templateCache) {
22+
const [js, html, meta] = await Promise.all([
23+
fs.promises.readFile(path.join(TEMPLATE_DIR, 'helloWorld.js'), 'utf8'),
24+
fs.promises.readFile(path.join(TEMPLATE_DIR, 'helloWorld.html'), 'utf8'),
25+
fs.promises.readFile(path.join(TEMPLATE_DIR, 'helloWorld.js-meta.xml'), 'utf8'),
26+
]);
27+
templateCache = { js, html, meta };
28+
}
29+
return templateCache;
30+
};
31+
32+
export const createSfdxProject = async (projectDir: string, customInstanceUrl: string): Promise<void> => {
33+
const sfdxProject = {
34+
packageDirectories: [{ path: 'force-app', default: true }],
35+
name: 'temp-project',
36+
namespace: '',
37+
instanceUrl: customInstanceUrl,
38+
sourceApiVersion: '60.0',
39+
};
40+
41+
// Parallel operations: create directories and read scratch def
42+
const [, scratchDefContent] = await Promise.all([
43+
Promise.all([
44+
fs.promises.mkdir(path.join(projectDir, 'force-app', 'main', 'default', 'lwc'), { recursive: true }),
45+
fs.promises.mkdir(path.join(projectDir, 'config'), { recursive: true }),
46+
]),
47+
fs.promises.readFile(SCRATCH_DEF_PATH, 'utf8'),
48+
]);
49+
50+
await Promise.all([
51+
fs.promises.writeFile(path.join(projectDir, 'sfdx-project.json'), JSON.stringify(sfdxProject, null, 2)),
52+
fs.promises.writeFile(path.join(projectDir, 'config', 'project-scratch-def.json'), scratchDefContent),
53+
]);
54+
};
55+
56+
export const createLwcComponent = async (projectDir: string, name: string): Promise<void> => {
57+
const lwcPath = path.join(projectDir, 'force-app', 'main', 'default', 'lwc', name);
58+
59+
const [, templates] = await Promise.all([fs.promises.mkdir(lwcPath, { recursive: true }), loadTemplateContent()]);
60+
61+
await Promise.all([
62+
fs.promises.writeFile(path.join(lwcPath, `${name}.js`), templates.js.replace(/helloWorld/g, name)),
63+
fs.promises.writeFile(path.join(lwcPath, `${name}.html`), templates.html),
64+
fs.promises.writeFile(path.join(lwcPath, `${name}.js-meta.xml`), templates.meta),
65+
]);
66+
};
67+
68+
export const clearTemplateCache = (): void => {
69+
templateCache = null;
70+
};

0 commit comments

Comments
 (0)