diff --git a/test/commands/lightning/dev/helpers/browserUtils.ts b/test/commands/lightning/dev/helpers/browserUtils.ts index 7fc1048e..9cf84c36 100644 --- a/test/commands/lightning/dev/helpers/browserUtils.ts +++ b/test/commands/lightning/dev/helpers/browserUtils.ts @@ -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 { 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(`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; } @@ -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 }); diff --git a/test/commands/lightning/dev/helpers/devServerUtils.ts b/test/commands/lightning/dev/helpers/devServerUtils.ts index c2ea5dbe..ee571fda 100644 --- a/test/commands/lightning/dev/helpers/devServerUtils.ts +++ b/test/commands/lightning/dev/helpers/devServerUtils.ts @@ -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'; @@ -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 }; + const versions = Object.keys(pkg.apiVersionMetadata ?? {}); + if (versions.length === 0) { + throw new Error(`No apiVersionMetadata found in ${pkgPath}`); + } + return versions.sort((a, b) => parseFloat(b) - parseFloat(a))[0]; +} + /** * Extracts the first LWR preview URL from process output. * @@ -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); } diff --git a/test/commands/lightning/dev/helpers/sessionUtils.ts b/test/commands/lightning/dev/helpers/sessionUtils.ts index 955190ea..a0fb9452 100644 --- a/test/commands/lightning/dev/helpers/sessionUtils.ts +++ b/test/commands/lightning/dev/helpers/sessionUtils.ts @@ -22,6 +22,30 @@ 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; + +/** + * 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). * @@ -29,16 +53,23 @@ const PROJECT_PATH = path.resolve(PLUGIN_ROOT_PATH, 'test/projects/component-pre */ export async function getSession(): Promise { 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)); }