diff --git a/extensions/vscode/src/api/types/configurations.ts b/extensions/vscode/src/api/types/configurations.ts index e1a7af024..2e883f088 100644 --- a/extensions/vscode/src/api/types/configurations.ts +++ b/extensions/vscode/src/api/types/configurations.ts @@ -24,6 +24,19 @@ export type ConfigurationInspectionResult = { projectDir: string; }; +export const areInspectionResultsSimilarEnough = ( + inspect1: ConfigurationInspectionResult, + inspect2: ConfigurationInspectionResult, +) => { + // Not comparing ALL attributes, just enough to maintain uniqueness and + // confidence that this is a "similar" inspection result + return ( + inspect1.projectDir === inspect2.projectDir && + inspect1.configuration.entrypoint === inspect2.configuration.entrypoint && + inspect1.configuration.type === inspect2.configuration.type + ); +}; + export function isConfigurationError( cfg: Configuration | ConfigurationError, ): cfg is ConfigurationError { @@ -49,6 +62,24 @@ export enum ContentType { UNKNOWN = "unknown", } +export const allValidContentTypes: ContentType[] = [ + ContentType.HTML, + ContentType.JUPYTER_NOTEBOOK, + ContentType.JUPYTER_VOILA, + ContentType.PYTHON_BOKEH, + ContentType.PYTHON_DASH, + ContentType.PYTHON_FASTAPI, + ContentType.PYTHON_FLASK, + ContentType.PYTHON_SHINY, + ContentType.PYTHON_STREAMLIT, + ContentType.QUARTO_SHINY, + ContentType.QUARTO, + ContentType.R_PLUMBER, + ContentType.R_SHINY, + ContentType.RMD_SHINY, + ContentType.RMD, +]; + export const contentTypeStrings = { [ContentType.HTML]: "serve pre-rendered HTML", [ContentType.JUPYTER_NOTEBOOK]: "render with Jupyter nbconvert", @@ -66,7 +97,8 @@ export const contentTypeStrings = { [ContentType.RMD_SHINY]: "render with rmarkdown/knitr and run embedded Shiny app", [ContentType.RMD]: "render with rmarkdown/knitr", - [ContentType.UNKNOWN]: "unknown content type; cannot deploy this item", + [ContentType.UNKNOWN]: + "unknown content type; manual selection needed to deploy", }; export type ConfigurationDetails = { diff --git a/extensions/vscode/src/constants.ts b/extensions/vscode/src/constants.ts index 24febcd94..0c4b6aa85 100644 --- a/extensions/vscode/src/constants.ts +++ b/extensions/vscode/src/constants.ts @@ -10,6 +10,18 @@ export const DEPLOYMENTS_PATTERN = "**/.posit/publish/deployments/*.toml"; export const DEFAULT_PYTHON_PACKAGE_FILE = "requirements.txt"; export const DEFAULT_R_PACKAGE_FILE = "renv.lock"; +// pulled from /internal/services/api/get_entrypoints.go +// should all be lowercase! +export const ENTRYPOINT_FILE_EXTENSIONS = [ + ".htm", + ".html", + ".ipynb", + ".py", + ".qmd", + ".r", + ".rmd", +]; + const baseCommands = { InitProject: "posit.publisher.init-project", ShowOutputChannel: "posit.publisher.showOutputChannel", diff --git a/extensions/vscode/src/multiStepInputs/multiStepHelper.ts b/extensions/vscode/src/multiStepInputs/multiStepHelper.ts index df6ea766b..69f2cf47a 100644 --- a/extensions/vscode/src/multiStepInputs/multiStepHelper.ts +++ b/extensions/vscode/src/multiStepInputs/multiStepHelper.ts @@ -1,5 +1,6 @@ // Copyright (C) 2024 by Posit Software, PBC. +import { ConfigurationInspectionResult } from "src/api"; import { QuickPickItem, window, @@ -21,6 +22,9 @@ export function isQuickPickItem(d: QuickPickItem | string): d is QuickPickItem { } export type QuickPickItemWithIndex = QuickPickItem & { index: number }; +export type QuickPickItemWithInspectionResult = QuickPickItem & { + inspectionResult?: ConfigurationInspectionResult; +}; export function isQuickPickItemWithIndex( d: QuickPickItem | string, @@ -28,12 +32,23 @@ export function isQuickPickItemWithIndex( return (d as QuickPickItemWithIndex).index !== undefined; } +export function isQuickPickItemWithInspectionResult( + d: QuickPickItem | string, +): d is QuickPickItemWithInspectionResult { + return ( + (d as QuickPickItemWithInspectionResult).inspectionResult !== undefined + ); +} + export interface MultiStepState { title: string; step: number; lastStep: number; totalSteps: number; - data: Record; + data: Record< + string, + QuickPickItem | QuickPickItemWithInspectionResult | string | undefined + >; promptStepNumbers: Record; } diff --git a/extensions/vscode/src/multiStepInputs/newDeployment.ts b/extensions/vscode/src/multiStepInputs/newDeployment.ts index 52bd325e4..385339a12 100644 --- a/extensions/vscode/src/multiStepInputs/newDeployment.ts +++ b/extensions/vscode/src/multiStepInputs/newDeployment.ts @@ -4,9 +4,9 @@ import path from "path"; import { MultiStepInput, MultiStepState, - QuickPickItemWithIndex, + QuickPickItemWithInspectionResult, isQuickPickItem, - isQuickPickItemWithIndex, + isQuickPickItemWithInspectionResult, } from "src/multiStepInputs/multiStepHelper"; import { @@ -17,6 +17,7 @@ import { Uri, commands, window, + workspace, } from "vscode"; import { @@ -27,6 +28,8 @@ import { contentTypeStrings, ConfigurationInspectionResult, EntryPointPath, + areInspectionResultsSimilarEnough, + ContentType, } from "src/api"; import { getPythonInterpreterPath } from "src/utils/config"; import { @@ -38,389 +41,8 @@ import { formatURL, normalizeURL } from "src/utils/url"; import { checkSyntaxApiKey } from "src/utils/apiKeys"; import { DeploymentObjects } from "src/types/shared"; import { showProgress } from "src/utils/progress"; -import { vscodeOpenFiles } from "src/utils/files"; - -type stepInfo = { - step: number; - totalSteps: number; -}; - -type possibleSteps = { - noCredentials: { - singleEntryPoint: stepInfo; - multipleEntryPoints: { - singleContentType: stepInfo; - multipleContentTypes: stepInfo; - }; - }; - newCredentials: { - singleEntryPoint: stepInfo; - multipleEntryPoints: { - singleContentType: stepInfo; - multipleContentTypes: stepInfo; - }; - }; - existingCredentials: { - singleEntryPoint: stepInfo; - multipleEntryPoints: { - singleContentType: stepInfo; - multipleContentTypes: stepInfo; - }; - }; -}; - -const steps: Record = { - inputEntryPointFileSelection: { - noCredentials: { - singleEntryPoint: { - step: 0, // not yet shown - totalSteps: 4, - }, - multipleEntryPoints: { - singleContentType: { - step: 1, - totalSteps: 5, - }, - multipleContentTypes: { - step: 1, - totalSteps: 6, - }, - }, - }, - newCredentials: { - singleEntryPoint: { - step: 0, // not yet shown - totalSteps: 5, - }, - multipleEntryPoints: { - singleContentType: { - step: 1, - totalSteps: 6, - }, - multipleContentTypes: { - step: 1, - totalSteps: 7, - }, - }, - }, - existingCredentials: { - singleEntryPoint: { - step: 0, // not yet shown - totalSteps: 2, - }, - multipleEntryPoints: { - singleContentType: { - step: 1, - totalSteps: 3, - }, - multipleContentTypes: { - step: 1, - totalSteps: 4, - }, - }, - }, - }, - inputEntryPointContentTypeSelection: { - noCredentials: { - singleEntryPoint: { - step: 0, // still 0 - totalSteps: 4, - }, - multipleEntryPoints: { - singleContentType: { - step: 1, // not shown - totalSteps: 5, - }, - multipleContentTypes: { - step: 2, - totalSteps: 6, - }, - }, - }, - newCredentials: { - singleEntryPoint: { - step: 0, // still 0 - totalSteps: 5, - }, - multipleEntryPoints: { - singleContentType: { - step: 1, // not shown - totalSteps: 6, - }, - multipleContentTypes: { - step: 2, - totalSteps: 7, - }, - }, - }, - existingCredentials: { - singleEntryPoint: { - step: 0, // still 0 - totalSteps: 2, - }, - multipleEntryPoints: { - singleContentType: { - step: 1, // not shown - totalSteps: 3, - }, - multipleContentTypes: { - step: 2, - totalSteps: 4, - }, - }, - }, - }, - inputTitle: { - noCredentials: { - singleEntryPoint: { - step: 1, - totalSteps: 4, - }, - multipleEntryPoints: { - singleContentType: { - step: 2, - totalSteps: 5, - }, - multipleContentTypes: { - step: 3, - totalSteps: 6, - }, - }, - }, - newCredentials: { - singleEntryPoint: { - step: 1, - totalSteps: 5, - }, - multipleEntryPoints: { - singleContentType: { - step: 2, - totalSteps: 6, - }, - multipleContentTypes: { - step: 3, - totalSteps: 7, - }, - }, - }, - existingCredentials: { - singleEntryPoint: { - step: 1, - totalSteps: 2, - }, - multipleEntryPoints: { - singleContentType: { - step: 2, - totalSteps: 3, - }, - multipleContentTypes: { - step: 3, - totalSteps: 4, - }, - }, - }, - }, - pickCredentials: { - noCredentials: { - singleEntryPoint: { - step: 1, // not shown - totalSteps: 4, - }, - multipleEntryPoints: { - singleContentType: { - step: 2, // not shown - totalSteps: 5, - }, - multipleContentTypes: { - step: 3, // not shown - totalSteps: 6, - }, - }, - }, - newCredentials: { - singleEntryPoint: { - step: 2, - totalSteps: 5, - }, - multipleEntryPoints: { - singleContentType: { - step: 3, - totalSteps: 6, - }, - multipleContentTypes: { - step: 4, - totalSteps: 7, - }, - }, - }, - existingCredentials: { - singleEntryPoint: { - step: 2, - totalSteps: 2, - }, - multipleEntryPoints: { - singleContentType: { - step: 3, - totalSteps: 3, - }, - multipleContentTypes: { - step: 4, - totalSteps: 4, - }, - }, - }, - }, - inputServerUrl: { - noCredentials: { - singleEntryPoint: { - step: 2, - totalSteps: 4, - }, - multipleEntryPoints: { - singleContentType: { - step: 3, - totalSteps: 5, - }, - multipleContentTypes: { - step: 4, - totalSteps: 6, - }, - }, - }, - newCredentials: { - singleEntryPoint: { - step: 3, - totalSteps: 5, - }, - multipleEntryPoints: { - singleContentType: { - step: 4, - totalSteps: 6, - }, - multipleContentTypes: { - step: 5, - totalSteps: 7, - }, - }, - }, - existingCredentials: { - singleEntryPoint: { - step: 2, // not shown - totalSteps: 2, - }, - multipleEntryPoints: { - singleContentType: { - step: 3, // not shown - totalSteps: 3, - }, - multipleContentTypes: { - step: 4, // not shown - totalSteps: 4, - }, - }, - }, - }, - inputAPIKey: { - noCredentials: { - singleEntryPoint: { - step: 3, - totalSteps: 4, - }, - multipleEntryPoints: { - singleContentType: { - step: 4, - totalSteps: 5, - }, - multipleContentTypes: { - step: 5, - totalSteps: 6, - }, - }, - }, - newCredentials: { - singleEntryPoint: { - step: 4, - totalSteps: 5, - }, - multipleEntryPoints: { - singleContentType: { - step: 5, - totalSteps: 6, - }, - multipleContentTypes: { - step: 6, - totalSteps: 7, - }, - }, - }, - existingCredentials: { - singleEntryPoint: { - step: 2, // not shown - totalSteps: 2, - }, - multipleEntryPoints: { - singleContentType: { - step: 3, // not shown - totalSteps: 3, - }, - multipleContentTypes: { - step: 4, // not shown - totalSteps: 4, - }, - }, - }, - }, - inputCredentialName: { - noCredentials: { - singleEntryPoint: { - step: 4, - totalSteps: 4, - }, - multipleEntryPoints: { - singleContentType: { - step: 5, - totalSteps: 5, - }, - multipleContentTypes: { - step: 6, - totalSteps: 6, - }, - }, - }, - newCredentials: { - singleEntryPoint: { - step: 5, - totalSteps: 5, - }, - multipleEntryPoints: { - singleContentType: { - step: 6, - totalSteps: 6, - }, - multipleContentTypes: { - step: 7, - totalSteps: 7, - }, - }, - }, - existingCredentials: { - singleEntryPoint: { - step: 2, // not shown - totalSteps: 2, - }, - multipleEntryPoints: { - singleContentType: { - step: 3, // not shown - totalSteps: 3, - }, - multipleContentTypes: { - step: 4, // not shown - totalSteps: 4, - }, - }, - }, - }, -}; +import { relativeDir, relativePath, vscodeOpenFiles } from "src/utils/files"; +import { ENTRYPOINT_FILE_EXTENSIONS } from "src/constants"; export async function newDeployment( viewId: string, @@ -435,7 +57,6 @@ export async function newDeployment( let credentials: Credential[] = []; let credentialListItems: QuickPickItem[] = []; - let discoveredEntryPoints: string[] = []; let entryPointListItems: QuickPickItem[] = []; let inspectionResults: ConfigurationInspectionResult[] = []; let contentRecordNames = new Map(); @@ -445,126 +66,95 @@ export async function newDeployment( let newContentRecord: PreContentRecord | undefined; const createNewCredentialLabel = "Create a New Credential"; + const browseForEntrypointLabel = "Open..."; - const newCredentialForced = (state?: MultiStepState): boolean => { - if (!state) { - return false; - } - return credentials.length === 0; + // Collected Data + type SelectedEntrypoint = { + filePath?: string; + inspectionResult?: ConfigurationInspectionResult; + contentType?: ContentType; }; - - const newCredentialSelected = (state?: MultiStepState): boolean => { - if (!state) { - return false; - } - return Boolean( - state.data.credentialName && - isQuickPickItem(state.data.credentialName) && - state.data.credentialName.label === createNewCredentialLabel, - ); + type NewCredentialAttrs = { + url?: string; + name?: string; + apiKey?: string; + }; + type NewDeploymentData = { + entrypoint: SelectedEntrypoint; + title?: string; + existingCredentialName?: string; + newCredentials: NewCredentialAttrs; }; - const newCredentialByAnyMeans = (state?: MultiStepState): boolean => { - return newCredentialForced(state) || newCredentialSelected(state); + let newDeploymentData: NewDeploymentData = { + entrypoint: {}, + newCredentials: {}, }; - const hasMultiplePossibleEntryPointFiles = () => { - return inspectionResults.length > 1; + const newCredentialForced = (): boolean => { + return credentials.length === 0; }; - const hasMultipleContentTypesForSelectedEntryPoint = () => { - return inspectionResults.length > 1; + const newCredentialSelected = (): boolean => { + return Boolean( + newDeploymentData?.existingCredentialName === createNewCredentialLabel, + ); }; - const getStepInfo = ( - stepId: string, - multiStepState: MultiStepState, - ): stepInfo | undefined => { - const step = steps[stepId]; - if (!step) { - // if we have not covered the step, then don't number it. - return { - step: 0, - totalSteps: 0, - }; - } - if (newCredentialForced(multiStepState)) { - if (hasMultiplePossibleEntryPointFiles()) { - if (hasMultipleContentTypesForSelectedEntryPoint()) { - return step.noCredentials.multipleEntryPoints.multipleContentTypes; - } - return step.noCredentials.multipleEntryPoints.singleContentType; - } - return step.noCredentials.singleEntryPoint; - } - if (newCredentialSelected(multiStepState)) { - if (hasMultiplePossibleEntryPointFiles()) { - if (hasMultipleContentTypesForSelectedEntryPoint()) { - return step.newCredentials.multipleEntryPoints.multipleContentTypes; - } - return step.newCredentials.multipleEntryPoints.singleContentType; - } - return step.newCredentials.singleEntryPoint; - } - // else it has to be existing credential selected - if (hasMultiplePossibleEntryPointFiles()) { - if (hasMultipleContentTypesForSelectedEntryPoint()) { - return step.existingCredentials.multipleEntryPoints - .multipleContentTypes; - } - return step.existingCredentials.multipleEntryPoints.singleContentType; - } - return step.existingCredentials.singleEntryPoint; + const newCredentialByAnyMeans = (): boolean => { + return newCredentialForced() || newCredentialSelected(); }; const getConfigurationInspectionQuickPicks = ( relEntryPoint: EntryPointPath, ) => { - return new Promise(async (resolve, reject) => { - const inspectionListItems: QuickPickItemWithIndex[] = []; - - try { - const python = await getPythonInterpreterPath(); - const relEntryPointDir = path.dirname(relEntryPoint); - const relEntryPointFile = path.basename(relEntryPoint); - - const inspectResponse = await api.configurations.inspect( - relEntryPointDir, - python, - { - entrypoint: relEntryPointFile, - }, - ); + return new Promise( + async (resolve, reject) => { + const inspectionListItems: QuickPickItemWithInspectionResult[] = []; + + try { + const python = await getPythonInterpreterPath(); + const relEntryPointDir = path.dirname(relEntryPoint); + const relEntryPointFile = path.basename(relEntryPoint); + + const inspectResponse = await api.configurations.inspect( + relEntryPointDir, + python, + { + entrypoint: relEntryPointFile, + }, + ); - inspectionResults = inspectResponse.data; - inspectionResults.forEach((result, i) => { - const config = result.configuration; - if (config.entrypoint) { - inspectionListItems.push({ - iconPath: new ThemeIcon("gear"), - label: config.type.toString(), - description: `(${contentTypeStrings[config.type]})`, - index: i, - }); - } - }); - } catch (error: unknown) { - const summary = getSummaryStringFromError( - "newDeployment, configurations.inspect", - error, - ); - window.showErrorMessage( - `Unable to continue with project inspection failure for ${entryPointFile}. ${summary}`, - ); - return reject(); - } - if (!inspectionListItems.length) { - const msg = `Unable to continue with no project entrypoints found during inspection for ${entryPointFile}.`; - window.showErrorMessage(msg); - return reject(); - } - return resolve(inspectionListItems); - }); + inspectionResults = inspectResponse.data; + inspectionResults.forEach((result) => { + const config = result.configuration; + if (config.entrypoint) { + inspectionListItems.push({ + iconPath: new ThemeIcon("gear"), + label: config.type.toString(), + description: `(${contentTypeStrings[config.type]})`, + inspectionResult: result, + }); + } + }); + } catch (error: unknown) { + const summary = getSummaryStringFromError( + "newDeployment, configurations.inspect", + error, + ); + window.showErrorMessage( + `Unable to continue with project inspection failure for ${entryPointFile}. ${summary}`, + ); + return reject(); + } + if (!inspectionListItems.length) { + const msg = `Unable to continue with no project entrypoints found during inspection for ${entryPointFile}.`; + window.showErrorMessage(msg); + return reject(); + } + return resolve(inspectionListItems); + }, + ); }; const getCredentials = new Promise(async (resolve, reject) => { @@ -588,88 +178,53 @@ export async function newDeployment( window.showErrorMessage( `Unable to continue with a failed API response. ${summary}`, ); - return reject(); + return reject(summary); } return resolve(); }); - const getEntrypoints = new Promise(async (resolve, reject) => { - try { - if (entryPointFile) { - // we were passed in a specific entrypoint file. - // while we don't need it, we'll still provide the results - // in the same way. - const entryPointPath = path.join(projectDir, entryPointFile); - entryPointListItems.push({ - iconPath: new ThemeIcon("file"), - label: entryPointPath, - }); - discoveredEntryPoints.push(entryPointPath); - return resolve(); - } - const entrypointFilesOpened: string[] = []; - const entrypointFilesUnopened: string[] = []; - - // rely upon the backend to tell us what are valid entrypoints - const entryPointsResponse = await api.entrypoints.get(projectDir); - discoveredEntryPoints = entryPointsResponse.data; + const getEntrypoints = new Promise((resolve) => { + if (entryPointFile) { + // we were passed in a specific entrypoint file. + // while we don't need it, we'll still provide the results + // in the same way. + const entryPointPath = path.join(projectDir, entryPointFile); + entryPointListItems.push({ + iconPath: new ThemeIcon("file"), + label: entryPointPath, + }); + return resolve(); + } - // build up a list of open files, relative to the opened workspace folder - const openFileList: string[] = vscodeOpenFiles(); + // build up a list of open files, relative to the opened workspace folder + const filteredOpenFileList = vscodeOpenFiles().filter((openFilePath) => { + const parsedPath = path.parse(openFilePath); + return ENTRYPOINT_FILE_EXTENSIONS.includes(parsedPath.ext.toLowerCase()); + }); + filteredOpenFileList.sort(); - // loop through and now separate possible entrypoints into open or not - discoveredEntryPoints.forEach((entrypointFile) => { - if ( - openFileList.find( - (f) => f.toLowerCase() === entrypointFile.toLowerCase(), - ) - ) { - entrypointFilesOpened.push(entrypointFile); - } else { - entrypointFilesUnopened.push(entrypointFile); - } + // build the entrypointList + if (filteredOpenFileList.length) { + entryPointListItems.push({ + label: "Open Files", + kind: QuickPickItemKind.Separator, }); - - // build the entrypointList - if (entrypointFilesOpened.length) { + filteredOpenFileList.forEach((openFile) => { entryPointListItems.push({ - label: "Open Files", - kind: QuickPickItemKind.Separator, - }); - entrypointFilesOpened.forEach((entryPointFile) => { - entryPointListItems.push({ - iconPath: new ThemeIcon("file"), - label: entryPointFile, - }); - }); - } - if (entrypointFilesUnopened.length) { - entryPointListItems.push({ - label: "Discovered Entrypoints", - kind: QuickPickItemKind.Separator, - }); - entrypointFilesUnopened.forEach((entryPointFile) => { - entryPointListItems.push({ - iconPath: new ThemeIcon("file"), - label: entryPointFile, - }); + iconPath: new ThemeIcon("file"), + label: openFile, }); - } - } catch (error: unknown) { - const summary = getSummaryStringFromError( - "newDeployment, entrypoints.get", - error, - ); - window.showErrorMessage( - `Unable to continue with project entrypoint detection failure. ${summary}`, - ); - return reject(); - } - if (!discoveredEntryPoints.length) { - const msg = `Unable to continue with no project entrypoints found.`; - window.showErrorMessage(msg); - return reject(); + }); } + entryPointListItems.push({ + label: "Other", + kind: QuickPickItemKind.Separator, + }); + entryPointListItems.push({ + iconPath: new ThemeIcon("files"), + label: browseForEntrypointLabel, + detail: "Select a file as your entrypoint.", + }); return resolve(); }); @@ -736,17 +291,7 @@ export async function newDeployment( step: 0, lastStep: 0, totalSteps: 0, - data: { - // each attribute is initialized to undefined - // to be returned when it has not been cancelled to assist type guards - entryPointPath: undefined, // eventual type is QuickPickItem - entryPoint: undefined, // eventual type is QuickPickItemWithIndex - title: undefined, // eventual type is string - credentialName: undefined, // eventual type is either a string or QuickPickItem - url: undefined, // eventual type is string - name: undefined, // eventual type is string - apiKey: undefined, // eventual type is string - }, + data: {}, promptStepNumbers: {}, }; @@ -758,126 +303,162 @@ export async function newDeployment( } // *************************************************************** - // Step #1 - maybe?: - // Select the entrypoint to be used w/ the contentRecord + // Step: Select the entrypoint to be used w/ the contentRecord // *************************************************************** async function inputEntryPointFileSelection( input: MultiStepInput, state: MultiStepState, ) { - // in case we have backed up from the subsequent check, we need to reset - // the array that it will update. This will allow steps to be the minimum number - // as long as we don't know it will take another one. - inspectionResults = []; - - // show only if we have more than one potential entrypoint discovered - if (discoveredEntryPoints.length > 1) { - const step = getStepInfo("inputEntryPointFileSelection", state); - if (!step) { - window.showErrorMessage( - "Internal Error: newDeployment::inputEntryPointFileSelection step info not found.", - ); - return; + // show only if we were not passed in a file + if (entryPointFile === undefined) { + if (newDeploymentData.entrypoint.filePath) { + entryPointListItems.forEach((item) => { + item.picked = item.label === newDeploymentData.entrypoint.filePath; + }); } - const pick = await input.showQuickPick({ - title: state.title, - step: step.step, - totalSteps: step.totalSteps, - placeholder: - "Select entrypoint file. This is your main file for your project. (Use this field to filter selections.)", - items: entryPointListItems, - buttons: [], - shouldResume: () => Promise.resolve(false), - ignoreFocusOut: true, - }); - state.data.entryPointPath = pick.label; + let selectedEntrypointFile: string | undefined = undefined; + do { + const pick = await input.showQuickPick({ + title: state.title, + step: 0, + totalSteps: 0, + placeholder: + "Select entrypoint file. This is your main file for your project. (Use this field to filter selections.)", + items: entryPointListItems, + buttons: [], + shouldResume: () => Promise.resolve(false), + ignoreFocusOut: true, + }); + + if (pick.label === browseForEntrypointLabel) { + let baseUri = Uri.parse("."); + const workspaceFolders = workspace.workspaceFolders; + if (workspaceFolders !== undefined) { + baseUri = workspaceFolders[0].uri; + } + selectedEntrypointFile = undefined; + const fileUris = await window.showOpenDialog({ + defaultUri: baseUri, + openLabel: "Select", + canSelectFolders: false, + canSelectMany: false, + title: "Select Entrypoint File (main file for your project)", + }); + if (!fileUris || !fileUris[0]) { + // cancelled. + continue; + } + const fileUri = fileUris[0]; + + if (relativeDir(fileUri)) { + selectedEntrypointFile = relativePath(fileUri); + } else { + window.showErrorMessage( + `Entrypoint files must be located within the open workspace. + File ${fileUri.fsPath} is not located within the open workspace: ${baseUri.fsPath}.`, + { + modal: true, + }, + ); + selectedEntrypointFile = undefined; + } + } else { + if (isQuickPickItem(pick)) { + selectedEntrypointFile = pick.label; + } else { + return; + } + } + } while (!selectedEntrypointFile); + newDeploymentData.entrypoint.filePath = selectedEntrypointFile; return (input: MultiStepInput) => - inputEntryPointContentTypeSelection(input, state); + inputEntryPointInspectionResultSelection(input, state); } else { - state.data.entryPointPath = discoveredEntryPoints[0]; + // We were passed in a specific file, so set and continue to inspection + newDeploymentData.entrypoint.filePath = entryPointFile; // We're skipping this step, so we must silently just jump to the next step - return inputEntryPointContentTypeSelection(input, state); + return inputEntryPointInspectionResultSelection(input, state); } } // *************************************************************** - // Step #2 - maybe?: - // Select the content type of the entrypoint + // Step: Select the content inspection result should use // *************************************************************** - async function inputEntryPointContentTypeSelection( + async function inputEntryPointInspectionResultSelection( input: MultiStepInput, state: MultiStepState, ) { - if (!state.data.entryPointPath) { + if (!newDeploymentData.entrypoint.filePath) { return; } - - // always relative - const entryPointPath = isQuickPickItem(state.data.entryPointPath) - ? state.data.entryPointPath.label - : state.data.entryPointPath; + // Have to create a copy of the guarded value, to keep language service happy + // within anonymous function below + const entryPointFilePath = path.join( + projectDir, + newDeploymentData.entrypoint.filePath, + ); const inspectionQuickPicks = await showProgress( "Scanning::newDeployment", viewId, - async () => await getConfigurationInspectionQuickPicks(entryPointPath), + async () => + await getConfigurationInspectionQuickPicks(entryPointFilePath), ); // skip if we only have one choice. if (inspectionQuickPicks.length > 1) { - const step = getStepInfo("inputEntryPointContentTypeSelection", state); - if (!step) { - window.showErrorMessage( - "Internal Error: newDeployment::inputEntryPointContentTypeSelection step info not found.", - ); - return; + if (newDeploymentData.entrypoint.inspectionResult) { + inspectionQuickPicks.forEach((pick) => { + if ( + pick.inspectionResult && + newDeploymentData.entrypoint.inspectionResult + ) { + pick.picked = areInspectionResultsSimilarEnough( + pick.inspectionResult, + newDeploymentData.entrypoint.inspectionResult, + ); + } + }); } const pick = await input.showQuickPick({ title: state.title, - step: step.step, - totalSteps: step.totalSteps, - placeholder: `Select the content type for your entrypoint file (${entryPointPath}).`, + step: 0, + totalSteps: 0, + placeholder: `Select the content type for your entrypoint file (${newDeploymentData.entrypoint.filePath}).`, items: inspectionQuickPicks, buttons: [], shouldResume: () => Promise.resolve(false), ignoreFocusOut: true, }); - state.data.entryPoint = pick; + if (!pick || !isQuickPickItemWithInspectionResult(pick)) { + return; + } + + newDeploymentData.entrypoint.inspectionResult = pick.inspectionResult; return (input: MultiStepInput) => inputTitle(input, state); } else { - state.data.entryPoint = inspectionQuickPicks[0]; + newDeploymentData.entrypoint.inspectionResult = + inspectionQuickPicks[0].inspectionResult; // We're skipping this step, so we must silently just jump to the next step return inputTitle(input, state); } } // *************************************************************** - // Step #2 - maybe - // Input the Title + // Step: Input the Title // *************************************************************** async function inputTitle(input: MultiStepInput, state: MultiStepState) { // in case we have backed up from the subsequent check, we need to reset // the selection that it will update. This will allow steps to be the minimum number // as long as we don't know for certain it will take more steps. - state.data.credentialName = undefined; - const step = getStepInfo("inputTitle", state); - if (!step) { - window.showErrorMessage( - "Internal Error: newDeployment::inputTitle step info not found.", - ); - return; - } let initialValue = ""; - if ( - state.data.entryPoint && - isQuickPickItemWithIndex(state.data.entryPoint) - ) { + if (newDeploymentData.entrypoint.inspectionResult) { const detail = - inspectionResults[state.data.entryPoint.index].configuration.title; + newDeploymentData.entrypoint.inspectionResult.configuration.title; if (detail) { initialValue = detail; } @@ -885,10 +466,9 @@ export async function newDeployment( const title = await input.showInputBox({ title: state.title, - step: step.step, - totalSteps: step.totalSteps, - value: - typeof state.data.title === "string" ? state.data.title : initialValue, + step: 0, + totalSteps: 0, + value: newDeploymentData.title ? newDeploymentData.title : initialValue, prompt: "Enter a title for your content or application.", validate: (value) => { if (value.length < 3) { @@ -903,39 +483,33 @@ export async function newDeployment( ignoreFocusOut: true, }); - state.data.title = title; + newDeploymentData.title = title; return (input: MultiStepInput) => pickCredentials(input, state); } // *************************************************************** - // Step #3 - maybe - // Select the credentials to be used + // Step: Select the credentials to be used // *************************************************************** async function pickCredentials(input: MultiStepInput, state: MultiStepState) { - if (!newCredentialForced(state)) { - const step = getStepInfo("pickCredentials", state); - if (!step) { - window.showErrorMessage( - "Internal Error: newDeployment::pickCredentials step info not found.", - ); - return; + if (!newCredentialForced()) { + if (newDeploymentData.existingCredentialName) { + credentialListItems.forEach((credential) => { + credential.picked = + credential.label === newDeploymentData.existingCredentialName; + }); } const pick = await input.showQuickPick({ title: state.title, - step: step.step, - totalSteps: step.totalSteps, + step: 0, + totalSteps: 0, placeholder: "Select the credential you want to use to deploy. (Use this field to filter selections.)", items: credentialListItems, - activeItem: - typeof state.data.credentialName !== "string" - ? state.data.credentialName - : undefined, buttons: [], shouldResume: () => Promise.resolve(false), ignoreFocusOut: true, }); - state.data.credentialName = pick; + newDeploymentData.existingCredentialName = pick.label; return (input: MultiStepInput) => inputServerUrl(input, state); } @@ -943,28 +517,18 @@ export async function newDeployment( } // *************************************************************** - // Step #4 - maybe?: - // Get the server url + // Step: New Credentials - Get the server url // *************************************************************** async function inputServerUrl(input: MultiStepInput, state: MultiStepState) { - if (newCredentialByAnyMeans(state)) { - const currentURL = - typeof state.data.url === "string" && state.data.url.length - ? state.data.url - : ""; - - const step = getStepInfo("inputServerUrl", state); - if (!step) { - window.showErrorMessage( - "Internal Error: newDeployment::inputServerUrl step info not found.", - ); - return; - } + if (newCredentialByAnyMeans()) { + const currentURL = newDeploymentData.newCredentials.url + ? newDeploymentData.newCredentials.url + : ""; const url = await input.showInputBox({ title: state.title, - step: step.step, - totalSteps: step.totalSteps, + step: 0, + totalSteps: 0, value: currentURL, prompt: "Enter the Public URL of the Posit Connect Server", placeholder: "example: https://servername.com:3939", @@ -1031,40 +595,30 @@ export async function newDeployment( ignoreFocusOut: true, }); - state.data.url = formatURL(url.trim()); + newDeploymentData.newCredentials.url = formatURL(url.trim()); return (input: MultiStepInput) => inputAPIKey(input, state); } return inputAPIKey(input, state); } // *************************************************************** - // Step #5 - maybe?: - // Enter the API Key + // Step: New Credentials - Enter the API Key // *************************************************************** async function inputAPIKey(input: MultiStepInput, state: MultiStepState) { - if (newCredentialByAnyMeans(state)) { - const currentAPIKey = - typeof state.data.apiKey === "string" && state.data.apiKey.length - ? state.data.apiKey - : ""; - - const step = getStepInfo("inputAPIKey", state); - if (!step) { - window.showErrorMessage( - "Internal Error: newDeployment::inputAPIKey step info not found.", - ); - return; - } + if (newCredentialByAnyMeans()) { + const currentAPIKey = newDeploymentData.newCredentials.apiKey + ? newDeploymentData.newCredentials.apiKey + : ""; const apiKey = await input.showInputBox({ title: state.title, - step: step.step, - totalSteps: step.totalSteps, + step: 0, + totalSteps: 0, password: true, value: currentAPIKey, prompt: `The API key to be used to authenticate with Posit Connect. - See the [User Guide](https://docs.posit.co/connect/user/api-keys/index.html#api-keys-creating) - for further information.`, + See the [User Guide](https://docs.posit.co/connect/user/api-keys/index.html#api-keys-creating) + for further information.`, validate: (input: string) => { if (input.includes(" ")) { return Promise.resolve({ @@ -1085,8 +639,9 @@ export async function newDeployment( } // url should always be defined by the time we get to this step // but we have to type guard it for the API - const serverUrl = - typeof state.data.url === "string" ? state.data.url : ""; + const serverUrl = newDeploymentData.newCredentials.url + ? newDeploymentData.newCredentials.url + : ""; try { const testResult = await api.credentials.test(serverUrl, input); if (testResult.status !== 200) { @@ -1113,38 +668,28 @@ export async function newDeployment( ignoreFocusOut: true, }); - state.data.apiKey = apiKey; + newDeploymentData.newCredentials.apiKey = apiKey; return (input: MultiStepInput) => inputCredentialName(input, state); } return inputCredentialName(input, state); } // *************************************************************** - // Step #6 - maybe?: - // Name the credential + // Step: New Credentials - Name the credential // *************************************************************** async function inputCredentialName( input: MultiStepInput, state: MultiStepState, ) { - if (newCredentialByAnyMeans(state)) { - const currentName = - typeof state.data.name === "string" && state.data.name.length - ? state.data.name - : ""; - - const step = getStepInfo("inputCredentialName", state); - if (!step) { - window.showErrorMessage( - "Internal Error: newDeployment::inputCredentialName step info not found.", - ); - return; - } + if (newCredentialByAnyMeans()) { + const currentName = newDeploymentData.newCredentials.name + ? newDeploymentData.newCredentials.name + : ""; const name = await input.showInputBox({ title: state.title, - step: step.step, - totalSteps: step.totalSteps, + step: 0, + totalSteps: 0, value: currentName, prompt: "Enter a Unique Nickname for your Credential.", placeholder: "example: Posit Connect", @@ -1176,7 +721,7 @@ export async function newDeployment( ignoreFocusOut: true, }); - state.data.name = name.trim(); + newDeploymentData.newCredentials.name = name.trim(); } // last step } @@ -1197,96 +742,88 @@ export async function newDeployment( ); } catch { // errors have already been displayed by the underlying promises.. - return; + return undefined; } - const state = await collectInputs(); + await collectInputs(); // make sure user has not hit escape or moved away from the window // before completing the steps. This also serves as a type guard on // our state data vars down to the actual type desired if ( - (!newCredentialForced(state) && state.data.credentialName === undefined) || - // credentialName can be either type - state.data.entryPoint === undefined || - !isQuickPickItemWithIndex(state.data.entryPoint) || - state.data.title === undefined || - typeof state.data.title !== "string" + !newDeploymentData.entrypoint.filePath || + !newDeploymentData.entrypoint.inspectionResult || + !newDeploymentData.title || + (!newCredentialByAnyMeans() && !newDeploymentData.existingCredentialName) ) { console.log("User has aborted flow. Exiting."); - return; + return undefined; } // Maybe create a new credential? - if (newCredentialByAnyMeans(state)) { + if (newCredentialByAnyMeans()) { // have to type guard here, will protect us against // cancellation. if ( - state.data.url === undefined || - isQuickPickItem(state.data.url) || - state.data.name === undefined || - isQuickPickItem(state.data.name) || - state.data.apiKey === undefined || - isQuickPickItem(state.data.apiKey) + !newDeploymentData.newCredentials.url || + !newDeploymentData.newCredentials.apiKey || + !newDeploymentData.newCredentials.name ) { - window.showErrorMessage( - "Internal Error: NewDeployment Unexpected type guard failure @1", - ); - return; + console.log("User has aborted flow. Exiting."); + return undefined; } try { // NEED an credential to be returned from this API // and assigned to newOrExistingCredential const response = await api.credentials.create( - state.data.name, - state.data.url, - state.data.apiKey, + newDeploymentData.newCredentials.name, + newDeploymentData.newCredentials.url, + newDeploymentData.newCredentials.apiKey, ); newOrSelectedCredential = response.data; } catch (error: unknown) { const summary = getSummaryStringFromError("credentials::add", error); window.showInformationMessage(summary); } - } else if (state.data.credentialName) { - // If not creating, then we need to retrieve the one we're using. - let targetName: string | undefined = undefined; - if (isQuickPickItem(state.data.credentialName)) { - targetName = state.data.credentialName.label; - } - if (targetName) { - newOrSelectedCredential = credentials.find( - (credential) => credential.name === targetName, + } else if (newDeploymentData.existingCredentialName) { + newOrSelectedCredential = credentials.find( + (credential) => + credential.name === newDeploymentData.existingCredentialName, + ); + if (!newOrSelectedCredential) { + window.showErrorMessage( + `Internal Error: NewDeployment Unable to find credential: ${newDeploymentData.existingCredentialName}`, ); + return undefined; } } else { // we are not creating a credential but also do not have a required existing value window.showErrorMessage( "Internal Error: NewDeployment Unexpected type guard failure @2", ); - return; + return undefined; } // Create the Config File let configName: string | undefined; - const selectedInspectionResult = - inspectionResults[state.data.entryPoint.index]; - if (!selectedInspectionResult) { - window.showErrorMessage( - `Unable to proceed creating configuration. Error retrieving config for ${state.data.entryPoint.label}, index = ${state.data.entryPoint.index}`, - ); - return; - } - selectedInspectionResult.configuration.title = state.data.title; + + newDeploymentData.entrypoint.inspectionResult.configuration.title = + newDeploymentData.title; try { const existingNames = ( - await api.configurations.getAll(selectedInspectionResult.projectDir) + await api.configurations.getAll( + newDeploymentData.entrypoint.inspectionResult.projectDir, + ) ).data.map((config) => config.configurationName); - configName = newConfigFileNameFromTitle(state.data.title, existingNames); + configName = newConfigFileNameFromTitle( + newDeploymentData.title, + existingNames, + ); const createResponse = await api.configurations.createOrUpdate( configName, - selectedInspectionResult.configuration, - selectedInspectionResult.projectDir, + newDeploymentData.entrypoint.inspectionResult.configuration, + newDeploymentData.entrypoint.inspectionResult.projectDir, ); const fileUri = Uri.file(createResponse.data.configurationPath); newConfig = createResponse.data; @@ -1297,50 +834,21 @@ export async function newDeployment( error, ); window.showErrorMessage(`Failed to create config file. ${summary}`); - return; - } - - let finalCredentialName = undefined; - if ( - newCredentialForced(state) && - state.data.name && - !isQuickPickItem(state.data.name) - ) { - finalCredentialName = state.data.name; - } else if (!state.data.credentialName) { - window.showErrorMessage( - "Internal Error: NewDeployment Unexpected type guard failure @3", - ); - return; - } else if ( - newCredentialSelected(state) && - state.data.name && - !isQuickPickItem(state.data.name) - ) { - finalCredentialName = state.data.name; - } else if (isQuickPickItem(state.data.credentialName)) { - finalCredentialName = state.data.credentialName.label; - } - if (!finalCredentialName) { - // should have assigned it by now. Logic error! - window.showErrorMessage( - "Internal Error: NewDeployment Unexpected type guard failure @4", - ); - return; + return undefined; } // Create the PreContentRecord File try { let existingNames = contentRecordNames.get( - selectedInspectionResult.projectDir, + newDeploymentData.entrypoint.inspectionResult.projectDir, ); if (!existingNames) { existingNames = []; } const contentRecordName = newDeploymentName(existingNames); const response = await api.contentRecords.createNew( - selectedInspectionResult.projectDir, - finalCredentialName, + newDeploymentData.entrypoint.inspectionResult.projectDir, + newOrSelectedCredential?.name, configName, contentRecordName, ); @@ -1353,13 +861,13 @@ export async function newDeployment( window.showErrorMessage( `Failed to create pre-deployment record. ${summary}`, ); - return; + return undefined; } if (!newOrSelectedCredential) { window.showErrorMessage( "Internal Error: NewDeployment Unexpected type guard failure @5", ); - return; + return undefined; } return { contentRecord: newContentRecord, diff --git a/extensions/vscode/src/views/homeView.ts b/extensions/vscode/src/views/homeView.ts index 298142922..a196ea48c 100644 --- a/extensions/vscode/src/views/homeView.ts +++ b/extensions/vscode/src/views/homeView.ts @@ -1069,6 +1069,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { private async showDeploymentQuickPick( contentRecordsSubset?: AllContentRecordTypes[], projectDir?: string, + entrypointFile?: string, ): Promise { try { // disable our home view, we are initiating a multi-step sequence @@ -1243,7 +1244,11 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { // If user selected create new, then switch over to that flow if (deployment?.label === createNewDeploymentLabel) { - return this.showNewDeploymentMultiStep(Views.HomeView); + return this.showNewDeploymentMultiStep( + Views.HomeView, + projectDir, + entrypointFile, + ); } let deploymentSelector: DeploymentSelector | undefined; @@ -1657,6 +1662,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { const selected = await this.showDeploymentQuickPick( compatibleContentRecords, entrypointDir, + entrypointFile, ); return selected; } diff --git a/test/vscode-ui/test/specs/fastapi.spec.ts b/test/vscode-ui/test/specs/fastapi.spec.ts index d3280a412..b86d90004 100644 --- a/test/vscode-ui/test/specs/fastapi.spec.ts +++ b/test/vscode-ui/test/specs/fastapi.spec.ts @@ -46,12 +46,18 @@ describe("VS Code Extension UI Test", () => { await expect(createMessage).toHaveText("Create a New Deployment"); await createMessage.click(); - // confirm title is ready and set title - const titleMessage = await browser.$("#quickInput_message"); - await titleMessage.waitForExist({ timeout: 5000 }); + const openFile = await browser.$(".label-name"); + await expect(createMessage).toHaveText("Open..."); + await openFile.click(); + + const simplepy = browser.$(`aria/simple.py`); + await simplepy.click(); + + const titleMessage = browser.$("#quickInput_message"); await expect(titleMessage).toHaveText( "Enter a title for your content or application. (Press 'Enter' to confirm or 'Escape' to cancel)", ); + await input.setValue("my fastapi app"); await browser.keys("\uE007"); // set server url diff --git a/test/vscode-ui/test/specs/nested-fastapi-configuration.spec.ts b/test/vscode-ui/test/specs/nested-fastapi-configuration.spec.ts index f39977d40..08848698b 100644 --- a/test/vscode-ui/test/specs/nested-fastapi-configuration.spec.ts +++ b/test/vscode-ui/test/specs/nested-fastapi-configuration.spec.ts @@ -5,6 +5,7 @@ import * as path from "path"; import { fileURLToPath } from "url"; import { dirname } from "path"; import * as helper from "../helpers.ts"; +import { sleep } from "wdio-vscode-service"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -20,6 +21,8 @@ describe("Nested Fast API Configuration", () => { before(async () => { workbench = await browser.getWorkbench(); input = await $(".input"); + // const filePath = path.join(__dirname, "test/sample-content/fastapi-simple/simple.py"); + // await workbench.openFile(filePath); }); it("open extension", async () => { @@ -37,6 +40,7 @@ describe("Nested Fast API Configuration", () => { const selectButton = (await $('[data-automation="select-deployment"]')).$( ".quick-pick-label", ); + await expect(selectButton).toHaveText("Select..."); await selectButton.click(); @@ -48,79 +52,18 @@ describe("Nested Fast API Configuration", () => { await expect(createMessage).toHaveText("Create a New Deployment"); await createMessage.click(); - // verify each entrypoint is found and listed - const quickpick = await browser.$(".quick-input-list"); - await quickpick.waitForExist({ timeout: 30000 }); - }); - - it("can list simplepy entrypoint", async () => { - const simplepy = browser.$(`aria/fastapi-simple${sep}simple.py`); - await expect(simplepy).toExist(); - }); - - it("can list quartoProjNoneMulti entrypoint", async () => { - const quartoProjNoneMulti = browser.$( - `aria/quarto-proj-none${sep}quarto-proj-none.qmd`, - ); - await expect(quartoProjNoneMulti).toExist(); - }); - - it("can list simplepyMulti entrypoint", async () => { - const simplepyMulti = browser.$(`aria/multi-type${sep}simple.py`); - await expect(simplepyMulti).toExist(); - }); - - it("can list quartoProjNone entrypoint", async () => { - const quartoProjNone = browser.$( - `aria/quarto-proj-none${sep}quarto-proj-none.qmd`, - ); - await expect(quartoProjNone).toExist(); - }); - - it("can list quartoProjPy entrypoint", async () => { - const quartoProjPy = browser.$( - `aria/quarto-proj-py${sep}quarto-proj-py.qmd`, - ); - await expect(quartoProjPy).toExist(); - }); - - it("can list quartoProjR entrypoint", async () => { - const quartoProjR = browser.$(`aria/quarto-proj-r${sep}quarto-proj-r.qmd`); - await expect(quartoProjR).toExist(); - }); - - it("can list quartoProject entrypoint", async () => { - const quartoProject = browser.$( - `aria/quarto-project${sep}quarto-project.qmd`, - ); - await expect(quartoProject).toExist(); - }); - - it("can list rmdHtml entrypoint", async () => { - const rmdHtml = browser.$(`aria/rmd-static-1${sep}index.htm`); - await expect(rmdHtml).toExist(); - }); - - it("can list rmdKnitr entrypoint", async () => { - const rmdKnitr = browser.$(`aria/rmd-static-1${sep}static.Rmd`); - await expect(rmdKnitr).toExist(); - }); + const openFile = await browser.$(".label-name"); + await expect(createMessage).toHaveText("Open..."); + await openFile.click(); - it("can list shiny entrypoint", async () => { - const shiny = browser.$(`aria/shinyapp${sep}app.R`); - await expect(shiny).toExist(); - }); + const fastapiFolder = browser.$(`aria/fastapi-simple`); + await fastapiFolder.click(); - it("can list shinyHtml entrypoint", async () => { - const shinyHtml = browser.$(`aria/shinyapp${sep}index.htm`); - await expect(shinyHtml).toExist(); + const simplepy = browser.$(`aria/simple.py`); + await simplepy.click(); }); it("can select entrypoint", async () => { - const simplepy = browser.$(`aria/fastapi-simple${sep}simple.py`); - await expect(simplepy).toExist(); - await simplepy.click(); - const titleMessage = browser.$("#quickInput_message"); await expect(titleMessage).toHaveText( "Enter a title for your content or application. (Press 'Enter' to confirm or 'Escape' to cancel)", diff --git a/test/vscode-ui/test/specs/nested-fastapi-deployment.spec.ts b/test/vscode-ui/test/specs/nested-fastapi-deployment.spec.ts index e5a866da6..c39da2002 100644 --- a/test/vscode-ui/test/specs/nested-fastapi-deployment.spec.ts +++ b/test/vscode-ui/test/specs/nested-fastapi-deployment.spec.ts @@ -156,7 +156,7 @@ describe("Nested Fast API Deployment", () => { '[data-automation="my connect server-list"]', ); await expect(credentialList).toHaveText( - "my connect server" + process.env.CONNECT_SERVER, + `my connect server\n` + process.env.CONNECT_SERVER, ); });