From f0df4c78efc06db7f149159cc54a65615ad14bb3 Mon Sep 17 00:00:00 2001 From: John Hefferman Date: Wed, 22 Jul 2026 11:47:58 -0600 Subject: [PATCH] fix: repair post-release dev-preview NUTs (W-23400737) The dev-preview e2e NUTs added in #626 fail in the post-CLI-release integration pipeline due to three layered issues: 1. API-version drift: scratch orgs are provisioned on the platform's current default API version (e.g. 68.0), but the plugin only bundles dev-server dists for the versions in package.json apiVersionMetadata (66.0/67.0). The dev server rejects the unsupported version and exits before printing a preview URL, so every browser test hits the 30s getPreviewURL timeout. Pin the spawned dev server to the plugin's max supported API version (getMaxSupportedApiVersion) so the tests are decoupled from platform version drift. 2. Sinon cwd cascade: TestSession stubs process.cwd before creating orgs; if org creation throws, the stub leaks and every later file dies with a misleading "Attempted to wrap cwd which is already wrapped", masking the real error. Restore the leaked stub on setup failure, and add retries (TESTKIT_SETUP_RETRIES, default 3) for intermittent scratch-org signup. 3. Secret redaction: `sf org display user --json` now redacts accessToken by default, breaking frontdoor SSO with a 401. Read the token directly from the org connection via @salesforce/core instead. Full NUT suite (12 tests) verified passing locally against a healthy DevHub. Co-Authored-By: Claude Opus 4.8 --- .../lightning/dev/helpers/browserUtils.ts | 32 +++++------- .../lightning/dev/helpers/devServerUtils.ts | 27 +++++++++- .../lightning/dev/helpers/sessionUtils.ts | 51 +++++++++++++++---- 3 files changed, 80 insertions(+), 30 deletions(-) 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)); }