Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# JWT private key
TESTKIT_JWT_KEY="-----BEGIN RSA PRIVATE KEY-----
FAKEPRIVATEKEY1234567890abcdefg==
-----END RSA PRIVATE KEY-----"

# Connected App Client ID for JWT auth
TESTKIT_JWT_CLIENT_ID=FAKE_CLIENT_ID_123456

# Username of your Dev Hub org
TESTKIT_HUB_USERNAME=test-user@example.com

# Path to the Salesforce CLI executable
TESTKIT_EXECUTABLE_PATH=./node_modules/.bin/sf

# Instance URL of your Dev Hub org
TESTKIT_HUB_INSTANCE=https://fake-devhub-instance.salesforce.com

# Prevent the dev server from opening the browser during tests
OPEN_BROWSER=false
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,9 @@ bld

# sf cli
.sf
.sfdx
.sfdx

stdout*
stderr*

.env
90 changes: 90 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,4 +306,94 @@ EXAMPLES

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

## Integration Testing

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.

### Prerequisites

**Salesforce CLI Installation**
The Salesforce CLI must be installed and accessible in your system PATH before running integration tests locally. The plugin expects the `sf` command to be available during local test execution.

- Install the Salesforce CLI: https://developer.salesforce.com/tools/sfdxcli
- Verify installation: `sf --version`

**Connected App Setup**
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:

- Create a connected app
- Enable JWT OAuth
- Configure callback URL and OAuth scopes

**JWT Credentials**
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:

- Generate a private key and certificate
- Upload certificate to the connected app

Set the environment variables (copy from `.env.template` to `.env`):

- `TESTKIT_JWT_CLIENT_ID` - Your connected app client ID
- `TESTKIT_JWT_KEY` - Your private key contents
- `TESTKIT_HUB_INSTANCE` - Salesforce instance URL
- `TESTKIT_ORG_USERNAME` - Your org username for testing
- `OPEN_BROWSER` - Control browser opening (true/false)

### Running Tests

Run all or specific integration tests (NUTs) via:

```bash
# Run all integration tests
yarn test:nuts

# Run component local preview test (local development only)
yarn test:nut:local

# Run by category
yarn test:nuts "test/commands/lightning/dev/component*.nut.ts"

# Run with environment variables
OPEN_BROWSER=false NODE_ENV=production yarn test:nuts
```

### Local-Only Tests

Some NUT tests are designed to run only in local development environments and are automatically skipped in CI pipelines. These tests typically:

- Require specific local setup or resources
- Are too slow for CI environments
- Need manual verification or debugging
- Test features that aren't suitable for automated testing

**Component Local Preview Test**
The `componentLocalPreview.nut.ts` test verifies that the Lightning Dev Server starts correctly and responds to component URLs. This test:

- Runs only locally (skipped when `CI=true`)
- Tests server startup and HTTP response
- Verifies component URL routing (`/c-hello-world/`)
- Can be run with: `yarn test:nut:local`

### Test Data Structure

The testkit creates SFDX projects and test data at runtime:

```
test/
├── testdata/
│ ├── lwc/
│ │ └── helloWorld/
│ └── project-definition.json
```

### Automation with TestKit

