diff --git a/command-snapshot.json b/command-snapshot.json index 6feaf042..e183a816 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -63,6 +63,27 @@ ], "plugin": "@salesforce/plugin-templates" }, + { + "alias": [], + "command": "template:generate:lightning-out", + "flagAliases": ["outputdir"], + "flagChars": ["d"], + "flags": [ + "app-name", + "components", + "definition-file", + "eca-callback-url", + "eca-contact-email", + "eca-name", + "flags-dir", + "host-domains", + "json", + "loglevel", + "output-dir", + "runtime" + ], + "plugin": "@salesforce/plugin-templates" + }, { "alias": ["force:lightning:app:create", "lightning:generate:app"], "command": "template:generate:lightning:app", diff --git a/messages/lightningOut.md b/messages/lightningOut.md new file mode 100644 index 00000000..a0e64938 --- /dev/null +++ b/messages/lightningOut.md @@ -0,0 +1,105 @@ +# examples + +- Generate a Lightning Out 2.0 app using individual flags to specify the values: + + <%= config.bin %> <%= command.id %> --app-name MyLoApp --runtime LWR_CORE --host-domains https://example.com --eca-name MyLoAppEca --eca-contact-email dev@example.com --eca-callback-url https://example.com/cb + +- Generate an app using the values in a JSON definition file called lo-def.json: + + <%= config.bin %> <%= command.id %> --definition-file lo-def.json + +- Generate the app into a specific directory: + + <%= config.bin %> <%= command.id %> --definition-file lo-def.json --output-dir force-app/main/default + +- Generate the app using most of the values from a definition file, but the --host-domains value overrides its equivalent in the file: + + <%= config.bin %> <%= command.id %> --definition-file lo-def.json --host-domains https://staging.example.com + +# summary + +Generate the required metadata to scaffold a Lightning Out 2.0 app. + +# description + +Lightning Out 2.0 lets you embed custom Lightning web components (LWCs) into your external, non-Salesforce apps. + +This command gets you started by generating the seven metadata artifact types that a Lightning Out 2.0 app requires into your Salesforce DX project: LightningOutApp, MyDomain and Security settings, one CorsWhitelistOrigin per host domain, and the three External Client Application OAuth components (ExternalClientApplication, ExtlClntAppGlobalOauthSettings, ExtlClntAppOauthSettings). The command is generate-only; it doesn't deploy any metadata to an org. + +Inputs may come from a --definition-file JSON, individual flags, or both. Flag values take precedence over the file on a per-key basis. The command validates the types of definition-file fields; the underlying generator performs the remaining structural validation, such as required fields and formats. + +# flags.app-name.summary + +Developer name of the new Lightning Out 2.0 app. + +# flags.eca-name.summary + +Developer name of the External Client Application associated with the app. + +# flags.runtime.summary + +Runtime the app targets. LWR_CORE serves from your Salesforce org for authenticated users; CLWR serves from an Experience Cloud site (guest access, extra site-deployment step). + +# flags.host-domains.summary + +HTTP or HTTPS origin of an external host page that embeds the app. Repeat the flag to specify more than one. Replaces, rather than merges with, any hostDomains in the --definition-file. + +# flags.components.summary + +Name of a Lightning web component exposed by the app. Repeat the flag to specify more than one. Replaces, rather than merges with, any components in the --definition-file. + +# flags.eca-contact-email.summary + +Contact email for the External Client Application. + +# flags.eca-callback-url.summary + +OAuth callback URL for the External Client Application. + +# flags.definition-file.summary + +Path to a JSON file describing the Lightning Out 2.0 app. Individual flags, when supplied, override the corresponding value in this file. + +# error.definition-file-json + +Definition file %s is not valid JSON: %s + +# error.definition-file-not-object + +Definition file %s must contain a single JSON object, not an array or scalar. + +# error.definition-file-field-type + +Definition file field "%s" must be %s. + +# warning.unknown-definition-key + +Ignoring unrecognized key "%s" in --definition-file. + +# warning.source-api-version + +Your local project's sourceApiVersion (%s) is below 68.0, the minimum API version this scaffold supports for deployment. Set sourceApiVersion to 68.0 or later in sfdx-project.json, or pass --api-version 68.0 or later when you deploy. + +# success.next-step + +Scaffold generated in %s. Deploy it with: sf project deploy start --source-dir %s%s + +# success.app-id + +After deploying, note the LightningOutApp's App ID from Setup — your host page needs it to embed the app. + +# success.dont-delete + +Don't delete the generated ExternalClientApplication after deploying — Lightning Out uses it for OAuth at runtime. + +# success.eca-overwrite + +Re-running this command overwrites the generated files for the "%s" External Client Application; back up local edits first. + +# success.components-exist + +Verify that every component you referenced already exists and is exposed for Lightning Out before deploying. + +# success.frontdoor + +For CLWR, host pages must complete a frontdoor.jsp handoff before the embedded app can authenticate. diff --git a/package.json b/package.json index c23ad222..5f07a244 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dependencies": { "@salesforce/core": "^9.1.9", "@salesforce/sf-plugins-core": "^13.0.4", - "@salesforce/templates": "^66.15.0" + "@salesforce/templates": "^66.16.0" }, "devDependencies": { "@oclif/plugin-command-snapshot": "^6.0.0", diff --git a/schemas/template-generate-lightning__out.json b/schemas/template-generate-lightning__out.json new file mode 100644 index 00000000..ef139cff --- /dev/null +++ b/schemas/template-generate-lightning__out.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/CreateOutput", + "definitions": { + "CreateOutput": { + "type": "object", + "properties": { + "outputDir": { + "type": "string" + }, + "created": { + "type": "array", + "items": { + "type": "string" + } + }, + "rawOutput": { + "type": "string" + } + }, + "required": ["outputDir", "created", "rawOutput"], + "additionalProperties": false + } + } +} diff --git a/src/commands/template/generate/lightning-out/index.ts b/src/commands/template/generate/lightning-out/index.ts new file mode 100644 index 00000000..096010bd --- /dev/null +++ b/src/commands/template/generate/lightning-out/index.ts @@ -0,0 +1,210 @@ +/* + * Copyright 2025, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fs from 'node:fs'; +import { Flags, loglevel, SfCommand, Ux } from '@salesforce/sf-plugins-core'; +import { CreateOutput, LightningOutOptions, TemplateType } from '@salesforce/templates'; +import { Messages, SfProject } from '@salesforce/core'; +import { getCustomTemplates, runGenerator } from '../../../../utils/templateCommand.js'; +import { outputDirFlagLightning } from '../../../../utils/flags.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-templates', 'lightningOut'); + +/** Flat flag shape read by {@link mergeLightningOutInputs} — a structural subset of the parsed oclif flags. */ +type LightningOutFlags = { + 'app-name'?: string; + 'eca-name'?: string; + runtime?: 'LWR_CORE' | 'CLWR'; + 'host-domains'?: string[]; + components?: string[]; + 'eca-contact-email'?: string; + 'eca-callback-url'?: string; + 'output-dir'?: string; +}; + +/** Parse the --definition-file JSON, surfacing a clear error on malformed or non-object input. */ +export function readDefinition(file: string): Record { + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (e) { + throw messages.createError('error.definition-file-json', [file, (e as Error).message]); + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw messages.createError('error.definition-file-not-object', [file]); + } + return parsed as Record; +} + +/** True when a value is an array whose every element is a string. */ +function isStringArray(v: unknown): boolean { + return Array.isArray(v) && v.every((x) => typeof x === 'string'); +} + +/** + * Validate the types of --definition-file fields before they reach the generator, so wrong-typed + * JSON (e.g. `"appName": 123`) yields an actionable error here rather than an internal TypeError + * from the generator (which calls `.trim()` on string fields). A field is skipped when the matching + * flag overrides it, since flags always arrive as strings and win per-key (see mergeLightningOutInputs). + */ +export function validateDefinitionShape(defn: Record, flags: LightningOutFlags): void { + const requireString = (overridden: boolean, val: unknown, key: string): void => { + if (!overridden && val !== undefined && typeof val !== 'string') { + throw messages.createError('error.definition-file-field-type', [key, 'a string']); + } + }; + const requireStringArray = (overridden: boolean, val: unknown, key: string): void => { + if (!overridden && val !== undefined && !isStringArray(val)) { + throw messages.createError('error.definition-file-field-type', [key, 'an array of strings']); + } + }; + + requireString(flags['app-name'] !== undefined, defn.appName, 'appName'); + requireString(flags.runtime !== undefined, defn.runtime, 'runtime'); + requireStringArray(flags['host-domains'] !== undefined, defn.hostDomains, 'hostDomains'); + requireStringArray(flags.components !== undefined, defn.components, 'components'); + + if (defn.eca !== undefined) { + if (typeof defn.eca !== 'object' || defn.eca === null || Array.isArray(defn.eca)) { + throw messages.createError('error.definition-file-field-type', ['eca', 'an object']); + } + const eca = defn.eca as Record; + requireString(flags['eca-name'] !== undefined, eca.name, 'eca.name'); + requireString(flags['eca-contact-email'] !== undefined, eca.contactEmail, 'eca.contactEmail'); + requireString(flags['eca-callback-url'] !== undefined, eca.callbackUrl, 'eca.callbackUrl'); + } +} + +/** + * Merge the --definition-file JSON with individual flags into one LightningOutOptions, with + * per-key precedence (flags win over the file) and wholesale list-replace semantics for + * hostDomains/components (never merge/concat). All structural validation (required-ness, shape, + * formats) is the generator's job — this function only resolves precedence. + */ +export function mergeLightningOutInputs( + defn: Record, + flags: LightningOutFlags +): { opts: LightningOutOptions; unknownKeys: string[] } { + const known = new Set(['appName', 'runtime', 'hostDomains', 'components', 'eca']); + const unknownKeys = Object.keys(defn).filter((k) => !known.has(k)); + const ecaDefn = (defn.eca ?? {}) as Record; + const opts: LightningOutOptions = { + appName: flags['app-name'] ?? (defn.appName as string), + runtime: (flags.runtime ?? defn.runtime) as LightningOutOptions['runtime'], + hostDomains: flags['host-domains'] ?? (defn.hostDomains as string[]) ?? [], + components: flags.components ?? (defn.components as string[]), + eca: { + name: flags['eca-name'] ?? (ecaDefn.name as string), + contactEmail: flags['eca-contact-email'] ?? (ecaDefn.contactEmail as string), + callbackUrl: flags['eca-callback-url'] ?? (ecaDefn.callbackUrl as string), + }, + outputdir: flags['output-dir'], + }; + return { opts, unknownKeys }; +} + +/** + * Resolve the local DX project's `sourceApiVersion`, used only for a CLI-side advisory warning + * (the generator itself has no project context). Returns undefined when there's no DX project — + * generating outside a project must not error. + */ +async function getSourceApiVersion(): Promise { + try { + const project = await SfProject.resolve(); + const projectJson = await project.resolveProjectConfig(); + return projectJson.sourceApiVersion as string | undefined; + } catch (e) { + return undefined; + } +} + +/** True when a project sourceApiVersion is present and below the v68.0 deploy floor. Pure; testable. */ +export function isBelowApiFloor(projApi: string | undefined): boolean { + return !!projApi && Number(projApi) < 68; +} + +/** + * Quote a path for safe copy-paste into a POSIX shell when it contains whitespace or quote chars, + * so the suggested deploy command survives output dirs such as "/tmp/Lightning Out". + */ +export function shellQuoteArg(p: string): string { + return /[\s"'\\]/.test(p) ? `'${p.replace(/'/g, "'\\''")}'` : p; +} + +export default class LightningOut extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + public static readonly state = 'beta'; + public static readonly hidden = true; + + public static readonly flags = { + 'app-name': Flags.string({ summary: messages.getMessage('flags.app-name.summary'), required: false }), + 'eca-name': Flags.string({ summary: messages.getMessage('flags.eca-name.summary'), required: false }), + runtime: Flags.option({ + options: ['LWR_CORE', 'CLWR'] as const, + summary: messages.getMessage('flags.runtime.summary'), + })(), + 'host-domains': Flags.string({ summary: messages.getMessage('flags.host-domains.summary'), multiple: true }), + components: Flags.string({ summary: messages.getMessage('flags.components.summary'), multiple: true }), + 'eca-contact-email': Flags.string({ summary: messages.getMessage('flags.eca-contact-email.summary') }), + 'eca-callback-url': Flags.string({ summary: messages.getMessage('flags.eca-callback-url.summary') }), + 'definition-file': Flags.file({ exists: true, summary: messages.getMessage('flags.definition-file.summary') }), + 'output-dir': outputDirFlagLightning, + loglevel, + }; + + public async run(): Promise { + const { flags } = await this.parse(LightningOut); + const defn = flags['definition-file'] ? readDefinition(flags['definition-file']) : {}; + validateDefinitionShape(defn, flags); + const { opts, unknownKeys } = mergeLightningOutInputs(defn, flags); + + unknownKeys.forEach((k) => this.warn(messages.getMessage('warning.unknown-definition-key', [k]))); + + const result = await runGenerator({ + templateType: TemplateType.LightningOut, + opts, + ux: new Ux({ jsonEnabled: this.jsonEnabled() }), + templates: getCustomTemplates(this.configAggregator), + }); + + // CLI-side sourceApiVersion floor check (the generator has no project context). + const projApi = await getSourceApiVersion(); + const belowFloor = isBelowApiFloor(projApi); + if (belowFloor) { + this.warn(messages.getMessage('warning.source-api-version', [String(projApi)])); + } + + // Success guidance (suppressed automatically under --json). + // Only pin --api-version to the 68.0 deploy floor when the project is below it; otherwise omit + // the optional flag so the user's own (>= floor) project default applies rather than being downgraded. + const outputDir = opts.outputdir ?? '.'; + const apiVersionSuffix = belowFloor ? ' --api-version 68.0' : ''; + this.log(messages.getMessage('success.next-step', [outputDir, shellQuoteArg(outputDir), apiVersionSuffix])); + this.info(messages.getMessage('success.app-id')); + this.info(messages.getMessage('success.dont-delete')); + this.info(messages.getMessage('success.eca-overwrite', [opts.eca.name ?? ''])); + this.info(messages.getMessage('success.components-exist')); + // frontdoor.jsp handoff is a CLWR-only concern; don't show it for LWR_CORE runs. + if (opts.runtime === 'CLWR') { + this.info(messages.getMessage('success.frontdoor')); + } + + return result; // --json returns the full CreateOutput (created[]) + } +} diff --git a/test/commands/template/generate/lightning-out/index.nut.ts b/test/commands/template/generate/lightning-out/index.nut.ts new file mode 100644 index 00000000..32e449d5 --- /dev/null +++ b/test/commands/template/generate/lightning-out/index.nut.ts @@ -0,0 +1,184 @@ +/* + * Copyright 2025, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import path from 'node:path'; +import fs from 'node:fs'; +import { expect, config } from 'chai'; +import { TestSession, execCmd } from '@salesforce/cli-plugins-testkit'; +import assert from 'yeoman-assert'; +import { CreateOutput } from '@salesforce/templates'; + +config.truncateThreshold = 0; + +describe('template generate lightning-out:', () => { + let session: TestSession; + + before(async () => { + session = await TestSession.create({ + project: {}, + devhubAuthStrategy: 'NONE', + }); + }); + after(async () => { + await session?.clean(); + }); + + const outDir = (name: string): string => path.join(session.project.dir, name); + + describe('happy path — 7 artifacts, no iframe artifact', () => { + let result: CreateOutput | undefined; + + before(() => { + result = execCmd( + 'template generate lightning-out --app-name MyLoApp --eca-name MyLoApp_ECA ' + + '--runtime LWR_CORE --host-domains https://app.example.com --host-domains https://portal.example.com:8080 ' + + '--components c/myButton --eca-contact-email dev@example.com ' + + '--eca-callback-url https://app.example.com/frontdoor.html --output-dir force-app/main/default --json', + { ensureExitCode: 0 } + ).jsonOutput?.result; + }); + + it('should scaffold exactly the seven artifact types', () => { + const projectOutDir = path.join(session.project.dir, 'force-app', 'main', 'default'); + assert.file([ + path.join(projectOutDir, 'lightningOutApps', 'MyLoApp.lightningOutApp-meta.xml'), + path.join(projectOutDir, 'settings', 'MyDomain.settings-meta.xml'), + path.join(projectOutDir, 'settings', 'Security.settings-meta.xml'), + path.join(projectOutDir, 'corsWhitelistOrigins', 'app_example_com.corsWhitelistOrigin-meta.xml'), + path.join(projectOutDir, 'corsWhitelistOrigins', 'portal_example_com_8080.corsWhitelistOrigin-meta.xml'), + path.join(projectOutDir, 'externalClientApps', 'MyLoApp_ECA.eca-meta.xml'), + path.join(projectOutDir, 'extlClntAppGlobalOauthSets', 'MyLoApp_ECA.ecaGlblOauth-meta.xml'), + path.join(projectOutDir, 'extlClntAppOauthSettings', 'MyLoApp_ECA.ecaOauth-meta.xml'), + ]); + expect(fs.existsSync(path.join(projectOutDir, 'iframeWhiteListUrlSettings'))).to.be.false; + }); + + it('should render the input values into the generated metadata (not just create the files)', () => { + const projectOutDir = path.join(session.project.dir, 'force-app', 'main', 'default'); + + const loApp = fs.readFileSync( + path.join(projectOutDir, 'lightningOutApps', 'MyLoApp.lightningOutApp-meta.xml'), + 'utf8' + ); + expect(loApp).to.include('LWR_CORE'); + expect(loApp).to.include('c/myButton'); + expect(loApp).to.include('https://app.example.com'); + expect(loApp).to.include('https://portal.example.com:8080'); + + // contactEmail lives only in the ExternalClientApplication artifact. + const eca = fs.readFileSync(path.join(projectOutDir, 'externalClientApps', 'MyLoApp_ECA.eca-meta.xml'), 'utf8'); + expect(eca).to.include('dev@example.com'); + + // callbackUrl lives only in the ExtlClntAppGlobalOauthSettings artifact. + const ecaGlobalOauth = fs.readFileSync( + path.join(projectOutDir, 'extlClntAppGlobalOauthSets', 'MyLoApp_ECA.ecaGlblOauth-meta.xml'), + 'utf8' + ); + expect(ecaGlobalOauth).to.include('https://app.example.com/frontdoor.html'); + }); + + it('should return a CreateOutput with non-empty created[]', () => { + assert(result); + expect(result.created).to.be.an('array').that.is.not.empty; + }); + }); + + describe('--definition-file', () => { + let defFile: string; + + before(() => { + defFile = path.join(session.project.dir, 'lo-def.json'); + fs.writeFileSync( + defFile, + JSON.stringify({ + appName: 'DefApp', + runtime: 'LWR_CORE', + hostDomains: ['https://app.example.com'], + components: ['c/myButton'], + eca: { name: 'DefApp_ECA', contactEmail: 'dev@example.com', callbackUrl: 'https://app.example.com/cb' }, + }) + ); + }); + + it('should generate the app + ECA files with the names from the definition file', () => { + const dir = outDir('def-plain'); + execCmd(`template generate lightning-out --definition-file ${defFile} --output-dir ${dir} --json`, { + ensureExitCode: 0, + }); + assert.file([ + path.join(dir, 'lightningOutApps', 'DefApp.lightningOutApp-meta.xml'), + path.join(dir, 'externalClientApps', 'DefApp_ECA.eca-meta.xml'), + ]); + }); + + it('should let --app-name override the definition file appName', () => { + const dir = outDir('def-override'); + execCmd( + `template generate lightning-out --definition-file ${defFile} --app-name OverrideApp --output-dir ${dir} --json`, + { ensureExitCode: 0 } + ); + assert.file(path.join(dir, 'lightningOutApps', 'OverrideApp.lightningOutApp-meta.xml')); + }); + }); + + describe('validation failure', () => { + it('should exit non-zero with a message naming the invalid host-domain scheme', () => { + const dir = outDir('bad-host-domain'); + // The generator accepts both http and https origins; only a non-http(s) scheme is rejected. + const stderr = execCmd( + 'template generate lightning-out --app-name BadHostApp --eca-name BadHostApp_ECA --runtime LWR_CORE ' + + `--host-domains ftp://app.example.com --components c/myButton --eca-contact-email dev@example.com --eca-callback-url https://app.example.com/cb --output-dir ${dir}`, + { ensureExitCode: 1 } + ).shellOutput.stderr; + expect(stderr).to.match(/host domain/i); + expect(stderr).to.match(/http or https/i); + }); + }); + + describe('sourceApiVersion floor guidance in the deploy suggestion', () => { + const projectJsonPath = (): string => path.join(session.project.dir, 'sfdx-project.json'); + + const setSourceApiVersion = (version: string): void => { + const cfg = JSON.parse(fs.readFileSync(projectJsonPath(), 'utf8')) as Record; + cfg.sourceApiVersion = version; + fs.writeFileSync(projectJsonPath(), JSON.stringify(cfg, null, 2)); + }; + + // Run without --json so the this.log/this.warn guidance is actually emitted. + const runNoJson = (dir: string): { stdout: string; stderr: string } => + execCmd( + 'template generate lightning-out --app-name FloorApp --eca-name FloorApp_ECA --runtime LWR_CORE ' + + `--host-domains https://app.example.com --components c/myButton --eca-contact-email dev@example.com ` + + `--eca-callback-url https://app.example.com/cb --output-dir ${dir}`, + { ensureExitCode: 0 } + ).shellOutput; + + it('warns and pins --api-version 68.0 when sourceApiVersion is below the floor', () => { + setSourceApiVersion('64.0'); + const { stdout, stderr } = runNoJson(outDir('floor-below')); + expect(stderr).to.match(/is below 68\.0/); // floor warning fired + expect(stdout).to.include('sf project deploy start'); + expect(stdout).to.include('--api-version 68.0'); + }); + + it('omits --api-version (lets the project default win) when sourceApiVersion is at or above the floor', () => { + setSourceApiVersion('70.0'); + const { stdout, stderr } = runNoJson(outDir('floor-above')); + expect(stderr).to.not.match(/is below 68\.0/); // no floor warning + expect(stdout).to.include('sf project deploy start'); + expect(stdout).to.not.include('--api-version'); + }); + }); +}); diff --git a/test/commands/template/generate/lightning-out/index.test.ts b/test/commands/template/generate/lightning-out/index.test.ts new file mode 100644 index 00000000..8e2d92e2 --- /dev/null +++ b/test/commands/template/generate/lightning-out/index.test.ts @@ -0,0 +1,229 @@ +/* + * Copyright 2025, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { Messages } from '@salesforce/core'; +import { TestContext } from '@salesforce/core/testSetup'; +import { expect } from 'chai'; +import { stubSfCommandUx } from '@salesforce/sf-plugins-core'; +import LightningOut, { + mergeLightningOutInputs, + readDefinition, + isBelowApiFloor, + shellQuoteArg, + validateDefinitionShape, +} from '../../../../../src/commands/template/generate/lightning-out/index.js'; + +// LightningOut's own module-level Messages.importMessagesDirectoryFromMetaUrl() registration +// (triggered by the import above) makes the 'lightningOut' bundle loadable here too. +const messages = Messages.loadMessages('@salesforce/plugin-templates', 'lightningOut'); + +describe('template generate lightning-out (unit)', () => { + const $$ = new TestContext(); + beforeEach(() => { + stubSfCommandUx($$.SANDBOX); + }); + afterEach(() => { + $$.restore(); + }); + + it('is a beta, hidden command', () => { + expect(LightningOut.state).to.equal('beta'); + expect(LightningOut.hidden).to.equal(true); + }); + + it('renders the output dir into success.next-step and includes --api-version only when the suffix is supplied', () => { + const outputdir = 'force-app/main/default'; + + // Below-floor projects get the explicit 68.0 pin. + const belowFloor = messages.getMessage('success.next-step', [outputdir, outputdir, ' --api-version 68.0']); + expect(belowFloor).to.include(`sf project deploy start --source-dir ${outputdir} --api-version 68.0`); + expect(belowFloor).to.not.include(''); + + // At/above-floor projects omit the optional flag so the project's own default applies. + const atOrAboveFloor = messages.getMessage('success.next-step', [outputdir, outputdir, '']); + expect(atOrAboveFloor).to.include(`sf project deploy start --source-dir ${outputdir}`); + expect(atOrAboveFloor).to.not.include('--api-version'); + }); + + describe('mergeLightningOutInputs', () => { + it('takes appName/eca.name from flags over the definition file', () => { + const { opts } = mergeLightningOutInputs( + { appName: 'FromFile', eca: { name: 'EcaFile', contactEmail: 'f@f.com', callbackUrl: 'https://f.com/cb' } }, + { 'app-name': 'FromFlag', 'eca-name': 'EcaFlag' } + ); + expect(opts.appName).to.equal('FromFlag'); + expect(opts.eca.name).to.equal('EcaFlag'); + expect(opts.eca.contactEmail).to.equal('f@f.com'); // unspecified flag falls back to file + }); + it('replaces list keys wholesale when the flag layer provides them', () => { + const { opts } = mergeLightningOutInputs( + { hostDomains: ['https://a.com', 'https://b.com'], components: ['c/x'] }, + { 'host-domains': ['https://c.com'] } + ); + expect(opts.hostDomains).to.deep.equal(['https://c.com']); // NOT concatenated with A,B + expect(opts.components).to.deep.equal(['c/x']); // no flag -> file value kept + }); + it('falls back entirely to the definition file when no flags given', () => { + const { opts } = mergeLightningOutInputs( + { + appName: 'A', + runtime: 'CLWR', + hostDomains: ['https://a.com'], + eca: { name: 'E', contactEmail: 'e@e.com', callbackUrl: 'https://a.com/cb' }, + }, + {} + ); + expect(opts).to.deep.include({ appName: 'A', runtime: 'CLWR' }); + expect(opts.hostDomains).to.deep.equal(['https://a.com']); + }); + it('reports unknown definition-file keys', () => { + const { unknownKeys } = mergeLightningOutInputs({ appName: 'A', bogus: 1, other: 2 }, {}); + expect(unknownKeys).to.have.members(['bogus', 'other']); + }); + it('defaults hostDomains to [] and leaves components undefined when absent everywhere', () => { + const { opts } = mergeLightningOutInputs({}, {}); + expect(opts.hostDomains).to.deep.equal([]); + expect(opts.components).to.equal(undefined); + }); + }); + + describe('readDefinition', () => { + const tmpFiles: string[] = []; + + const writeTmpFile = (contents: string): string => { + const file = path.join(os.tmpdir(), `lightning-out-readDefinition-${Date.now()}-${Math.random()}.json`); + fs.writeFileSync(file, contents, 'utf8'); + tmpFiles.push(file); + return file; + }; + + afterEach(() => { + while (tmpFiles.length) { + const file = tmpFiles.pop(); + if (file && fs.existsSync(file)) fs.rmSync(file); + } + }); + + it('returns the parsed object for a valid JSON object file', () => { + const file = writeTmpFile('{"appName":"A"}'); + expect(readDefinition(file)).to.deep.equal({ appName: 'A' }); + }); + + it('throws a definition-file-json error for malformed JSON', () => { + const file = writeTmpFile('{bad json'); + expect(() => readDefinition(file)).to.throw(); + try { + readDefinition(file); + expect.fail('expected readDefinition to throw'); + } catch (e) { + expect((e as Error).name).to.equal('Definition-file-jsonError'); + } + }); + + it('throws a definition-file-not-object error for a JSON array', () => { + const file = writeTmpFile('[]'); + expect(() => readDefinition(file)).to.throw(); + try { + readDefinition(file); + expect.fail('expected readDefinition to throw'); + } catch (e) { + expect((e as Error).name).to.equal('Definition-file-not-objectError'); + } + }); + }); + + describe('isBelowApiFloor', () => { + const cases: Array<[string | undefined, boolean]> = [ + ['64', true], + ['67.0', true], + ['68', false], + ['68.0', false], + ['70', false], + [undefined, false], + ['', false], + ]; + + cases.forEach(([input, expected]) => { + it(`returns ${String(expected)} for ${JSON.stringify(input)}`, () => { + expect(isBelowApiFloor(input)).to.equal(expected); + }); + }); + + it('treats non-numeric strings as not below the floor (current behavior)', () => { + // current behavior: Number('garbage')=NaN, NaN<68 is false + expect(isBelowApiFloor('garbage')).to.equal(false); + }); + }); + + describe('shellQuoteArg', () => { + it('leaves a plain path untouched', () => { + expect(shellQuoteArg('force-app/main/default')).to.equal('force-app/main/default'); + }); + it('single-quotes a path containing whitespace', () => { + expect(shellQuoteArg('/tmp/Lightning Out')).to.equal("'/tmp/Lightning Out'"); + }); + it('escapes an embedded single quote', () => { + expect(shellQuoteArg("/tmp/o'brien")).to.equal("'/tmp/o'\\''brien'"); + }); + }); + + describe('validateDefinitionShape', () => { + it('accepts a well-typed definition', () => { + expect(() => + validateDefinitionShape( + { + appName: 'A', + runtime: 'LWR_CORE', + hostDomains: ['https://a.com'], + components: ['c/x'], + eca: { name: 'E', contactEmail: 'e@e.com', callbackUrl: 'https://a.com/cb' }, + }, + {} + ) + ).to.not.throw(); + }); + it('is a no-op for an empty definition', () => { + expect(() => validateDefinitionShape({}, {})).to.not.throw(); + }); + it('rejects a string field of the wrong type', () => { + expect(() => validateDefinitionShape({ appName: 123 }, {})).to.throw(/appName/); + expect(() => validateDefinitionShape({ runtime: 5 }, {})).to.throw(/runtime/); + }); + it('rejects a list field that is not an array of strings', () => { + expect(() => validateDefinitionShape({ hostDomains: 'https://a.com' }, {})).to.throw(/hostDomains/); + expect(() => validateDefinitionShape({ components: [1, 2] }, {})).to.throw(/components/); + }); + it('rejects eca when it is not an object', () => { + expect(() => validateDefinitionShape({ eca: 'nope' }, {})).to.throw(/eca/); + expect(() => validateDefinitionShape({ eca: [] }, {})).to.throw(/eca/); + }); + it('rejects a wrong-typed nested eca field', () => { + expect(() => validateDefinitionShape({ eca: { contactEmail: 123 } }, {})).to.throw(/eca\.contactEmail/); + }); + it('skips a bad file value when the matching flag overrides it', () => { + expect(() => validateDefinitionShape({ appName: 123 }, { 'app-name': 'Foo' })).to.not.throw(); + expect(() => + validateDefinitionShape({ hostDomains: 'bad' }, { 'host-domains': ['https://a.com'] }) + ).to.not.throw(); + expect(() => + validateDefinitionShape({ eca: { contactEmail: 123 } }, { 'eca-contact-email': 'x@y.com' }) + ).to.not.throw(); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 7931ce57..8132728d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1447,10 +1447,10 @@ cli-progress "^3.12.0" terminal-link "^3.0.0" -"@salesforce/templates@^66.15.0": - version "66.15.0" - resolved "https://registry.yarnpkg.com/@salesforce/templates/-/templates-66.15.0.tgz#2b64727feaac1cc947fd8f7eff87f22cc2d9cdb3" - integrity sha512-wOFos8lh/xsswuRs7z3o6D8pCtmScC0LJ0Jzbzz6SxZGxYKoI09NpgXwvCOzZkLRqyCYNUknQi0UplOP2PaIOw== +"@salesforce/templates@^66.16.0": + version "66.16.0" + resolved "https://registry.yarnpkg.com/@salesforce/templates/-/templates-66.16.0.tgz#afe4e928334cd4553ce93b20957fb186c14de6ec" + integrity sha512-eJyqw9x0gAIl+rhKKeyOJ0lmQrYSHaz932EErn84pugewSYUjvomTgDUjKHqUrqgJA4yOciav8lQ/gkK2sC3AQ== dependencies: "@salesforce/kit" "^4.0.0" ejs "^3.1.10"