From f3fc413966cf7d022c4c70c71e7debac0347174c Mon Sep 17 00:00:00 2001 From: Brian Buchanan Date: Mon, 16 Jun 2025 14:39:38 -0400 Subject: [PATCH] feat: move component preview to org --- src/commands/lightning/dev/app.ts | 43 +---- src/commands/lightning/dev/component.ts | 83 ++++++---- src/lwc-dev-server/index.ts | 4 +- src/shared/previewUtils.ts | 100 +++++++++++- test/shared/previewUtils.test.ts | 206 +++++++++++++++++++++++- 5 files changed, 364 insertions(+), 72 deletions(-) diff --git a/src/commands/lightning/dev/app.ts b/src/commands/lightning/dev/app.ts index e1bf9e92..0861e89c 100644 --- a/src/commands/lightning/dev/app.ts +++ b/src/commands/lightning/dev/app.ts @@ -17,7 +17,6 @@ import { Platform, } from '@salesforce/lwc-dev-mobile-core'; import { Flags, SfCommand } from '@salesforce/sf-plugins-core'; -import { OrgUtils } from '../../../shared/orgUtils.js'; import { startLWCServer } from '../../../lwc-dev-server/index.js'; import { PreviewUtils } from '../../../shared/previewUtils.js'; import { PromptUtils } from '../../../shared/promptUtils.js'; @@ -77,29 +76,11 @@ export default class LightningDevApp extends SfCommand { throw new Error(sharedMessages.getMessage('error.no-project', [(error as Error)?.message ?? ''])); } - const connection = targetOrg.getConnection(undefined); - const username = connection.getUsername(); - if (!username) { - throw new Error(sharedMessages.getMessage('error.username')); - } - - const localDevEnabled = await OrgUtils.isLocalDevEnabled(connection); - if (!localDevEnabled) { - throw new Error(sharedMessages.getMessage('error.localdev.not.enabled')); - } - - OrgUtils.ensureMatchingAPIVersion(connection); + logger.debug('Initalizing preview connection and configuring local web server identity'); + const { connection, ldpServerId, ldpServerToken } = await PreviewUtils.initializePreviewConnection(targetOrg); const platform = flags['device-type'] ?? (await PromptUtils.promptUserToSelectPlatform()); - logger.debug('Configuring local web server identity'); - const appServerIdentity = await PreviewUtils.getOrCreateAppServerIdentity(connection); - const ldpServerToken = appServerIdentity.identityToken; - const ldpServerId = appServerIdentity.usernameToServerEntityIdMap[username]; - if (!ldpServerId) { - throw new Error(sharedMessages.getMessage('error.identitydata.entityid')); - } - const appId = await PreviewUtils.getLightningExperienceAppId(connection, appName, logger); logger.debug('Determining the next available port for Local Dev Server'); @@ -149,25 +130,7 @@ export default class LightningDevApp extends SfCommand { logger.debug('No Lightning Experience application name provided.... using the default app instead.'); } - // There are various ways to pass in a target org (as an alias, as a username, etc). - // We could have LightningPreviewApp parse its --target-org flag which will be resolved - // to an Org object (see https://github.com/forcedotcom/sfdx-core/blob/main/src/org/org.ts) - // then write a bunch of code to look at this Org object to try to determine whether - // it was initialized using Alias, Username, etc. and get a string representation of the - // org to be forwarded to OrgOpenCommand. - // - // Or we could simply look at the raw arguments passed to the LightningPreviewApp command, - // find the raw value for --target-org flag and forward that raw value to OrgOpenCommand. - // The OrgOpenCommand will then parse the raw value automatically. If the value is - // valid then OrgOpenCommand will consume it and continue. And if the value is invalid then - // OrgOpenCommand simply throws an error which will get bubbled up to LightningPreviewApp. - // - // Here we've chosen the second approach - const idx = this.argv.findIndex((item) => item.toLowerCase() === '-o' || item.toLowerCase() === '--target-org'); - let targetOrg: string | undefined; - if (idx >= 0 && idx < this.argv.length - 1) { - targetOrg = this.argv[idx + 1]; - } + const targetOrg = PreviewUtils.getTargetOrgFromArguments(this.argv); if (ldpServerUrl.startsWith('wss')) { this.log(`\n${messages.getMessage('trust.local.dev.server')}`); diff --git a/src/commands/lightning/dev/component.ts b/src/commands/lightning/dev/component.ts index 76278bca..97a1fab4 100644 --- a/src/commands/lightning/dev/component.ts +++ b/src/commands/lightning/dev/component.ts @@ -6,15 +6,17 @@ */ import path from 'node:path'; -import url from 'node:url'; import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; -import { Messages, SfProject } from '@salesforce/core'; -import { cmpDev } from '@lwrjs/api'; +import { Messages, SfProject, Logger } from '@salesforce/core'; +import { Platform } from '@salesforce/lwc-dev-mobile-core'; import { ComponentUtils } from '../../../shared/componentUtils.js'; import { PromptUtils } from '../../../shared/promptUtils.js'; +import { PreviewUtils } from '../../../shared/previewUtils.js'; +import { startLWCServer } from '../../../lwc-dev-server/index.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'lightning.dev.component'); +const sharedMessages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'shared.utils'); export default class LightningDevComponent extends SfCommand { public static readonly summary = messages.getMessage('summary'); @@ -32,15 +34,37 @@ export default class LightningDevComponent extends SfCommand { char: 'c', default: false, }), - // TODO should this be required or optional? - // We don't technically need this if your components are simple / don't need any data from your org - 'target-org': Flags.optionalOrg(), + 'target-org': Flags.requiredOrg(), }; public async run(): Promise { const { flags } = await this.parse(LightningDevComponent); + const logger = await Logger.child(this.ctor.name); const project = await SfProject.resolve(); + let sfdxProjectRootPath = ''; + try { + sfdxProjectRootPath = await SfProject.resolveProjectPath(); + } catch (error) { + return Promise.reject( + new Error(sharedMessages.getMessage('error.no-project', [(error as Error)?.message ?? ''])) + ); + } + + let componentName = flags['name']; + const clientSelect = flags['client-select']; + const targetOrg = flags['target-org']; + + const { ldpServerId, ldpServerToken } = await PreviewUtils.initializePreviewConnection(targetOrg); + + logger.debug('Determining the next available port for Local Dev Server'); + const serverPorts = await PreviewUtils.getNextAvailablePorts(); + logger.debug(`Next available ports are http=${serverPorts.httpPort} , https=${serverPorts.httpsPort}`); + + logger.debug('Determining Local Dev Server url'); + const ldpServerUrl = PreviewUtils.generateWebSocketUrlForLocalDevServer(Platform.desktop, serverPorts, logger); + logger.debug(`Local Dev Server url is ${ldpServerUrl}`); + const namespacePaths = await ComponentUtils.getNamespacePaths(project); const componentPaths = await ComponentUtils.getAllComponentPaths(namespacePaths); if (!componentPaths) { @@ -63,11 +87,11 @@ export default class LightningDevComponent extends SfCommand { return undefined; } - const componentName = path.basename(componentPath); - const label = ComponentUtils.componentNameToTitleCase(componentName); + const name = path.basename(componentPath); + const label = ComponentUtils.componentNameToTitleCase(name); return { - name: componentName, + name, label: xml.LightningComponentBundle.masterLabel ?? label, description: xml.LightningComponentBundle.description ?? '', }; @@ -75,36 +99,37 @@ export default class LightningDevComponent extends SfCommand { ) ).filter((component) => !!component); - let name = flags.name; - if (!flags['client-select']) { - if (name) { + if (!clientSelect) { + if (componentName) { // validate that the component exists before launching the server - const match = components.find((component) => name === component.name || name === component.label); + const match = components.find( + (component) => componentName === component.name || componentName === component.label + ); if (!match) { - throw new Error(messages.getMessage('error.component-not-found', [name])); + throw new Error(messages.getMessage('error.component-not-found', [componentName])); } - name = match.name; + componentName = match.name; } else { // prompt the user for a name if one was not provided - name = await PromptUtils.promptUserToSelectComponent(components); - if (!name) { + componentName = await PromptUtils.promptUserToSelectComponent(components); + if (!componentName) { throw new Error(messages.getMessage('error.component')); } } } - const dirname = path.dirname(url.fileURLToPath(import.meta.url)); - const rootDir = path.resolve(dirname, '../../../..'); - const port = parseInt(process.env.PORT ?? '3000', 10); - - await cmpDev({ - rootDir, - mode: 'dev', - port, - name: name ? `c/${name}` : undefined, - namespacePaths, - open: process.env.OPEN_BROWSER === 'false' ? false : true, - }); + await startLWCServer(logger, sfdxProjectRootPath, ldpServerToken, Platform.desktop, serverPorts); + + const targetOrgArg = PreviewUtils.getTargetOrgFromArguments(this.argv); + const launchArguments = PreviewUtils.generateComponentPreviewLaunchArguments( + ldpServerUrl, + ldpServerId, + componentName, + targetOrgArg + ); + + // Open the browser and navigate to the right page + await this.config.runCommand('org:open', launchArguments); } } diff --git a/src/lwc-dev-server/index.ts b/src/lwc-dev-server/index.ts index a3e6b8fb..f6c75b81 100644 --- a/src/lwc-dev-server/index.ts +++ b/src/lwc-dev-server/index.ts @@ -30,7 +30,9 @@ async function createLWCServerConfig( const { namespace } = projectJson; // e.g. lwc folders in force-app/main/default/lwc, package-dir/lwc - const namespacePaths = (await Promise.all(packageDirs.map((dir) => glob(`${dir.fullPath}/**/lwc`, { absolute: true })))).flat(); + const namespacePaths = ( + await Promise.all(packageDirs.map((dir) => glob(`${dir.fullPath}/**/lwc`, { absolute: true }))) + ).flat(); const ports = serverPorts ?? (await ConfigUtils.getLocalDevServerPorts()) ?? { diff --git a/src/shared/previewUtils.ts b/src/shared/previewUtils.ts index aa7e573a..64ff30a1 100644 --- a/src/shared/previewUtils.ts +++ b/src/shared/previewUtils.ts @@ -13,7 +13,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { Connection, Logger, Messages } from '@salesforce/core'; +import { Connection, Logger, Messages, Org } from '@salesforce/core'; import { AndroidDeviceManager, AppleDeviceManager, @@ -33,8 +33,15 @@ import { PromptUtils } from './promptUtils.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'lightning.dev.app'); +const sharedMessages = Messages.loadMessages('@salesforce/plugin-lightning-dev', 'shared.utils'); const DevPreviewAuraMode = 'DEVPREVIEW'; +export type PreviewConnection = { + connection: Connection; + ldpServerId: string; + ldpServerToken: string; +}; + export class PreviewUtils { public static generateWebSocketUrlForLocalDevServer( platform: string, @@ -139,6 +146,37 @@ export class PreviewUtils { } } + /** + * Extracts the target org from command line arguments. + * + * There are various ways to pass in a target org (as an alias, as a username, etc). + * We could have LightningPreviewApp parse its --target-org flag which will be resolved + * to an Org object (see https://github.com/forcedotcom/sfdx-core/blob/main/src/org/org.ts) + * then write a bunch of code to look at this Org object to try to determine whether + * it was initialized using Alias, Username, etc. and get a string representation of the + * org to be forwarded to OrgOpenCommand. + * + * Or we could simply look at the raw arguments passed to the LightningPreviewApp command, + * find the raw value for --target-org flag and forward that raw value to OrgOpenCommand. + * The OrgOpenCommand will then parse the raw value automatically. If the value is + * valid then OrgOpenCommand will consume it and continue. And if the value is invalid then + * OrgOpenCommand simply throws an error which will get bubbled up to LightningPreviewApp. + * + * Here we've chosen the second approach. + * + * @param args - Array of command line arguments + * @returns The target org identifier if found, undefined otherwise + */ + public static getTargetOrgFromArguments(args: string[]): string | undefined { + const idx = args.findIndex((item) => item.toLowerCase() === '-o' || item.toLowerCase() === '--target-org'); + let targetOrg: string | undefined; + if (idx >= 0 && idx < args.length - 1) { + targetOrg = args[idx + 1]; + } + + return targetOrg; + } + /** * Generates the proper set of arguments to be used for launching desktop browser and navigating to the right location. * @@ -176,6 +214,38 @@ export class PreviewUtils { return launchArguments; } + /** + * Generates the proper set of arguments to be used for launching a component preview in the browser. + * + * @param ldpServerUrl The URL for the local dev server + * @param ldpServerId Record ID for the identity token + * @param componentName The name of the component to preview + * @param targetOrg An optional org id + * @returns Array of arguments to be used by Org:Open command for launching the component preview + */ + public static generateComponentPreviewLaunchArguments( + ldpServerUrl: string, + ldpServerId: string, + componentName?: string, + targetOrg?: string + ): string[] { + let appPath = `lwr/application/e/devpreview/ai/${encodeURIComponent( + 'localdev%2Fpreview' + )}?ldpServerUrl=${ldpServerUrl}&ldpServerId=${ldpServerId}`; + if (componentName) { + // TODO: support other namespaces + appPath += `&specifier=c/${componentName}`; + } + + const launchArguments = ['--path', appPath]; + + if (targetOrg) { + launchArguments.push('--target-org', targetOrg); + } + + return launchArguments; + } + /** * Generates the proper set of arguments to be used for launching a mobile app with custom launch arguments. * @@ -324,6 +394,34 @@ export class PreviewUtils { }); } + public static async initializePreviewConnection(targetOrg: Org): Promise { + const connection = targetOrg.getConnection(undefined); + const username = connection.getUsername(); + if (!username) { + return Promise.reject(new Error(sharedMessages.getMessage('error.username'))); + } + + const localDevEnabled = await OrgUtils.isLocalDevEnabled(connection); + if (!localDevEnabled) { + return Promise.reject(new Error(sharedMessages.getMessage('error.localdev.not.enabled'))); + } + + OrgUtils.ensureMatchingAPIVersion(connection); + + const appServerIdentity = await PreviewUtils.getOrCreateAppServerIdentity(connection); + const ldpServerToken = appServerIdentity.identityToken; + const ldpServerId = appServerIdentity.usernameToServerEntityIdMap[username]; + if (!ldpServerId) { + return Promise.reject(new Error(sharedMessages.getMessage('error.identitydata.entityid'))); + } + + return { + connection, + ldpServerId, + ldpServerToken, + }; + } + public static async getOrCreateAppServerIdentity(connection: Connection): Promise { const username = connection.getUsername()!; diff --git a/test/shared/previewUtils.test.ts b/test/shared/previewUtils.test.ts index 39818ff3..017da981 100644 --- a/test/shared/previewUtils.test.ts +++ b/test/shared/previewUtils.test.ts @@ -5,6 +5,7 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ +import { parseArgs } from 'node:util'; import { TestContext } from '@salesforce/core/testSetup'; import { expect } from 'chai'; import { @@ -21,7 +22,8 @@ import { SSLCertificateData, Version, } from '@salesforce/lwc-dev-mobile-core'; -import { AuthInfo, Connection } from '@salesforce/core'; +import { AuthInfo, Connection, Logger, Org } from '@salesforce/core'; +import { PreviewUtils as LwcDevMobileCorePreviewUtils } from '@salesforce/lwc-dev-mobile-core'; import { ConfigUtils, LOCAL_DEV_SERVER_DEFAULT_HTTP_PORT, @@ -188,4 +190,206 @@ describe('previewUtils', () => { expect(resolved).to.deep.equal(testIdentityData); expect(writeIdentityTokenStub.calledOnce).to.be.true; }); + + it('generateComponentPreviewLaunchArguments with all parameters', async () => { + const result = PreviewUtils.generateComponentPreviewLaunchArguments( + 'https://localhost:3333', + testLdpServerId, + 'myTestComponent', + 'myTargetOrg' + ); + + const parsed = parseArgs({ + args: result, + options: { + path: { type: 'string' }, + 'target-org': { type: 'string' }, + }, + }); + + expect(parsed.values.path).to.include('ldpServerUrl=https://localhost:3333'); + expect(parsed.values.path).to.include(`ldpServerId=${testLdpServerId}`); + expect(parsed.values.path).to.include('specifier=c/myTestComponent'); + expect(parsed.values['target-org']).to.equal('myTargetOrg'); + }); + + it('generateComponentPreviewLaunchArguments without componentName', async () => { + const result = PreviewUtils.generateComponentPreviewLaunchArguments( + 'https://localhost:3333', + testLdpServerId, + undefined, + 'myTargetOrg' + ); + + const parsed = parseArgs({ + args: result, + options: { + path: { type: 'string' }, + 'target-org': { type: 'string' }, + }, + }); + + expect(parsed.values.path).to.include('ldpServerUrl=https://localhost:3333'); + expect(parsed.values.path).to.include(`ldpServerId=${testLdpServerId}`); + expect(parsed.values.path).to.not.include('specifier='); + expect(parsed.values['target-org']).to.equal('myTargetOrg'); + }); + + it('generateComponentPreviewLaunchArguments without targetOrg', async () => { + const result = PreviewUtils.generateComponentPreviewLaunchArguments( + 'https://localhost:3333', + testLdpServerId, + 'myTestComponent' + ); + + const parsed = parseArgs({ + args: result, + options: { + path: { type: 'string' }, + 'target-org': { type: 'string' }, + }, + }); + + expect(parsed.values.path).to.include('ldpServerUrl=https://localhost:3333'); + expect(parsed.values.path).to.include(`ldpServerId=${testLdpServerId}`); + expect(parsed.values.path).to.include('specifier=c/myTestComponent'); + expect(parsed.values['target-org']).to.be.undefined; + }); + + it('generateComponentPreviewLaunchArguments with only required parameters', async () => { + const result = PreviewUtils.generateComponentPreviewLaunchArguments('https://localhost:3333', testLdpServerId); + + const parsed = parseArgs({ + args: result, + options: { + path: { type: 'string' }, + 'target-org': { type: 'string' }, + }, + }); + + expect(parsed.values.path).to.include('ldpServerUrl=https://localhost:3333'); + expect(parsed.values.path).to.include(`ldpServerId=${testLdpServerId}`); + expect(parsed.values.path).to.not.include('specifier='); + expect(parsed.values['target-org']).to.be.undefined; + }); + + it('getTargetOrgFromArguments finds -o flag', async () => { + const args = ['command', '-o', 'myOrg', 'otherArg']; + const result = PreviewUtils.getTargetOrgFromArguments(args); + expect(result).to.equal('myOrg'); + }); + + it('getTargetOrgFromArguments finds --target-org flag', async () => { + const args = ['command', '--target-org', 'myOrg', 'otherArg']; + const result = PreviewUtils.getTargetOrgFromArguments(args); + expect(result).to.equal('myOrg'); + }); + + it('getTargetOrgFromArguments finds --target-org flag case insensitive', async () => { + const args = ['command', '--TARGET-ORG', 'myOrg', 'otherArg']; + const result = PreviewUtils.getTargetOrgFromArguments(args); + expect(result).to.equal('myOrg'); + }); + + it('getTargetOrgFromArguments returns undefined when flag not found', async () => { + const args = ['command', 'otherArg']; + const result = PreviewUtils.getTargetOrgFromArguments(args); + expect(result).to.be.undefined; + }); + + it('getTargetOrgFromArguments returns undefined when flag is last argument', async () => { + const args = ['command', 'otherArg', '--target-org']; + const result = PreviewUtils.getTargetOrgFromArguments(args); + expect(result).to.be.undefined; + }); + + it('generateWebSocketUrlForLocalDevServer delegates to core library', async () => { + const mockUrl = 'ws://localhost:3333'; + const platform = 'iOS'; + const ports = { httpPort: 3333, httpsPort: 3334 }; + + const generateWebSocketUrlStub = $$.SANDBOX.stub( + LwcDevMobileCorePreviewUtils, + 'generateWebSocketUrlForLocalDevServer' + ).returns(mockUrl); + + const result = PreviewUtils.generateWebSocketUrlForLocalDevServer(platform, ports, {} as Logger); + + expect(result).to.equal(mockUrl); + expect(generateWebSocketUrlStub.calledOnceWith(platform, ports, {} as Logger)).to.be.true; + }); + + it('initializePreviewConnection succeeds with valid org', async () => { + const mockOrg = { + getConnection: () => ({ + getUsername: () => testUsername, + }), + } as Org; + + $$.SANDBOX.stub(OrgUtils, 'isLocalDevEnabled').resolves(true); + $$.SANDBOX.stub(OrgUtils, 'ensureMatchingAPIVersion').returns(); + $$.SANDBOX.stub(PreviewUtils, 'getOrCreateAppServerIdentity').resolves(testIdentityData); + + const result = await PreviewUtils.initializePreviewConnection(mockOrg); + + expect(result.ldpServerId).to.equal(testLdpServerId); + expect(result.ldpServerToken).to.equal(testLdpServerToken); + expect(result.connection).to.exist; + }); + + it('initializePreviewConnection rejects when username is not found', async () => { + const mockOrg = { + getConnection: () => ({ + getUsername: () => undefined, + }), + } as Org; + + try { + await PreviewUtils.initializePreviewConnection(mockOrg); + expect.fail('Should have thrown an error'); + } catch (error) { + expect((error as Error).message).to.include('Org must have a valid user'); + } + }); + + it('initializePreviewConnection rejects when local dev is not enabled', async () => { + const mockOrg = { + getConnection: () => ({ + getUsername: () => testUsername, + }), + } as Org; + + $$.SANDBOX.stub(OrgUtils, 'isLocalDevEnabled').resolves(false); + + try { + await PreviewUtils.initializePreviewConnection(mockOrg); + expect.fail('Should have thrown an error'); + } catch (error) { + expect((error as Error).message).to.include('Local Dev is not enabled'); + } + }); + + it('initializePreviewConnection rejects when ldpServerId is not found', async () => { + const mockOrg = { + getConnection: () => ({ + getUsername: () => testUsername, + }), + } as Org; + + const identityDataWithoutEntityId = { + identityToken: testLdpServerToken, + usernameToServerEntityIdMap: {}, + }; + + $$.SANDBOX.stub(OrgUtils, 'isLocalDevEnabled').resolves(true); + $$.SANDBOX.stub(OrgUtils, 'ensureMatchingAPIVersion').returns(); + $$.SANDBOX.stub(PreviewUtils, 'getOrCreateAppServerIdentity').resolves(identityDataWithoutEntityId); + + try { + await PreviewUtils.initializePreviewConnection(mockOrg); + expect.fail('Should have thrown an error'); + } catch (error) { + expect((error as Error).message).to.include('entity ID'); + } + }); });