Tests use [@salesforce/cli-plugins-testkit](https://github.com/salesforcecli/cli-plugins-testkit) for:

- Temporary project creation at runtime
- JWT-based org login
- Cleanup after each run

No manual authentication is needed. Set the required environment variables once and run tests headlessly.

<!-- commandsstop -->
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@types/node-fetch": "^2.6.12",
"@types/xml2js": "^0.4.14",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"dotenv": "^16.5.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-header": "^3.1.1",
Expand Down Expand Up @@ -111,6 +112,9 @@
"publishConfig": {
"access": "public"
},
"resolutions": {
"cliui": "7.0.4"
},
"wireit": {
"build": {
"dependencies": [
Expand Down
128 changes: 128 additions & 0 deletions test/commands/lightning/dev/componentLocalPreview.nut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* Copyright (c) 2025, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import path from 'node:path';
import fs from 'node:fs';
import { expect } from 'chai';
import { TestSession } from '@salesforce/cli-plugins-testkit';
import axios from 'axios';
import * as dotenv from 'dotenv';
import { toKebabCase } from './helpers/utils.js';
import { createSfdxProject, createLwcComponent } from './helpers/projectSetup.js';
import { startLightningDevServer } from './helpers/devServerUtils.js';

// Load environment variables from .env file
dotenv.config();

const INSTANCE_URL = process.env.TESTKIT_HUB_INSTANCE;
const TEST_TIMEOUT_MS = 60_000;
const STARTUP_DELAY_MS = 5000;
const DEV_SERVER_PORT = 3000;

// Skip this test in CI environment - run only locally
const shouldSkipTest = process.env.CI === 'true' || process.env.CI === '1';

(shouldSkipTest ? describe.skip : describe)('LWC Local Preview Integration', () => {
let session: TestSession;
let componentName: string;
let projectDir: string;

before(async () => {
componentName = 'helloWorld';

session = await TestSession.create({ devhubAuthStrategy: 'JWT' });

const timestamp = Date.now();
projectDir = path.join(session.dir, `lwc-project-${timestamp}`);
fs.mkdirSync(projectDir, { recursive: true });

await Promise.all([
createSfdxProject(projectDir, INSTANCE_URL ?? ''),
createLwcComponent(projectDir, componentName),
]);
});

after(async () => {
await session?.clean();
});

it('should start lightning dev server and respond to /c-hello-world/ URL', async function () {
this.timeout(TEST_TIMEOUT_MS);

let stderrOutput = '';
let stdoutOutput = '';
let exitedEarly = false;
let exitCode: number | null = null;

const serverProcess = startLightningDevServer(projectDir, componentName);

serverProcess.stderr?.on('data', (data: Buffer) => {
stderrOutput += data.toString();
});

serverProcess.stdout?.on('data', (data: Buffer) => {
stdoutOutput += data.toString();
});

serverProcess.on('exit', (code: number) => {
exitedEarly = true;
exitCode = code;
});

serverProcess.on('error', (error) => {
exitedEarly = true;
stderrOutput += `Process error: ${String(error)}\n`;
});

// Wait for server startup
await new Promise((r) => setTimeout(r, STARTUP_DELAY_MS));

// Test the kebab-case component URL with /c- prefix
const componentKebabName = toKebabCase(componentName);
const componentUrl = `http://localhost:${DEV_SERVER_PORT}/c-${componentKebabName}/`;
let componentHttpSuccess = false;

try {
const componentResponse = await axios.get(componentUrl, { timeout: 2000 });
componentHttpSuccess = componentResponse.status === 200;
} catch (error) {
const err = error as { message?: string };
stderrOutput += `Component URL HTTP request failed: ${err.message ?? 'Unknown error'}\n`;
componentHttpSuccess = false;
}

// Clean up
try {
if (serverProcess.pid && process.kill(serverProcess.pid, 0)) {
process.kill(serverProcess.pid, 'SIGKILL');
}
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== 'ESRCH') throw error;
}

// Stderr error check
const criticalPatterns = [
'FATAL',
'Cannot find module',
'ENOENT',
'Unable to find component',
'command lightning:dev:component not found',
];
const hasCriticalError = criticalPatterns.some((pattern) => stderrOutput.includes(pattern));

expect(
exitedEarly,
`Dev server exited early with code ${exitCode}. Full stderr: ${stderrOutput}. Full stdout: ${stdoutOutput}`
).to.be.false;
expect(hasCriticalError, `Critical stderr output detected:\n${stderrOutput}`).to.be.false;
expect(
componentHttpSuccess,
`Dev server did not respond with HTTP 200 for component URL. Tried URL: ${componentUrl}`
).to.be.true;
});
});
23 changes: 23 additions & 0 deletions test/commands/lightning/dev/helpers/devServerUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import path from 'node:path';
import { spawn, ChildProcess } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const currentFile = fileURLToPath(import.meta.url);
const currentDir = path.dirname(currentFile);
const pluginRoot = path.resolve(currentDir, '../../../../..');

export const startLightningDevServer = (projectDir: string, componentName: string): ChildProcess => {
const devScriptPath = path.join(pluginRoot, 'bin', 'run.js');

return spawn('node', [devScriptPath, 'lightning', 'dev', 'component', '--name', componentName], {
cwd: projectDir,
env: { ...process.env, NODE_ENV: 'production', PORT: '3000', OPEN_BROWSER: process.env.OPEN_BROWSER ?? 'false' },
});
};
70 changes: 70 additions & 0 deletions test/commands/lightning/dev/helpers/projectSetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';

const currentFile = fileURLToPath(import.meta.url);
const currentDir = path.dirname(currentFile);

const TEMPLATE_DIR = path.resolve(currentDir, '../testdata/lwc/helloWorld');
const SCRATCH_DEF_PATH = path.resolve(currentDir, '../testdata/project-definition.json');

let templateCache: { js: string; html: string; meta: string } | null = null;

const loadTemplateContent = async (): Promise<{ js: string; html: string; meta: string }> => {
if (!templateCache) {
const [js, html, meta] = await Promise.all([
fs.promises.readFile(path.join(TEMPLATE_DIR, 'helloWorld.js'), 'utf8'),
fs.promises.readFile(path.join(TEMPLATE_DIR, 'helloWorld.html'), 'utf8'),
fs.promises.readFile(path.join(TEMPLATE_DIR, 'helloWorld.js-meta.xml'), 'utf8'),
]);
templateCache = { js, html, meta };
}
return templateCache;
};

export const createSfdxProject = async (projectDir: string, customInstanceUrl: string): Promise<void> => {
const sfdxProject = {
packageDirectories: [{ path: 'force-app', default: true }],
name: 'temp-project',
namespace: '',
instanceUrl: customInstanceUrl,
sourceApiVersion: '60.0',
};

// Parallel operations: create directories and read scratch def
const [, scratchDefContent] = await Promise.all([
Promise.all([
fs.promises.mkdir(path.join(projectDir, 'force-app', 'main', 'default', 'lwc'), { recursive: true }),
fs.promises.mkdir(path.join(projectDir, 'config'), { recursive: true }),
]),
fs.promises.readFile(SCRATCH_DEF_PATH, 'utf8'),
]);

await Promise.all([
fs.promises.writeFile(path.join(projectDir, 'sfdx-project.json'), JSON.stringify(sfdxProject, null, 2)),
fs.promises.writeFile(path.join(projectDir, 'config', 'project-scratch-def.json'), scratchDefContent),
]);
};

export const createLwcComponent = async (projectDir: string, name: string): Promise<void> => {
const lwcPath = path.join(projectDir, 'force-app', 'main', 'default', 'lwc', name);

const [, templates] = await Promise.all([fs.promises.mkdir(lwcPath, { recursive: true }), loadTemplateContent()]);

await Promise.all([
fs.promises.writeFile(path.join(lwcPath, `${name}.js`), templates.js.replace(/helloWorld/g, name)),
fs.promises.writeFile(path.join(lwcPath, `${name}.html`), templates.html),
fs.promises.writeFile(path.join(lwcPath, `${name}.js-meta.xml`), templates.meta),
]);
};

export const clearTemplateCache = (): void => {
templateCache = null;
};
11 changes: 11 additions & 0 deletions test/commands/lightning/dev/helpers/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
* Copyright (c) 2025, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
export function toKebabCase(str: string): string {
return str
.replace(/([a-z0-9])([A-Z])/g, '$1-$2') // insert dash between camelCase boundaries
.toLowerCase();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<template>
<h1>{greeting}</h1>
<button onclick="{handleClick}">Toggle</button>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { LightningElement } from 'lwc';
export default class helloWorld extends LightningElement {
greeting = 'Hello, World!';
handleClick() {
this.greeting = this.greeting === 'Hello, World!' ? 'Hi again!' : 'Hello, World!';
}
}
Loading
Loading