Skip to content
Merged
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
32 changes: 13 additions & 19 deletions test/commands/lightning/dev/helpers/browserUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,35 +15,29 @@
*/

import { chromium, type Browser, type Page } from 'playwright';
import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit';

type OrgDisplayUserResult = { accessToken?: string };
import { TestSession } from '@salesforce/cli-plugins-testkit';
import { Org } from '@salesforce/core';

/**
* Returns the access token for frontdoor sid authentication. Required for
* Playwright testing. Uses testkit execCmd so the correct CLI executable is
* used on all platforms (e.g. sf on Unix, sf.cmd on Windows).
* Playwright testing. Reads the token directly from the org connection via the
* core Org API rather than shelling out to `sf org display user --json`, which
* now redacts secrets by default (returning a "[REDACTED] ..." placeholder
* instead of the real token, which would make frontdoor auth fail with a 401).
*
* @param session - TestSession with a default scratch org.
* @returns The session ID string.
*/
export function getAccessToken(session: TestSession): string {
export async function getAccessToken(session: TestSession): Promise<string> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export async function getAccessToken(session: TestSession): Promise<string> {
async function getAccessToken(session: TestSession): Promise<string> {

not used outside this file, doesn't need to be exported

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — addressed in follow-up #677. getAccessToken is now non-exported (it's only used by getPreview in this file).

const scratchOrg = session.orgs.get('default');
const username = scratchOrg?.username ?? '';
const projectDir = session.project?.dir ?? '';
if (!username || !projectDir) {
throw new Error('Session has no default scratch org username or project dir');
if (!username) {
throw new Error('Session has no default scratch org username');
}
const result = execCmd<OrgDisplayUserResult>(`org display user -o ${username} --json`, {
cwd: projectDir,
cli: 'sf',
ensureExitCode: 0,
});
const accessToken = result.jsonOutput?.result?.accessToken ?? '';
const org = await Org.create({ aliasOrUsername: username });
const accessToken = org.getConnection().accessToken ?? '';
if (!accessToken) {
throw new Error(
`sf org display user result missing accessToken: ${result.shellOutput.stdout} ${result.shellOutput.stderr ?? ''}`,
);
throw new Error(`Could not resolve an access token for org '${username}'`);
}
return accessToken;
}
Expand Down Expand Up @@ -72,7 +66,7 @@ async function establishSessionViaFrontDoor(page: Page, previewOrigin: string, a
* @returns Promise resolving to the Playwright browser and page; caller must close them when done.
*/
export async function getPreview(previewUrl: string, session: TestSession): Promise<{ browser: Browser; page: Page }> {
const accessToken = getAccessToken(session);
const accessToken = await getAccessToken(session);
const previewOrigin = new URL(previewUrl).origin;
const headed = process.env.HEADED === 'true' || process.env.HEADED === '1';
const browser = await chromium.launch({ headless: !headed });
Expand Down
27 changes: 26 additions & 1 deletion test/commands/lightning/dev/helpers/devServerUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { spawn, ChildProcessByStdio, ChildProcessWithoutNullStreams } from 'node
import { Readable } from 'node:stream';
import type { Writable } from 'node:stream';
import { type ChildProcess } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { TestSession } from '@salesforce/cli-plugins-testkit';
Expand All @@ -28,6 +29,26 @@ const MAX_WAIT_MS = 30_000;

export const PLUGIN_ROOT_PATH = path.resolve(CURRENT_DIR_PATH, '../../../../..');

/**
* Returns the highest org API version the plugin ships a dev server for, read from
* package.json's `apiVersionMetadata`. Scratch orgs are provisioned on whatever API
* version the platform currently defaults to, which drifts ahead of the plugin; the
* dev server rejects unsupported versions and exits before printing a preview URL.
* Pinning the preview to this version keeps the e2e tests decoupled from that drift
* and auto-tracks new versions as they are added to package.json.
*
* @returns The max supported API version string (e.g. '67.0').
*/
export function getMaxSupportedApiVersion(): string {
const pkgPath = path.join(PLUGIN_ROOT_PATH, 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { apiVersionMetadata?: Record<string, unknown> };
const versions = Object.keys(pkg.apiVersionMetadata ?? {});
if (versions.length === 0) {
throw new Error(`No apiVersionMetadata found in ${pkgPath}`);

@rax-it rax-it Jul 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: should we try a default version?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional fail-fast here rather than a default. apiVersionMetadata is a hard requirement for the plugin — the shipping dependencyLoader.ts reads packageJson.apiVersionMetadata directly and would break too if it were ever empty, so an empty map means the plugin is misconfigured, not in a transient state a test should paper over.

There's also no existing "default API version" constant anywhere in the source to fall back to, so a default would have to be a hardcoded magic number (e.g. '67.0'). That would silently rot as versions move, and could pin the tests to a version with no matching @lwc/sfdx-local-dev-dist-<v> dist — producing a more confusing downstream import failure than the clear "No apiVersionMetadata found" we throw at the source.

Happy to revisit if we ever introduce a canonical default version the plugin resolves against.

}
return versions.sort((a, b) => parseFloat(b) - parseFloat(a))[0];
}

/**
* Extracts the first LWR preview URL from process output.
*
Expand Down Expand Up @@ -150,7 +171,11 @@ export function startLightningDevServer(
OPEN_BROWSER: 'false',
LIGHTNING_DEV_PRINT_PREVIEW_URL: 'true',
};
const args = [runJs, 'lightning', 'dev', 'component', '-o', username];
// Pin the preview to the plugin's max supported API version. Scratch orgs are
// created on the platform's current default API version, which can be newer than
// any version the plugin bundles a dev server for; without this the dev server
// exits with an "unsupported API version" error and never prints a preview URL.
const args = [runJs, 'lightning', 'dev', 'component', '-o', username, '--api-version', getMaxSupportedApiVersion()];
if (componentName) {
args.push('--name', componentName);
}
Expand Down
51 changes: 41 additions & 10 deletions test/commands/lightning/dev/helpers/sessionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,54 @@ let cachedSession: TestSession;

const PROJECT_PATH = path.resolve(PLUGIN_ROOT_PATH, 'test/projects/component-preview-project');

// Number of times TestSession will retry scratch org creation before failing. Scratch org
// signup is intermittently flaky (RemoteOrgSignupFailed / C-9999); retrying avoids spurious
// failures in the post-release pipeline. Overridable via TESTKIT_SETUP_RETRIES.
const SETUP_RETRIES = Number.parseInt(process.env.TESTKIT_SETUP_RETRIES ?? '', 10) || 3;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious use of Number.parseInt instead of global parseInt.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to the global parseInt in #677 for consistency — the rest of the repo (src/configMeta.ts, src/shared/previewUtils.ts, and devServerUtils.ts right next door) uses the global form, so Number.parseInt was the odd one out here. No behavioral difference either way.


/**
* Restores process.cwd() if it is currently a leaked sinon stub.
*
* TestSession's constructor stubs process.cwd() *before* it creates scratch orgs. If org
* creation then throws, TestSession.create() rejects without ever returning the instance,
* so the sandbox is never restored and the stub leaks. The next file's TestSession.create()
* then throws a misleading "Attempted to wrap cwd which is already wrapped", masking the real
* error across every subsequent file. Restoring the stub here lets the true failure surface
* in the one file that actually failed.
*/
function restoreLeakedCwdStub(): void {
// A sinon stub is self-bound, so calling restore() off the proxy is safe here.
// eslint-disable-next-line @typescript-eslint/unbound-method
const cwd = process.cwd as typeof process.cwd & { isSinonProxy?: boolean; restore?: () => void };
if (cwd.isSinonProxy) {
cwd.restore?.();
}
}

/**
* Returns a shared TestSession for NUTs, created once and reused (same project and Dev Hub).
*
* @returns Promise that resolves to the cached or newly created TestSession.
*/
export async function getSession(): Promise<TestSession> {
if (!cachedSession) {
cachedSession = await TestSession.create({
devhubAuthStrategy: 'AUTO',
project: { sourceDir: PROJECT_PATH },
scratchOrgs: [
{
config: 'config/project-scratch-def.json',
setDefault: true,
},
],
});
try {
cachedSession = await TestSession.create({
devhubAuthStrategy: 'AUTO',
project: { sourceDir: PROJECT_PATH },
retries: SETUP_RETRIES,
scratchOrgs: [
{
config: 'config/project-scratch-def.json',
setDefault: true,
},
],
});
} catch (err) {
// Prevent a single setup failure from cascading into misleading errors in later files.
restoreLeakedCwdStub();
throw err;
}
}
return new Promise((r) => r(cachedSession));
}
Expand Down
Loading