diff --git a/.changeset/docs-default-user-scope.md b/.changeset/docs-default-user-scope.md new file mode 100644 index 00000000..b1d5cb44 --- /dev/null +++ b/.changeset/docs-default-user-scope.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-dx-docs': patch +--- + +Updated plugin install examples to default to user scope diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index 4302491c..0132a1e5 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -310,6 +310,18 @@ "icon": "$(clock)", "category": "B2C DX - Sandboxes" }, + { + "command": "b2c-dx.sandbox.clone", + "title": "Clone Sandbox", + "icon": "$(copy)", + "category": "B2C DX - Sandboxes" + }, + { + "command": "b2c-dx.sandbox.viewCloneDetails", + "title": "View Clone Details", + "icon": "$(git-branch)", + "category": "B2C DX - Sandboxes" + }, { "command": "b2c-dx.instance.inspect", "title": "B2C Instance Config", @@ -737,32 +749,42 @@ }, { "command": "b2c-dx.sandbox.openBM", - "when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-/", + "when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-(?!cloning|settingup)/", "group": "1_info@2" }, { "command": "b2c-dx.sandbox.start", - "when": "view == b2cSandboxExplorer && viewItem == sandbox-stopped", + "when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-stopped(-cloned)?$/", "group": "2_lifecycle@1" }, { "command": "b2c-dx.sandbox.stop", - "when": "view == b2cSandboxExplorer && viewItem == sandbox-started", + "when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-started(-cloned)?$/", "group": "2_lifecycle@2" }, { "command": "b2c-dx.sandbox.restart", - "when": "view == b2cSandboxExplorer && viewItem == sandbox-started", + "when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-started(-cloned)?$/", "group": "2_lifecycle@3" }, { "command": "b2c-dx.sandbox.extendExpiration", - "when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-/", + "when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-(?!cloning|settingup)/", "group": "2_lifecycle@4" }, + { + "command": "b2c-dx.sandbox.clone", + "when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-(started|stopped)(-cloned)?$/", + "group": "2_lifecycle@5" + }, + { + "command": "b2c-dx.sandbox.viewCloneDetails", + "when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-.*-cloned$/", + "group": "1_info@3" + }, { "command": "b2c-dx.sandbox.delete", - "when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-/", + "when": "view == b2cSandboxExplorer && viewItem =~ /^sandbox-(?!cloning|settingup)/", "group": "3_destructive@1" }, { @@ -874,6 +896,14 @@ "command": "b2c-dx.sandbox.extendExpiration", "when": "false" }, + { + "command": "b2c-dx.sandbox.clone", + "when": "false" + }, + { + "command": "b2c-dx.sandbox.viewCloneDetails", + "when": "false" + }, { "command": "b2c-dx.webdav.removeCatalog", "when": "false" diff --git a/packages/b2c-vs-extension/src/sandbox-tree/sandbox-clone-helpers.ts b/packages/b2c-vs-extension/src/sandbox-tree/sandbox-clone-helpers.ts new file mode 100644 index 00000000..407d19ed --- /dev/null +++ b/packages/b2c-vs-extension/src/sandbox-tree/sandbox-clone-helpers.ts @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** Minimal structural shape of a sandbox record used by these helpers. */ +export interface SandboxLike { + id: string; + realm?: string; + instance?: string; + state?: string; + clonedFrom?: string; +} + +/** States of a cloned sandbox that indicate the clone is still being set up from its source. */ +export const CLONE_IN_PROGRESS_STATES = new Set(['cloning', 'creating', 'failed']); + +/** Sandbox states that drive the realm auto-poll (anything mid-transition). */ +export const TRANSITIONAL_STATES = new Set(['creating', 'starting', 'stopping', 'deleting', 'cloning']); + +export function getRealmInstanceId(s: SandboxLike): string | undefined { + return s.realm && s.instance ? `${s.realm}-${s.instance}` : undefined; +} + +/** Return the set of realm-instance identifiers that are currently a source of an in-progress clone. */ +export function getActiveCloneSourceIds(sandboxes: SandboxLike[]): Set { + const sources = new Set(); + for (const s of sandboxes) { + if (typeof s.clonedFrom === 'string' && s.clonedFrom.length > 0) { + const state = (s.state ?? '').toLowerCase(); + if (CLONE_IN_PROGRESS_STATES.has(state)) { + sources.add(s.clonedFrom); + } + } + } + return sources; +} + +export interface SandboxDisplay { + /** Text shown in the tree row description. */ + displayState: string; + /** Context-value suffix after `sandbox-`, without the `-cloned` suffix. */ + contextState: string; + /** Full context value used by VS Code menu `when` clauses. */ + contextValue: string; + /** True when this row represents a cloned sandbox (clonedFrom is set). */ + isClone: boolean; + /** True when the sandbox is a cloned target still being set up (state=failed + clonedFrom). */ + isCloneInSetup: boolean; + /** True when this row is the source of an in-progress clone. */ + showAsCloning: boolean; + /** Text shown in the tooltip State line. */ + tooltipStateLine: string | undefined; +} + +/** + * Compute display data for a sandbox tree row. Pure function — no VS Code dependencies. + * + * @param sandbox the sandbox record + * @param isCloneSource true when the caller knows this sandbox is the source of an active clone + */ +export function computeSandboxDisplay(sandbox: SandboxLike, isCloneSource: boolean): SandboxDisplay { + const rawState = (sandbox.state ?? 'unknown').toLowerCase(); + const isClone = typeof sandbox.clonedFrom === 'string' && sandbox.clonedFrom.length > 0; + const isCloneInSetup = isClone && rawState === 'failed'; + const showAsCloning = isCloneSource && !isCloneInSetup; + const displayState = isCloneInSetup ? 'setting up' : showAsCloning ? 'cloning' : rawState; + const contextState = isCloneInSetup ? 'settingup' : showAsCloning ? 'cloning' : rawState; + const contextValue = isClone ? `sandbox-${contextState}-cloned` : `sandbox-${contextState}`; + let tooltipStateLine: string | undefined; + if (sandbox.state) { + tooltipStateLine = isCloneInSetup + ? 'setting up (clone in progress)' + : showAsCloning + ? `${sandbox.state} (clone in progress)` + : sandbox.state; + } + return {displayState, contextState, contextValue, isClone, isCloneInSetup, showAsCloning, tooltipStateLine}; +} diff --git a/packages/b2c-vs-extension/src/sandbox-tree/sandbox-commands.ts b/packages/b2c-vs-extension/src/sandbox-tree/sandbox-commands.ts index 594724b8..de31f237 100644 --- a/packages/b2c-vs-extension/src/sandbox-tree/sandbox-commands.ts +++ b/packages/b2c-vs-extension/src/sandbox-tree/sandbox-commands.ts @@ -289,6 +289,186 @@ export function registerSandboxCommands( }, ); + const CLONE_PROFILES = ['medium', 'large', 'xlarge', 'xxlarge'] as const; + type CloneProfile = (typeof CLONE_PROFILES)[number]; + const CLONE_POLL_INTERVAL_MS = 10_000; + const CLONE_POLL_TIMEOUT_MS = 60 * 60_000; + + const clone = vscode.commands.registerCommand('b2c-dx.sandbox.clone', async (node: SandboxTreeItem) => { + if (!node) return; + + const ttlStr = await vscode.window.showInputBox({ + title: `Clone Sandbox — ${node.label ?? node.sandbox.id}`, + prompt: 'TTL in hours for the clone (0 = infinite, otherwise must be >= 24)', + value: '24', + validateInput: (v) => { + const n = Number(v); + if (Number.isNaN(n)) return 'Enter a number'; + if (n > 0 && n < 24) return 'TTL must be 0 (infinite) or at least 24 hours'; + return null; + }, + }); + if (ttlStr === undefined) return; + const ttl = Number(ttlStr); + + const profilePick = await vscode.window.showQuickPick( + [{label: 'Same as source', value: undefined}, ...CLONE_PROFILES.map((p) => ({label: p, value: p}))], + {title: 'Clone Sandbox — Resource Profile', placeHolder: 'Select profile for the clone'}, + ); + if (!profilePick) return; + const targetProfile = profilePick.value as CloneProfile | undefined; + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const emailsStr = await vscode.window.showInputBox({ + title: `Clone Sandbox — Notification Emails`, + prompt: 'Comma-separated email addresses to notify (optional)', + placeHolder: 'user1@example.com, user2@example.com', + validateInput: (v) => { + const trimmed = v.trim(); + if (!trimmed) return null; + const invalid = trimmed + .split(',') + .map((e) => e.trim()) + .filter((e) => e.length > 0) + .filter((e) => !emailRegex.test(e)); + return invalid.length ? `Invalid email(s): ${invalid.join(', ')}` : null; + }, + }); + if (emailsStr === undefined) return; + const emails = emailsStr + .split(',') + .map((e) => e.trim()) + .filter((e) => e.length > 0); + + const sandboxName = typeof node.label === 'string' ? node.label : node.sandbox.id; + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Cloning sandbox ${sandboxName}`, + cancellable: false, + }, + async (progress) => { + progress.report({message: node.sandbox.id}); + let sourceMarked = false; + try { + const odsClient = await getOdsClientFromConfig(configProvider); + const result = await odsClient.POST('/sandboxes/{sandboxId}/clones', { + params: {path: {sandboxId: node.sandbox.id}}, + body: { + ttl, + ...(targetProfile ? {targetProfile} : {}), + ...(emails.length ? {emails} : {}), + }, + }); + if (result.error) { + vscode.window.showErrorMessage( + `Sandbox clone failed: ${getApiErrorMessage(result.error, result.response)}`, + ); + return; + } + treeProvider.markSourceCloning(node.sandbox.id); + sourceMarked = true; + const cloneId = result.data?.data?.cloneId; + if (!cloneId) { + vscode.window.showInformationMessage('Sandbox clone initiated.'); + treeProvider.refreshRealm(node.realm); + treeProvider.startPollingRealm(node.realm); + return; + } + + vscode.window.showInformationMessage(`Sandbox clone initiated (cloneId: ${cloneId}).`); + treeProvider.refreshRealm(node.realm); + treeProvider.startPollingRealm(node.realm); + + const startTime = Date.now(); + let lastPct = 0; + while (Date.now() - startTime < CLONE_POLL_TIMEOUT_MS) { + await new Promise((r) => setTimeout(r, CLONE_POLL_INTERVAL_MS)); + treeProvider.refreshRealm(node.realm); + const statusResult = await odsClient.GET('/sandboxes/{sandboxId}/clones/{cloneId}', { + params: {path: {sandboxId: node.sandbox.id, cloneId}}, + }); + if (statusResult.error || !statusResult.data?.data) continue; + const clone = statusResult.data.data; + const status = clone.status ?? 'IN_PROGRESS'; + const pct = clone.progressPercentage ?? 0; + const increment = Math.max(0, pct - lastPct); + lastPct = pct; + progress.report({ + increment, + message: `${node.sandbox.id} — ${status} ${pct}%${clone.lastKnownState ? ` (${clone.lastKnownState})` : ''}`, + }); + if (status === 'COMPLETED' || status === 'FAILED') { + if (status === 'COMPLETED') { + vscode.window.showInformationMessage(`Clone ${cloneId} completed.`); + } else { + vscode.window.showErrorMessage( + `Clone ${cloneId} failed${clone.lastKnownState ? ` at ${clone.lastKnownState}` : ''}.`, + ); + } + // The /clones endpoint reports COMPLETED before the /sandboxes list updates the + // source/target states. Keep the source marked and refresh a few more ticks so the + // tree catches the final states before the "cloning" label clears. + const sandboxId = node.sandbox.id; + const realm = node.realm; + for (let i = 0; i < 3; i++) { + await new Promise((r) => setTimeout(r, CLONE_POLL_INTERVAL_MS)); + treeProvider.refreshRealm(realm); + } + treeProvider.unmarkSourceCloning(sandboxId); + sourceMarked = false; + treeProvider.refreshRealm(realm); + treeProvider.startPollingRealm(realm); + return; + } + } + vscode.window.showWarningMessage( + `Clone ${cloneId} still in progress after timeout. Use "View Clone Details" to check status.`, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`Sandbox clone failed: ${message}`); + } finally { + if (sourceMarked) { + treeProvider.unmarkSourceCloning(node.sandbox.id); + } + } + }, + ); + }); + + const viewCloneDetails = vscode.commands.registerCommand( + 'b2c-dx.sandbox.viewCloneDetails', + async (node: SandboxTreeItem) => { + if (!node) return; + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: 'Fetching clone details...'}, + async () => { + try { + const details = await treeProvider.getSandboxWithCloneDetails(node.sandbox.id); + if (!details) { + vscode.window.showErrorMessage('Could not fetch clone details.'); + return; + } + const cloneDetails = details.cloneDetails ?? { + clonedFrom: details.clonedFrom, + sourceInstanceIdentifier: details.sourceInstanceIdentifier, + }; + const content = JSON.stringify(cloneDetails, null, 2); + const uri = vscode.Uri.parse(`${SANDBOX_DETAIL_SCHEME}:${node.label ?? node.sandbox.id}-clone.json`); + detailProvider.setContent(uri, content); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.languages.setTextDocumentLanguage(doc, 'json'); + await vscode.window.showTextDocument(doc, {preview: true}); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + vscode.window.showErrorMessage(`Failed to fetch clone details: ${message}`); + } + }, + ); + }, + ); + return [ detailRegistration, refresh, @@ -302,5 +482,7 @@ export function registerSandboxCommands( viewDetails, openBM, extendExpiration, + clone, + viewCloneDetails, ]; } diff --git a/packages/b2c-vs-extension/src/sandbox-tree/sandbox-config.ts b/packages/b2c-vs-extension/src/sandbox-tree/sandbox-config.ts index 9d7a2fd7..8d60312d 100644 --- a/packages/b2c-vs-extension/src/sandbox-tree/sandbox-config.ts +++ b/packages/b2c-vs-extension/src/sandbox-tree/sandbox-config.ts @@ -18,6 +18,8 @@ export interface SandboxInfo { createdBy?: string; autoScheduled?: boolean; links?: Array<{href: string; rel: string}>; + clonedFrom?: string; + sourceInstanceIdentifier?: string; [key: string]: unknown; } diff --git a/packages/b2c-vs-extension/src/sandbox-tree/sandbox-tree-provider.ts b/packages/b2c-vs-extension/src/sandbox-tree/sandbox-tree-provider.ts index 42c564f7..d90d5225 100644 --- a/packages/b2c-vs-extension/src/sandbox-tree/sandbox-tree-provider.ts +++ b/packages/b2c-vs-extension/src/sandbox-tree/sandbox-tree-provider.ts @@ -6,6 +6,12 @@ import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk'; import {createOdsClient} from '@salesforce/b2c-tooling-sdk/clients'; import * as vscode from 'vscode'; +import { + TRANSITIONAL_STATES, + computeSandboxDisplay, + getActiveCloneSourceIds, + getRealmInstanceId, +} from './sandbox-clone-helpers.js'; import type {SandboxConfigProvider, SandboxInfo} from './sandbox-config.js'; const DEFAULT_ODS_HOST = 'admin.dx.commercecloud.salesforce.com'; @@ -16,6 +22,7 @@ const STATE_ICONS: Record = { starting: new vscode.ThemeIcon('sync~spin', new vscode.ThemeColor('charts.yellow')), stopping: new vscode.ThemeIcon('sync~spin', new vscode.ThemeColor('charts.yellow')), creating: new vscode.ThemeIcon('loading~spin', new vscode.ThemeColor('charts.yellow')), + cloning: new vscode.ThemeIcon('clone', new vscode.ThemeColor('notificationsWarningIcon.foreground')), failed: new vscode.ThemeIcon('error', new vscode.ThemeColor('testing.iconFailed')), deleting: new vscode.ThemeIcon('trash', new vscode.ThemeColor('charts.yellow')), }; @@ -38,25 +45,32 @@ export class SandboxTreeItem extends vscode.TreeItem { constructor( readonly sandbox: SandboxInfo, readonly realm: string, + isCloneSource = false, ) { const label = sandbox.instance ? `${sandbox.realm ?? ''}${sandbox.realm ? '-' : ''}${sandbox.instance}` : sandbox.id; super(label, vscode.TreeItemCollapsibleState.None); - const state = (sandbox.state ?? 'unknown').toLowerCase(); + const display = computeSandboxDisplay(sandbox, isCloneSource); + const rawState = (sandbox.state ?? 'unknown').toLowerCase(); const eolDate = sandbox.eol ? new Date(sandbox.eol).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'}) : undefined; - this.description = eolDate ? `${state} · expires ${eolDate}` : state; - this.iconPath = STATE_ICONS[state] ?? DEFAULT_ICON; - this.contextValue = `sandbox-${state}`; + this.description = eolDate ? `${display.displayState} · expires ${eolDate}` : display.displayState; + this.iconPath = display.isCloneInSetup + ? new vscode.ThemeIcon('server-process~spin', new vscode.ThemeColor('charts.yellow')) + : display.showAsCloning + ? (STATE_ICONS.cloning ?? DEFAULT_ICON) + : (STATE_ICONS[rawState] ?? DEFAULT_ICON); + this.contextValue = display.contextValue; const lines: string[] = [`ID: ${sandbox.id}`]; if (sandbox.hostName) lines.push(`Host: ${sandbox.hostName}`); - if (sandbox.state) lines.push(`State: ${sandbox.state}`); + if (display.tooltipStateLine) lines.push(`State: ${display.tooltipStateLine}`); if (sandbox.createdAt) lines.push(`Created: ${sandbox.createdAt}`); if (sandbox.eol) lines.push(`EOL: ${sandbox.eol}`); + if (sandbox.clonedFrom) lines.push(`Cloned from: ${sandbox.clonedFrom}`); this.tooltip = new vscode.MarkdownString(lines.join('\n\n')); this.command = { @@ -67,7 +81,6 @@ export class SandboxTreeItem extends vscode.TreeItem { } } -const TRANSITIONAL_STATES = new Set(['creating', 'starting', 'stopping', 'deleting']); const MAX_POLL_DURATION_MS = 10 * 60_000; function getPollIntervalMs(): number { @@ -91,9 +104,26 @@ export class SandboxTreeDataProvider implements vscode.TreeDataProvider(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; private pollingTimers = new Map>(); + /** Sandbox IDs currently acting as a clone source (tracked client-side while Clone Sandbox is in flight). */ + private activeCloneSources = new Set(); constructor(private readonly configProvider: SandboxConfigProvider) {} + markSourceCloning(sandboxId: string): void { + this.activeCloneSources.add(sandboxId); + this._onDidChangeTreeData.fire(); + } + + unmarkSourceCloning(sandboxId: string): void { + if (this.activeCloneSources.delete(sandboxId)) { + this._onDidChangeTreeData.fire(); + } + } + + hasActiveCloneSource(): boolean { + return this.activeCloneSources.size > 0; + } + refresh(): void { this.configProvider.clearCache(); this._onDidChangeTreeData.fire(); @@ -129,7 +159,14 @@ export class SandboxTreeDataProvider implements vscode.TreeDataProvider TRANSITIONAL_STATES.has((s.state ?? '').toLowerCase())); - if (!hasTransitional) { + // Keep polling while any clone is still being set up (target: "failed" with clonedFrom). + const hasCloneInSetup = sandboxes.some((s) => { + const isClone = typeof s.clonedFrom === 'string' && s.clonedFrom.length > 0; + return isClone && (s.state ?? '').toLowerCase() === 'failed'; + }); + // Keep polling while a Clone Sandbox action is in flight client-side. + const hasActiveSource = this.hasActiveCloneSource(); + if (!hasTransitional && !hasCloneInSetup && !hasActiveSource) { this.stopPollingRealm(realm); } }, 3000); @@ -188,7 +225,15 @@ export class SandboxTreeDataProvider implements vscode.TreeDataProvider { const cached = this.configProvider.getCachedSandboxes(element.realm); if (cached) { - return sortSandboxesByName(cached).map((s) => new SandboxTreeItem(s, element.realm)); + const sourceIds = getActiveCloneSourceIds(cached); + return sortSandboxesByName(cached).map( + (s) => + new SandboxTreeItem( + s, + element.realm, + this.activeCloneSources.has(s.id) || sourceIds.has(getRealmInstanceId(s) ?? ''), + ), + ); } const configProvider = this.configProvider.getConfigProvider(); @@ -220,7 +265,15 @@ export class SandboxTreeDataProvider implements vscode.TreeDataProvider new SandboxTreeItem(s, element.realm)); + const sourceIds = getActiveCloneSourceIds(sandboxes); + return sortSandboxesByName(sandboxes).map( + (s) => + new SandboxTreeItem( + s, + element.realm, + this.activeCloneSources.has(s.id) || sourceIds.has(getRealmInstanceId(s) ?? ''), + ), + ); } catch (err) { const message = err instanceof Error ? err.message : String(err); vscode.window.showErrorMessage(`Sandboxes (${element.realm}): ${message}`); @@ -244,4 +297,21 @@ export class SandboxTreeDataProvider implements vscode.TreeDataProvider { + const configProvider = this.configProvider.getConfigProvider(); + const config = configProvider.getConfig(); + if (!config?.hasOAuthConfig()) return undefined; + + const host = config.values.sandboxApiHost ?? DEFAULT_ODS_HOST; + const oauthOptions = await configProvider.getImplicitAuthOptions(); + const authStrategy = config.createOAuth(oauthOptions); + const odsClient = createOdsClient({host}, authStrategy); + const result = await odsClient.GET('/sandboxes/{sandboxId}', { + params: {path: {sandboxId}, query: {expand: ['clonedetails']}}, + }); + if (result.error || !result.data?.data) return undefined; + return result.data.data as unknown as SandboxInfo; + } } diff --git a/packages/b2c-vs-extension/src/test/sandbox-clone-helpers.test.ts b/packages/b2c-vs-extension/src/test/sandbox-clone-helpers.test.ts new file mode 100644 index 00000000..593171a7 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/sandbox-clone-helpers.test.ts @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import * as assert from 'assert'; +import { + CLONE_IN_PROGRESS_STATES, + TRANSITIONAL_STATES, + computeSandboxDisplay, + getActiveCloneSourceIds, + getRealmInstanceId, + type SandboxLike, +} from './sandbox-clone-helpers.js'; + +function sandbox(partial: Partial & {id: string}): SandboxLike { + return {...partial}; +} + +suite('sandbox-clone-helpers', () => { + suite('getRealmInstanceId', () => { + test('joins realm and instance', () => { + assert.strictEqual(getRealmInstanceId(sandbox({id: 'x', realm: 'zzzz', instance: '004'})), 'zzzz-004'); + }); + + test('returns undefined when either field is missing', () => { + assert.strictEqual(getRealmInstanceId(sandbox({id: 'x', realm: 'zzzz'})), undefined); + assert.strictEqual(getRealmInstanceId(sandbox({id: 'x', instance: '004'})), undefined); + assert.strictEqual(getRealmInstanceId(sandbox({id: 'x'})), undefined); + }); + }); + + suite('getActiveCloneSourceIds', () => { + test('collects clonedFrom when target is in a clone-in-setup state', () => { + const list: SandboxLike[] = [ + sandbox({id: 'src', realm: 'zzzz', instance: '004', state: 'started'}), + sandbox({id: 'tgt', realm: 'zzzz', instance: '005', state: 'cloning', clonedFrom: 'zzzz-004'}), + ]; + const sources = getActiveCloneSourceIds(list); + assert.deepStrictEqual([...sources], ['zzzz-004']); + }); + + test('treats creating + clonedFrom as in-progress', () => { + const list: SandboxLike[] = [ + sandbox({id: 'tgt', realm: 'zzzz', instance: '005', state: 'creating', clonedFrom: 'zzzz-004'}), + ]; + assert.ok(getActiveCloneSourceIds(list).has('zzzz-004')); + }); + + test('treats failed + clonedFrom as in-progress (clone setting up)', () => { + const list: SandboxLike[] = [ + sandbox({id: 'tgt', realm: 'zzzz', instance: '005', state: 'failed', clonedFrom: 'zzzz-004'}), + ]; + assert.ok(getActiveCloneSourceIds(list).has('zzzz-004')); + }); + + test('ignores cloned sandboxes that are already started', () => { + const list: SandboxLike[] = [ + sandbox({id: 'tgt', realm: 'zzzz', instance: '005', state: 'started', clonedFrom: 'zzzz-004'}), + ]; + assert.strictEqual(getActiveCloneSourceIds(list).size, 0); + }); + + test('ignores sandboxes without clonedFrom', () => { + const list: SandboxLike[] = [sandbox({id: 'src', realm: 'zzzz', instance: '004', state: 'cloning'})]; + assert.strictEqual(getActiveCloneSourceIds(list).size, 0); + }); + + test('handles empty clonedFrom string as absent', () => { + const list: SandboxLike[] = [ + sandbox({id: 'tgt', realm: 'zzzz', instance: '005', state: 'failed', clonedFrom: ''}), + ]; + assert.strictEqual(getActiveCloneSourceIds(list).size, 0); + }); + }); + + suite('computeSandboxDisplay', () => { + test('regular started sandbox', () => { + const d = computeSandboxDisplay(sandbox({id: 'x', state: 'started'}), false); + assert.strictEqual(d.displayState, 'started'); + assert.strictEqual(d.contextValue, 'sandbox-started'); + assert.strictEqual(d.isClone, false); + assert.strictEqual(d.isCloneInSetup, false); + assert.strictEqual(d.showAsCloning, false); + assert.strictEqual(d.tooltipStateLine, 'started'); + }); + + test('regular stopped sandbox', () => { + const d = computeSandboxDisplay(sandbox({id: 'x', state: 'stopped'}), false); + assert.strictEqual(d.displayState, 'stopped'); + assert.strictEqual(d.contextValue, 'sandbox-stopped'); + }); + + test('uppercase state is normalized to lowercase', () => { + const d = computeSandboxDisplay(sandbox({id: 'x', state: 'STARTED'}), false); + assert.strictEqual(d.displayState, 'started'); + assert.strictEqual(d.contextValue, 'sandbox-started'); + }); + + test('missing state renders as unknown', () => { + const d = computeSandboxDisplay(sandbox({id: 'x'}), false); + assert.strictEqual(d.displayState, 'unknown'); + assert.strictEqual(d.contextValue, 'sandbox-unknown'); + assert.strictEqual(d.tooltipStateLine, undefined); + }); + + test('cloned sandbox in failed state is "setting up"', () => { + const d = computeSandboxDisplay(sandbox({id: 'x', state: 'failed', clonedFrom: 'zzzz-004'}), false); + assert.strictEqual(d.displayState, 'setting up'); + assert.strictEqual(d.contextValue, 'sandbox-settingup-cloned'); + assert.strictEqual(d.isClone, true); + assert.strictEqual(d.isCloneInSetup, true); + assert.strictEqual(d.tooltipStateLine, 'setting up (clone in progress)'); + }); + + test('cloned sandbox in other states uses that state and carries -cloned suffix', () => { + const d = computeSandboxDisplay(sandbox({id: 'x', state: 'started', clonedFrom: 'zzzz-004'}), false); + assert.strictEqual(d.displayState, 'started'); + assert.strictEqual(d.contextValue, 'sandbox-started-cloned'); + assert.strictEqual(d.isClone, true); + assert.strictEqual(d.isCloneInSetup, false); + }); + + test('source of in-progress clone renders as "cloning"', () => { + const d = computeSandboxDisplay(sandbox({id: 'x', state: 'started'}), true); + assert.strictEqual(d.displayState, 'cloning'); + assert.strictEqual(d.contextValue, 'sandbox-cloning'); + assert.strictEqual(d.showAsCloning, true); + assert.strictEqual(d.tooltipStateLine, 'started (clone in progress)'); + }); + + test('source flag on a stopped source still shows cloning', () => { + const d = computeSandboxDisplay(sandbox({id: 'x', state: 'stopped'}), true); + assert.strictEqual(d.displayState, 'cloning'); + assert.strictEqual(d.contextValue, 'sandbox-cloning'); + assert.strictEqual(d.tooltipStateLine, 'stopped (clone in progress)'); + }); + + test('cloned + failed takes precedence over isCloneSource=true', () => { + // A sandbox that is itself a clone-in-setup should render as "setting up" even if + // somehow also flagged as a source (defensive: these cases shouldn't co-occur). + const d = computeSandboxDisplay(sandbox({id: 'x', state: 'failed', clonedFrom: 'zzzz-004'}), true); + assert.strictEqual(d.displayState, 'setting up'); + assert.strictEqual(d.contextValue, 'sandbox-settingup-cloned'); + assert.strictEqual(d.isCloneInSetup, true); + assert.strictEqual(d.showAsCloning, false); + }); + + test('empty clonedFrom string does not make sandbox a clone', () => { + const d = computeSandboxDisplay(sandbox({id: 'x', state: 'started', clonedFrom: ''}), false); + assert.strictEqual(d.isClone, false); + assert.strictEqual(d.contextValue, 'sandbox-started'); + }); + }); + + suite('state sets', () => { + test('CLONE_IN_PROGRESS_STATES contains the expected states', () => { + assert.ok(CLONE_IN_PROGRESS_STATES.has('cloning')); + assert.ok(CLONE_IN_PROGRESS_STATES.has('creating')); + assert.ok(CLONE_IN_PROGRESS_STATES.has('failed')); + }); + + test('TRANSITIONAL_STATES includes cloning (so realm polling keeps running)', () => { + assert.ok(TRANSITIONAL_STATES.has('cloning')); + assert.ok(TRANSITIONAL_STATES.has('creating')); + assert.ok(TRANSITIONAL_STATES.has('starting')); + assert.ok(TRANSITIONAL_STATES.has('stopping')); + assert.ok(TRANSITIONAL_STATES.has('deleting')); + }); + + test('TRANSITIONAL_STATES does not include terminal states', () => { + assert.ok(!TRANSITIONAL_STATES.has('started')); + assert.ok(!TRANSITIONAL_STATES.has('stopped')); + assert.ok(!TRANSITIONAL_STATES.has('failed')); + }); + }); +}); diff --git a/packages/b2c-vs-extension/src/test/sandbox-clone-helpers.ts b/packages/b2c-vs-extension/src/test/sandbox-clone-helpers.ts new file mode 100644 index 00000000..a243bd38 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/sandbox-clone-helpers.ts @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/* + * Test-local mirror of src/sandbox-tree/sandbox-clone-helpers.ts. + * Kept in sync manually — production code is outside this test config's rootDir. + */ + +export interface SandboxLike { + id: string; + realm?: string; + instance?: string; + state?: string; + clonedFrom?: string; +} + +export const CLONE_IN_PROGRESS_STATES = new Set(['cloning', 'creating', 'failed']); + +export const TRANSITIONAL_STATES = new Set(['creating', 'starting', 'stopping', 'deleting', 'cloning']); + +export function getRealmInstanceId(s: SandboxLike): string | undefined { + return s.realm && s.instance ? `${s.realm}-${s.instance}` : undefined; +} + +export function getActiveCloneSourceIds(sandboxes: SandboxLike[]): Set { + const sources = new Set(); + for (const s of sandboxes) { + if (typeof s.clonedFrom === 'string' && s.clonedFrom.length > 0) { + const state = (s.state ?? '').toLowerCase(); + if (CLONE_IN_PROGRESS_STATES.has(state)) { + sources.add(s.clonedFrom); + } + } + } + return sources; +} + +export interface SandboxDisplay { + displayState: string; + contextState: string; + contextValue: string; + isClone: boolean; + isCloneInSetup: boolean; + showAsCloning: boolean; + tooltipStateLine: string | undefined; +} + +export function computeSandboxDisplay(sandbox: SandboxLike, isCloneSource: boolean): SandboxDisplay { + const rawState = (sandbox.state ?? 'unknown').toLowerCase(); + const isClone = typeof sandbox.clonedFrom === 'string' && sandbox.clonedFrom.length > 0; + const isCloneInSetup = isClone && rawState === 'failed'; + const showAsCloning = isCloneSource && !isCloneInSetup; + const displayState = isCloneInSetup ? 'setting up' : showAsCloning ? 'cloning' : rawState; + const contextState = isCloneInSetup ? 'settingup' : showAsCloning ? 'cloning' : rawState; + const contextValue = isClone ? `sandbox-${contextState}-cloned` : `sandbox-${contextState}`; + let tooltipStateLine: string | undefined; + if (sandbox.state) { + tooltipStateLine = isCloneInSetup + ? 'setting up (clone in progress)' + : showAsCloning + ? `${sandbox.state} (clone in progress)` + : sandbox.state; + } + return {displayState, contextState, contextValue, isClone, isCloneInSetup, showAsCloning, tooltipStateLine}; +} diff --git a/packages/b2c-vs-extension/src/test/sandbox-menu.test.ts b/packages/b2c-vs-extension/src/test/sandbox-menu.test.ts new file mode 100644 index 00000000..26186319 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/sandbox-menu.test.ts @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Compiled test file lives at out/test/.js; package.json is 2 levels up. +const PACKAGE_JSON_PATH = path.resolve(__dirname, '..', '..', 'package.json'); + +interface MenuEntry { + command?: string; + submenu?: string; + when?: string; + group?: string; +} + +interface PackageJson { + contributes: { + commands: Array<{command: string; title: string}>; + menus: {'view/item/context': MenuEntry[]; commandPalette: MenuEntry[]}; + }; +} + +function loadPackageJson(): PackageJson { + return JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, 'utf8')); +} + +function getSandboxMenuEntries(pkg: PackageJson): Record { + const entries: Record = {}; + for (const entry of pkg.contributes.menus['view/item/context']) { + if (entry.command && entry.command.startsWith('b2c-dx.sandbox.')) { + entries[entry.command] = entry; + } + } + return entries; +} + +/** Extract the `viewItem =~ /.../ ` regex from a when clause. Returns null if the clause is not a regex. */ +function extractViewItemRegex(whenClause: string | undefined): RegExp | null { + if (!whenClause) return null; + const match = whenClause.match(/viewItem =~ \/(.+?)\/(?=\s|$|&|\))/); + return match ? new RegExp(match[1]) : null; +} + +function extractViewItemEquals(whenClause: string | undefined): string | null { + if (!whenClause) return null; + const match = whenClause.match(/viewItem == ([a-zA-Z0-9_-]+)/); + return match ? match[1] : null; +} + +/** Does the when clause accept a given viewItem context value? */ +function whenClauseMatches(whenClause: string | undefined, viewItem: string): boolean { + if (!whenClause) return false; + const regex = extractViewItemRegex(whenClause); + if (regex) return regex.test(viewItem); + const eq = extractViewItemEquals(whenClause); + if (eq) return eq === viewItem; + return false; +} + +suite('sandbox menu contributions (package.json)', () => { + let pkg: PackageJson; + let menu: Record; + + suiteSetup(() => { + pkg = loadPackageJson(); + menu = getSandboxMenuEntries(pkg); + }); + + suite('command registration', () => { + test('Clone Sandbox command is declared', () => { + const cmd = pkg.contributes.commands.find((c) => c.command === 'b2c-dx.sandbox.clone'); + assert.ok(cmd, 'b2c-dx.sandbox.clone must be declared in contributes.commands'); + assert.strictEqual(cmd?.title, 'Clone Sandbox'); + }); + + test('View Clone Details command is declared', () => { + const cmd = pkg.contributes.commands.find((c) => c.command === 'b2c-dx.sandbox.viewCloneDetails'); + assert.ok(cmd, 'b2c-dx.sandbox.viewCloneDetails must be declared in contributes.commands'); + assert.strictEqual(cmd?.title, 'View Clone Details'); + }); + + test('both new commands are hidden from the Command Palette', () => { + const paletteHidden = pkg.contributes.menus.commandPalette.filter( + (e) => + (e.command === 'b2c-dx.sandbox.clone' || e.command === 'b2c-dx.sandbox.viewCloneDetails') && + e.when === 'false', + ); + assert.strictEqual(paletteHidden.length, 2, 'both clone-related commands must be hidden from palette'); + }); + }); + + suite('Clone Sandbox visibility', () => { + const when = () => menu['b2c-dx.sandbox.clone']?.when; + + test('is shown for started sandboxes', () => { + assert.ok(whenClauseMatches(when(), 'sandbox-started')); + assert.ok(whenClauseMatches(when(), 'sandbox-started-cloned')); + }); + + test('is shown for stopped sandboxes', () => { + assert.ok(whenClauseMatches(when(), 'sandbox-stopped')); + assert.ok(whenClauseMatches(when(), 'sandbox-stopped-cloned')); + }); + + test('is hidden for cloning / settingup / other transitional states', () => { + assert.ok(!whenClauseMatches(when(), 'sandbox-cloning')); + assert.ok(!whenClauseMatches(when(), 'sandbox-cloning-cloned')); + assert.ok(!whenClauseMatches(when(), 'sandbox-settingup')); + assert.ok(!whenClauseMatches(when(), 'sandbox-settingup-cloned')); + assert.ok(!whenClauseMatches(when(), 'sandbox-creating')); + assert.ok(!whenClauseMatches(when(), 'sandbox-starting')); + assert.ok(!whenClauseMatches(when(), 'sandbox-stopping')); + assert.ok(!whenClauseMatches(when(), 'sandbox-deleting')); + assert.ok(!whenClauseMatches(when(), 'sandbox-failed')); + assert.ok(!whenClauseMatches(when(), 'sandbox-unknown')); + }); + }); + + suite('View Clone Details visibility', () => { + const when = () => menu['b2c-dx.sandbox.viewCloneDetails']?.when; + + test('is shown only when the sandbox is a clone (has -cloned suffix)', () => { + assert.ok(whenClauseMatches(when(), 'sandbox-started-cloned')); + assert.ok(whenClauseMatches(when(), 'sandbox-stopped-cloned')); + assert.ok(whenClauseMatches(when(), 'sandbox-settingup-cloned')); + assert.ok(whenClauseMatches(when(), 'sandbox-cloning-cloned')); + }); + + test('is hidden for non-cloned sandboxes', () => { + assert.ok(!whenClauseMatches(when(), 'sandbox-started')); + assert.ok(!whenClauseMatches(when(), 'sandbox-stopped')); + assert.ok(!whenClauseMatches(when(), 'sandbox-cloning')); + assert.ok(!whenClauseMatches(when(), 'sandbox-failed')); + }); + }); + + suite('Start/Stop/Restart still match -cloned variants', () => { + test('Start matches both sandbox-stopped and sandbox-stopped-cloned', () => { + const when = menu['b2c-dx.sandbox.start']?.when; + assert.ok(whenClauseMatches(when, 'sandbox-stopped')); + assert.ok(whenClauseMatches(when, 'sandbox-stopped-cloned')); + assert.ok(!whenClauseMatches(when, 'sandbox-started')); + }); + + test('Stop matches both sandbox-started and sandbox-started-cloned', () => { + const when = menu['b2c-dx.sandbox.stop']?.when; + assert.ok(whenClauseMatches(when, 'sandbox-started')); + assert.ok(whenClauseMatches(when, 'sandbox-started-cloned')); + assert.ok(!whenClauseMatches(when, 'sandbox-stopped')); + }); + + test('Restart matches both sandbox-started and sandbox-started-cloned', () => { + const when = menu['b2c-dx.sandbox.restart']?.when; + assert.ok(whenClauseMatches(when, 'sandbox-started')); + assert.ok(whenClauseMatches(when, 'sandbox-started-cloned')); + }); + }); + + suite('Open BM / Extend Expiration / Delete hide during cloning & settingup', () => { + const hiddenForStates = ['cloning', 'cloning-cloned', 'settingup', 'settingup-cloned']; + const visibleForStates = ['started', 'started-cloned', 'stopped', 'stopped-cloned', 'failed']; + + for (const cmd of ['b2c-dx.sandbox.openBM', 'b2c-dx.sandbox.extendExpiration', 'b2c-dx.sandbox.delete']) { + test(`${cmd} is hidden while cloning/settingup`, () => { + const when = menu[cmd]?.when; + for (const s of hiddenForStates) { + assert.ok(!whenClauseMatches(when, `sandbox-${s}`), `${cmd} should be hidden for sandbox-${s}`); + } + }); + + test(`${cmd} is visible for regular states`, () => { + const when = menu[cmd]?.when; + for (const s of visibleForStates) { + assert.ok(whenClauseMatches(when, `sandbox-${s}`), `${cmd} should be visible for sandbox-${s}`); + } + }); + } + }); + + suite('View Details remains available in all sandbox states', () => { + const when = () => menu['b2c-dx.sandbox.viewDetails']?.when; + + test('shown for every sandbox context value', () => { + for (const s of [ + 'started', + 'stopped', + 'cloning', + 'settingup', + 'creating', + 'starting', + 'stopping', + 'deleting', + 'failed', + 'unknown', + 'started-cloned', + 'settingup-cloned', + ]) { + assert.ok(whenClauseMatches(when(), `sandbox-${s}`), `View Details should show for sandbox-${s}`); + } + }); + }); +});