Skip to content

Commit fa4af8b

Browse files
authored
feat(dev-server): integrate with preview app command (#49)
* chore: first pass * chore: first commit * fix: use esmock for testing * chore: bump lwc-dev-server version * chore: adds test * chore: handle sfdx--project config * chore: address feedback * chore: merge base branch * test: e2e * chore: rename spy * chore: add comments
1 parent 0b0a3ee commit fa4af8b

File tree

12 files changed

+851
-74
lines changed

12 files changed

+851
-74
lines changed

.mocharc.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@
44
"recursive": true,
55
"reporter": "spec",
66
"timeout": 600000,
7-
"node-option": ["loader=ts-node/esm"]
7+
"node-option": ["loader=ts-node/esm", "loader=esmock"]
88
}

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,4 @@ FLAG DESCRIPTIONS
183183
This person can be anyone in the world!
184184
```
185185

186-
_See code: [src/commands/hello/world.ts](https://github.com/salesforcecli/plugin-lightning-dev/blob/1.0.25/src/commands/hello/world.ts)_
187-
188186
<!-- commandsstop -->

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
"bugs": "https://github.com/forcedotcom/cli/issues",
77
"dependencies": {
88
"@lwrjs/api": "0.13.0-alpha.19",
9+
"@lwc/lwc-dev-server": "^7.1.3-6.6.7",
10+
"@lwc/sfdc-lwc-compiler": "^7.1.3-6.6.7",
911
"@oclif/core": "^3.26.6",
1012
"@salesforce/core": "^7.3.9",
1113
"@salesforce/kit": "^3.1.2",
@@ -26,6 +28,7 @@
2628
"@types/node-fetch": "^2.6.11",
2729
"@types/tar": "^6.1.13",
2830
"eslint-plugin-sf-plugin": "^1.18.5",
31+
"esmock": "^2.6.5",
2932
"oclif": "^4.12.3",
3033
"ts-node": "^10.9.2",
3134
"typescript": "^5.4.5"
@@ -203,7 +206,7 @@
203206
"output": []
204207
},
205208
"link-check": {
206-
"command": "node -e \"process.exit(process.env.CI ? 0 : 1)\" || linkinator \"**/*.md\" --skip \"CHANGELOG.md|node_modules|test/|confluence.internal.salesforce.com|world.ts|my.salesforce.com|%s\" --markdown --retry --directory-listing --verbosity error",
209+
"command": "node -e \"process.exit(process.env.CI ? 0 : 1)\" || linkinator \"**/*.md\" --skip \"CHANGELOG.md|node_modules|test/|confluence.internal.salesforce.com|my.salesforce.com|%s\" --markdown --retry --directory-listing --verbosity error",
207210
"files": [
208211
"./*.md",
209212
"./!(CHANGELOG).md",
@@ -214,4 +217,4 @@
214217
},
215218
"exports": "./lib/index.js",
216219
"type": "module"
217-
}
220+
}

src/commands/lightning/preview/app.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import { Flags, SfCommand } from '@salesforce/sf-plugins-core';
2020
import chalk from 'chalk';
2121
import { OrgUtils } from '../../../shared/orgUtils.js';
22+
import { startLWCServer } from '../../../lwc-dev-server/index.js';
2223
import { PreviewUtils } from '../../../shared/previewUtils.js';
2324

2425
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
@@ -199,6 +200,9 @@ export default class LightningPreviewApp extends SfCommand<void> {
199200

200201
const launchArguments = PreviewUtils.generateDesktopPreviewLaunchArguments(ldpServerUrl, appId, targetOrg);
201202

203+
// Start the LWC Dev Server
204+
await startLWCServer(process.cwd(), logger ? logger : await Logger.child(this.ctor.name));
205+
202206
await this.config.runCommand('org:open', launchArguments);
203207
}
204208

@@ -276,6 +280,8 @@ export default class LightningPreviewApp extends SfCommand<void> {
276280
}
277281
}
278282

283+
// Start the LWC Dev Server
284+
await startLWCServer(process.cwd(), logger ? logger : await Logger.child(this.ctor.name));
279285
// 7. Launch the native app for previewing (launchMobileApp will show its own spinner)
280286
// eslint-disable-next-line camelcase
281287
appConfig.launch_arguments = PreviewUtils.generateMobileAppPreviewLaunchArguments(ldpServerUrl, appName, appId);

src/lwc-dev-server/index.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* Copyright (c) 2023, 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 { existsSync, lstatSync, readFileSync } from 'node:fs';
9+
import path from 'node:path';
10+
import process from 'node:process';
11+
import { LWCServer, LogLevel, ServerConfig, Workspace, startLwcDevServer } from '@lwc/lwc-dev-server';
12+
import { Logger } from '@salesforce/core';
13+
14+
const DEV_SERVER_PORT = 8081;
15+
16+
/**
17+
* Map sf cli log level to lwc dev server log level
18+
* https://github.com/salesforcecli/cli/wiki/Code-Your-Plugin#logging-levels
19+
*
20+
* @param cliLogLevel
21+
* @returns number
22+
*/
23+
function mapLogLevel(cliLogLevel: number): number {
24+
switch (cliLogLevel) {
25+
case 10:
26+
return LogLevel.verbose;
27+
case 20:
28+
return LogLevel.debug;
29+
case 30:
30+
return LogLevel.info;
31+
case 40:
32+
return LogLevel.warn;
33+
case 50:
34+
return LogLevel.error;
35+
case 60:
36+
return LogLevel.silent;
37+
default:
38+
return LogLevel.error;
39+
}
40+
}
41+
42+
function createLWCServerConfig(rootDir: string, logger: Logger): ServerConfig {
43+
const sfdxConfig = path.resolve(rootDir, 'sfdx-project.json');
44+
45+
if (!existsSync(sfdxConfig) || !lstatSync(sfdxConfig).isFile()) {
46+
throw new Error(`sfdx-project.json not found in ${rootDir}`);
47+
}
48+
49+
const sfdxConfigJson = readFileSync(sfdxConfig, 'utf-8');
50+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
51+
const { packageDirectories } = JSON.parse(sfdxConfigJson);
52+
const namespacePaths: string[] = [];
53+
54+
for (const dir of packageDirectories) {
55+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
56+
if (dir.path) {
57+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument
58+
const resolvedDir = path.resolve(rootDir, dir.path, 'main', 'default');
59+
if (existsSync(resolvedDir) && lstatSync(resolvedDir).isDirectory()) {
60+
logger.debug(`Adding ${resolvedDir} to namespace paths`);
61+
namespacePaths.push(resolvedDir);
62+
} else {
63+
logger.warn(`Skipping ${resolvedDir} because it does not exist or is not a directory`);
64+
}
65+
}
66+
}
67+
68+
return {
69+
rootDir,
70+
port: DEV_SERVER_PORT,
71+
protocol: 'wss',
72+
host: 'localhost',
73+
paths: namespacePaths,
74+
workspace: Workspace.SfCli,
75+
targets: ['LEX'], // should this be something else?
76+
logLevel: mapLogLevel(logger.getLevel()),
77+
};
78+
}
79+
80+
export async function startLWCServer(rootDir: string, logger: Logger): Promise<LWCServer> {
81+
const config = createLWCServerConfig(rootDir, logger);
82+
logger.trace(`Starting LWC Dev Server with config: ${JSON.stringify(config)}`);
83+
let lwcDevServer: LWCServer | null = await startLwcDevServer(config);
84+
85+
const cleanup = (): void => {
86+
if (lwcDevServer) {
87+
logger.trace('Stopping LWC Dev Server');
88+
lwcDevServer.stopServer();
89+
lwcDevServer = null;
90+
}
91+
};
92+
93+
[
94+
'exit', // normal exit flow
95+
'SIGINT', // when a user presses ctrl+c
96+
'SIGTERM', // when a user kills the process
97+
].forEach((signal) => process.on(signal, cleanup));
98+
99+
return lwcDevServer;
100+
}

test/commands/lightning/preview/app.test.ts

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from '@salesforce/lwc-dev-mobile-core';
1818
import { stubSpinner, stubUx } from '@salesforce/sf-plugins-core';
1919
import { expect } from 'chai';
20+
import esmock from 'esmock';
2021
import sinon from 'sinon';
2122
import LightningPreviewApp, {
2223
androidSalesforceAppPreviewConfig,
@@ -49,11 +50,20 @@ describe('lightning preview app', () => {
4950
'34'
5051
);
5152
const testEmulatorPort = 1234;
53+
let MockedLightningPreviewApp: typeof LightningPreviewApp;
5254

5355
beforeEach(async () => {
5456
stubUx($$.SANDBOX);
5557
stubSpinner($$.SANDBOX);
5658
await $$.stubAuths(testOrgData);
59+
MockedLightningPreviewApp = await esmock<typeof LightningPreviewApp>(
60+
'../../../../src/commands/lightning/preview/app.js',
61+
{
62+
'../../../../src/lwc-dev-server/index.js': {
63+
startLWCServer: async () => ({ stopServer: () => {} }),
64+
},
65+
}
66+
);
5767
});
5868

5969
afterEach(() => {
@@ -63,7 +73,7 @@ describe('lightning preview app', () => {
6373
it('throws when app not found', async () => {
6474
try {
6575
$$.SANDBOX.stub(OrgUtils, 'getAppId').resolves(undefined);
66-
await LightningPreviewApp.run(['--name', 'blah', '-o', testOrgData.username]);
76+
await MockedLightningPreviewApp.run(['--name', 'blah', '-o', testOrgData.username]);
6777
} catch (err) {
6878
expect(err)
6979
.to.be.an('error')
@@ -77,7 +87,7 @@ describe('lightning preview app', () => {
7787
$$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').throws(
7888
new Error('Cannot determine LDP url.')
7989
);
80-
await LightningPreviewApp.run(['--name', 'Sales', '-o', testOrgData.username]);
90+
await MockedLightningPreviewApp.run(['--name', 'Sales', '-o', testOrgData.username]);
8191
} catch (err) {
8292
expect(err).to.be.an('error').with.property('message', 'Cannot determine LDP url.');
8393
}
@@ -97,9 +107,9 @@ describe('lightning preview app', () => {
97107
$$.SANDBOX.stub(PreviewUtils, 'generateWebSocketUrlForLocalDevServer').returns(testServerUrl);
98108
const runCmdStub = $$.SANDBOX.stub(Config.prototype, 'runCommand').resolves();
99109
if (appName) {
100-
await LightningPreviewApp.run(['--name', appName, '-o', testOrgData.username]);
110+
await MockedLightningPreviewApp.run(['--name', appName, '-o', testOrgData.username]);
101111
} else {
102-
await LightningPreviewApp.run(['-o', testOrgData.username]);
112+
await MockedLightningPreviewApp.run(['-o', testOrgData.username]);
103113
}
104114

105115
expect(runCmdStub.calledOnce);
@@ -192,7 +202,10 @@ describe('lightning preview app', () => {
192202
const expectedCertFilePath = '/path/to/cert.pem';
193203
$$.SANDBOX.stub(PreviewUtils, 'generateSelfSignedCert').returns(expectedCertFilePath);
194204

195-
const waitForUserToInstallCertStub = $$.SANDBOX.stub(LightningPreviewApp, 'waitForUserToInstallCert').resolves();
205+
const waitForUserToInstallCertStub = $$.SANDBOX.stub(
206+
MockedLightningPreviewApp,
207+
'waitForUserToInstallCert'
208+
).resolves();
196209

197210
$$.SANDBOX.stub(PreviewUtils, 'verifyMobileAppInstalled').resolves(true);
198211
$$.SANDBOX.stub(PreviewUtils, 'launchMobileApp').resolves();
@@ -225,10 +238,10 @@ describe('lightning preview app', () => {
225238
const expectedCertFilePath = '/path/to/cert.pem';
226239
$$.SANDBOX.stub(PreviewUtils, 'generateSelfSignedCert').returns(expectedCertFilePath);
227240

228-
$$.SANDBOX.stub(LightningPreviewApp, 'waitForUserToInstallCert').resolves();
241+
$$.SANDBOX.stub(MockedLightningPreviewApp, 'waitForUserToInstallCert').resolves();
229242

230243
const verifyMobileAppInstalledStub = $$.SANDBOX.stub(PreviewUtils, 'verifyMobileAppInstalled').resolves(false);
231-
$$.SANDBOX.stub(LightningPreviewApp.prototype, 'confirm').resolves(false);
244+
$$.SANDBOX.stub(MockedLightningPreviewApp.prototype, 'confirm').resolves(false);
232245

233246
await verifyMobileThrowsWhenUserDeclinesToInstallApp(Platform.ios, verifyMobileAppInstalledStub);
234247
await verifyMobileThrowsWhenUserDeclinesToInstallApp(Platform.android, verifyMobileAppInstalledStub);
@@ -250,10 +263,10 @@ describe('lightning preview app', () => {
250263
const expectedCertFilePath = '/path/to/cert.pem';
251264
$$.SANDBOX.stub(PreviewUtils, 'generateSelfSignedCert').returns(expectedCertFilePath);
252265

253-
$$.SANDBOX.stub(LightningPreviewApp, 'waitForUserToInstallCert').resolves();
266+
$$.SANDBOX.stub(MockedLightningPreviewApp, 'waitForUserToInstallCert').resolves();
254267

255268
$$.SANDBOX.stub(PreviewUtils, 'verifyMobileAppInstalled').resolves(false);
256-
$$.SANDBOX.stub(LightningPreviewApp.prototype, 'confirm').resolves(true);
269+
$$.SANDBOX.stub(MockedLightningPreviewApp.prototype, 'confirm').resolves(true);
257270

258271
const iosBundlePath = '/path/to/bundle.zip';
259272
const androidBundlePath = '/path/to/bundle.apk';
@@ -269,15 +282,24 @@ describe('lightning preview app', () => {
269282

270283
async function verifyMobileThrowsWithUnmetRequirements(platform: Platform.ios | Platform.android) {
271284
try {
272-
await LightningPreviewApp.run(['-n', 'Sales', '-o', testOrgData.username, '-t', platform]);
285+
await MockedLightningPreviewApp.run(['-n', 'Sales', '-o', testOrgData.username, '-t', platform]);
273286
} catch (err) {
274287
expect(err).to.be.an('error').with.property('message').that.contains('Requirement blah not met');
275288
}
276289
}
277290

278291
async function verifyMobileThrowsWhenDeviceNotFound(platform: Platform.ios | Platform.android) {
279292
try {
280-
await LightningPreviewApp.run(['-n', 'Sales', '-o', testOrgData.username, '-t', platform, '-i', 'some_device']);
293+
await MockedLightningPreviewApp.run([
294+
'-n',
295+
'Sales',
296+
'-o',
297+
testOrgData.username,
298+
'-t',
299+
platform,
300+
'-i',
301+
'some_device',
302+
]);
281303
} catch (err) {
282304
expect(err)
283305
.to.be.an('error')
@@ -290,7 +312,7 @@ describe('lightning preview app', () => {
290312
bootStub: sinon.SinonStub
291313
) {
292314
try {
293-
await LightningPreviewApp.run(['-n', 'Sales', '-o', testOrgData.username, '-t', platform]);
315+
await MockedLightningPreviewApp.run(['-n', 'Sales', '-o', testOrgData.username, '-t', platform]);
294316
} catch (err) {
295317
expect(err).to.be.an('error').with.property('message', 'Failed to boot device');
296318

@@ -308,7 +330,7 @@ describe('lightning preview app', () => {
308330

309331
async function verifyMobileThrowsWhenFailedToGenerateCert(platform: Platform.ios | Platform.android) {
310332
try {
311-
await LightningPreviewApp.run(['-n', 'Sales', '-o', testOrgData.username, '-t', platform]);
333+
await MockedLightningPreviewApp.run(['-n', 'Sales', '-o', testOrgData.username, '-t', platform]);
312334
} catch (err) {
313335
expect(err).to.be.an('error').with.property('message', 'Failed to generate certificate');
314336
}
@@ -320,7 +342,7 @@ describe('lightning preview app', () => {
320342
waitForUserToInstallCertStub: sinon.SinonStub
321343
) {
322344
const expectedDevice = platform === Platform.ios ? testIOSDevice : testAndroidDevice;
323-
await LightningPreviewApp.run(['-n', 'Sales', '-o', testOrgData.username, '-t', platform]);
345+
await MockedLightningPreviewApp.run(['-n', 'Sales', '-o', testOrgData.username, '-t', platform]);
324346
expect(waitForUserToInstallCertStub.calledWith(platform, expectedDevice, expectedCertFilePath)).to.be.true;
325347
waitForUserToInstallCertStub.resetHistory();
326348
}
@@ -333,7 +355,7 @@ describe('lightning preview app', () => {
333355
const deviceId = platform === Platform.ios ? testIOSDevice.udid : testAndroidDevice.name;
334356

335357
try {
336-
await LightningPreviewApp.run(['-n', 'Sales', '-o', testOrgData.username, '-t', platform]);
358+
await MockedLightningPreviewApp.run(['-n', 'Sales', '-o', testOrgData.username, '-t', platform]);
337359
} catch (err) {
338360
expect(err)
339361
.to.be.an('error')
@@ -362,9 +384,13 @@ describe('lightning preview app', () => {
362384
const expectedAppConfig =
363385
platform === Platform.ios ? iOSSalesforceAppPreviewConfig : androidSalesforceAppPreviewConfig;
364386
// eslint-disable-next-line camelcase
365-
expectedAppConfig.launch_arguments = PreviewUtils.generateMobileAppPreviewLaunchArguments(expectedLdpServerUrl);
387+
expectedAppConfig.launch_arguments = PreviewUtils.generateMobileAppPreviewLaunchArguments(
388+
expectedLdpServerUrl,
389+
'Sales',
390+
testAppId
391+
);
366392

367-
await LightningPreviewApp.run(['-n', 'Sales', '-o', testOrgData.username, '-t', platform]);
393+
await MockedLightningPreviewApp.run(['-n', 'Sales', '-o', testOrgData.username, '-t', platform]);
368394
expect(downloadStub.calledOnce).to.be.true;
369395

370396
if (platform === Platform.ios) {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// Intentionally blank file.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// Intentionally blank file.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"packageDirectories": [
3+
{
4+
"path": "force-app"
5+
}
6+
]
7+
}

0 commit comments

Comments
 (0)