From 6403b8dd53f02e392c149940285585e0891004d5 Mon Sep 17 00:00:00 2001 From: "@rugpanov" Date: Thu, 13 Aug 2026 16:53:21 +0200 Subject: [PATCH 1/4] feat(python-setup): add "Install uv" button to the uv-missing error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *Why* When `environments setup-local` fails because uv is not installed (`E_UV_MISSING`), the error popup only offered "Show Logs", leaving the user to find and install uv on their own. Give them a one-click path to uv's install guide, matching how the extension already surfaces the Azure CLI install instructions. *What* - errorMessages.ts: single-source `UV_INSTALL_DOCS_URL` and a small `getPythonSetupErrorAction(result)` helper that returns an {label, url} action only for `E_UV_MISSING`, so the call-to-action lives next to the message copy. - PythonSetupEnvironmentSetup.ts: `showError` gains an optional `action` arg; the failure path passes `getPythonSetupErrorAction(result)`. - pythonSetupDeps.ts: render the remediation button first (before "Show Logs") and open its URL via the existing `openExternal` helper when picked. Deliberately links to the docs (which pick the right installer per platform) rather than running an installer itself. The `action` param is optional, so the other `showError` call sites are unchanged and keep their Show-Logs-only popup. *Verification* - TDD: 5 new unit tests (errorMessages action mapping; deps button shown + URL opened on pick, not opened otherwise; orchestrator passes the action on uv-missing and none on other failures). - yarn test:unit — 739 passing, 0 failing. - tsc --noEmit clean; yarn test:lint + Prettier clean. Co-authored-by: Isaac --- .../PythonSetupEnvironmentSetup.test.ts | 61 +++++++++++++++++++ .../PythonSetupEnvironmentSetup.ts | 14 ++++- .../controllers/pythonSetupDeps.test.ts | 60 +++++++++++++++++- .../controllers/pythonSetupDeps.ts | 15 ++++- .../python-setup/utils/errorMessages.test.ts | 26 ++++++++ .../src/python-setup/utils/errorMessages.ts | 34 +++++++++++ 6 files changed, 205 insertions(+), 5 deletions(-) diff --git a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts index 4829e201c..cf1795e7b 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.test.ts @@ -14,6 +14,10 @@ import { SUCCESS_REAL_RUN_WITH_WARNINGS, ERROR_NO_TARGET, } from "../models/fixtures/setupLocalResults"; +import { + PythonSetupErrorAction, + UV_INSTALL_DOCS_URL, +} from "../utils/errorMessages"; import {SetupLocalInvocation} from "../utils/setupLocalArgs"; import { PythonSetupAttempt, @@ -333,6 +337,63 @@ describe("PythonSetupEnvironmentSetup.setup", () => { expect(shown[0].detail).to.contain("E_NO_TARGET"); }); + it("offers an Install uv action when the CLI reports uv is missing", async () => { + const shown: { + message: string; + action?: PythonSetupErrorAction; + }[] = []; + const uvMissing: PythonSetupResult = { + schemaVersion: 1, + command: "environments setup-local", + ok: false, + mode: "default", + dryRun: false, + greenfield: false, + phases: [], + warnings: [], + durationMs: 0, + error: { + code: "E_UV_MISSING", + failurePhase: "preflight", + message: "uv not found on PATH", + diskMutated: false, + }, + }; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli: makeCli({resolve: uvMissing}), + showError: async (message, _detail, action) => { + shown.push({message, action}); + }, + }) + ); + + await setup.setup(); + + expect(shown).to.have.length(1); + expect(shown[0].action).to.deep.equal({ + label: "Install uv", + url: UV_INSTALL_DOCS_URL, + }); + }); + + it("passes no remediation action for failures other than uv-missing", async () => { + const shown: {action?: PythonSetupErrorAction}[] = []; + const setup = new PythonSetupEnvironmentSetup( + makeDeps({ + cli: makeCli({resolve: ERROR_NO_TARGET}), + showError: async (_message, _detail, action) => { + shown.push({action}); + }, + }) + ); + + await setup.setup(); + + expect(shown).to.have.length(1); + expect(shown[0].action).to.equal(undefined); + }); + it("surfaces the raw error message when the CLI run rejects", async () => { const shownErrors: string[] = []; const setup = new PythonSetupEnvironmentSetup( diff --git a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts index b8a8c3006..3b7cf260e 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupEnvironmentSetup.ts @@ -10,8 +10,10 @@ import { } from "../models/PythonSetupResult"; import { formatSetupFailureDetail, + getPythonSetupErrorAction, getPythonSetupErrorMessage, NO_COMPUTE_TARGET_MESSAGE, + PythonSetupErrorAction, } from "../utils/errorMessages"; import {SetupLocalInvocation} from "../utils/setupLocalArgs"; import { @@ -130,8 +132,15 @@ export interface PythonSetupSetupDeps { * action that reveals the setup output channel. `detail`, when given, is * written to that channel first (see `formatSetupFailureDetail`), so the * button leads to the CLI's full explanation instead of an empty log. + * `action`, when given, adds one more button that opens an external URL — + * e.g. "Install uv" pointing at uv's install guide (see + * `getPythonSetupErrorAction`). */ - showError: (message: string, detail?: string) => Promise; + showError: ( + message: string, + detail?: string, + action?: PythonSetupErrorAction + ) => Promise; showSuccess: (result: PythonSetupResult) => Promise; @@ -346,7 +355,8 @@ export class PythonSetupEnvironmentSetup implements Disposable { }); await this.deps.showError( getPythonSetupErrorMessage(result), - formatSetupFailureDetail(result) + formatSetupFailureDetail(result), + getPythonSetupErrorAction(result) ); return; } diff --git a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts index c92d61b7b..27d72c9b2 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts @@ -1,5 +1,5 @@ import {expect} from "chai"; -import {Uri, window} from "vscode"; +import {env, Uri, window} from "vscode"; import { makePythonSetupDeps, makePythonSetupVisibility, @@ -520,6 +520,64 @@ describe("makePythonSetupDeps showError", () => { expect(appended).to.have.length(0); expect(shownWith[0].actions).to.contain("Show Logs"); }); + + it("offers the given action button and opens its URL when picked", async () => { + const originalOpen = env.openExternal; + const opened: string[] = []; + (env as unknown as {openExternal: unknown}).openExternal = async ( + uri: Uri + ) => { + opened.push(uri.toString(true)); + return true; + }; + try { + const deps = makePythonSetupDeps( + makeWiring({log: {append: () => {}, show: () => {}}}) + ); + reply = "Install uv"; + + await deps.showError("uv missing", "detail", { + label: "Install uv", + url: "https://docs.astral.sh/uv/getting-started/installation/", + }); + + expect(shownWith[0].actions).to.contain("Install uv"); + expect(shownWith[0].actions).to.contain("Show Logs"); + expect(opened).to.deep.equal([ + "https://docs.astral.sh/uv/getting-started/installation/", + ]); + } finally { + (env as unknown as {openExternal: unknown}).openExternal = + originalOpen; + } + }); + + it("does not open the URL when the action button is not picked", async () => { + const originalOpen = env.openExternal; + const opened: string[] = []; + (env as unknown as {openExternal: unknown}).openExternal = async ( + uri: Uri + ) => { + opened.push(uri.toString(true)); + return true; + }; + try { + const deps = makePythonSetupDeps( + makeWiring({log: {append: () => {}, show: () => {}}}) + ); + reply = "Show Logs"; + + await deps.showError("uv missing", "detail", { + label: "Install uv", + url: "https://docs.astral.sh/uv/getting-started/installation/", + }); + + expect(opened).to.have.length(0); + } finally { + (env as unknown as {openExternal: unknown}).openExternal = + originalOpen; + } + }); }); describe("makePythonSetupDeps showSuccess", () => { diff --git a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts index 82f1b40fe..e2fb56343 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts @@ -5,6 +5,8 @@ import {PackageManagerDetection} from "../../language/packageManagerDetection"; import {Telemetry} from "../../telemetry"; import "../../telemetry/pythonSetupExtensions"; import {PythonSetupState} from "../../vscode-objs/StateStorage"; +import {openExternal} from "../../utils/urlUtils"; +import {PythonSetupErrorAction} from "../utils/errorMessages"; import {shouldShowPythonSetup} from "../utils/pythonSetupGate"; import {formatSetupLog, formatSetupNotification} from "../utils/setupSummary"; import {venvInterpreterPath} from "../utils/venvInterpreterPath"; @@ -208,7 +210,11 @@ export function makePythonSetupDeps( // revealing the (empty) output channel. await window.showWarningMessage(message); }, - showError: async (message: string, detail?: string) => { + showError: async ( + message: string, + detail?: string, + action?: PythonSetupErrorAction + ) => { // The mapped one-liner is deliberately concise and drops the CLI's // own explanation; write that detail into the channel so the log the // popup points at actually contains it (under `--output json` the CLI @@ -221,9 +227,14 @@ export function makePythonSetupDeps( // notification (with its jump-to-logs button) as before. wiring.log.show(); const showLogs = "Show Logs"; - const picked = await window.showErrorMessage(message, showLogs); + // Lead with the remediation button (e.g. "Install uv") when one is + // attached, so the action the user most likely wants comes first. + const actions = action ? [action.label, showLogs] : [showLogs]; + const picked = await window.showErrorMessage(message, ...actions); if (picked === showLogs) { wiring.log.show(); + } else if (action && picked === action.label) { + await openExternal(action.url); } }, showSuccess: async (result) => { diff --git a/packages/databricks-vscode/src/python-setup/utils/errorMessages.test.ts b/packages/databricks-vscode/src/python-setup/utils/errorMessages.test.ts index 6641b6ace..712733145 100644 --- a/packages/databricks-vscode/src/python-setup/utils/errorMessages.test.ts +++ b/packages/databricks-vscode/src/python-setup/utils/errorMessages.test.ts @@ -1,7 +1,9 @@ import {expect} from "chai"; import { formatSetupFailureDetail, + getPythonSetupErrorAction, getPythonSetupErrorMessage, + UV_INSTALL_DOCS_URL, } from "./errorMessages"; import { PythonSetupResult, @@ -205,6 +207,30 @@ describe("getPythonSetupErrorMessage", () => { }); }); +describe("getPythonSetupErrorAction", () => { + it("offers an Install uv action pointing at the uv docs for E_UV_MISSING", () => { + const action = getPythonSetupErrorAction( + failure("E_UV_MISSING", {failurePhase: "preflight"}) + ); + expect(action).to.deep.equal({ + label: "Install uv", + url: UV_INSTALL_DOCS_URL, + }); + }); + + it("offers no action for error codes other than E_UV_MISSING", () => { + expect(getPythonSetupErrorAction(failure("E_PROVISION"))).to.equal( + undefined + ); + }); + + it("offers no action when the result carries no error", () => { + const ok = failure("E_UV_MISSING"); + ok.error = null; + expect(getPythonSetupErrorAction(ok)).to.equal(undefined); + }); +}); + describe("formatSetupFailureDetail", () => { it("names the failing phase and error code", () => { const detail = formatSetupFailureDetail( diff --git a/packages/databricks-vscode/src/python-setup/utils/errorMessages.ts b/packages/databricks-vscode/src/python-setup/utils/errorMessages.ts index 3e75fd1ef..d372cf82c 100644 --- a/packages/databricks-vscode/src/python-setup/utils/errorMessages.ts +++ b/packages/databricks-vscode/src/python-setup/utils/errorMessages.ts @@ -25,6 +25,25 @@ import { export const NO_COMPUTE_TARGET_MESSAGE = "Select a cluster or serverless compute before setting up the environment."; +/** + * uv's official installation guide. Single-sourced here so the popup action and + * its test point at the same page; the extension deliberately links to the docs + * (which pick the right installer per platform) rather than running an installer + * itself. + */ +export const UV_INSTALL_DOCS_URL = + "https://docs.astral.sh/uv/getting-started/installation/"; + +/** + * An optional remediation button to attach to a failure popup: a label and the + * external URL it opens. Kept alongside {@link getPythonSetupErrorMessage} so the + * copy and its call-to-action live together. + */ +export interface PythonSetupErrorAction { + label: string; + url: string; +} + /* eslint-disable @typescript-eslint/naming-convention */ const BASE_MESSAGE: Record< PythonSetupErrorCode, @@ -76,6 +95,21 @@ export function getPythonSetupErrorMessage(result: PythonSetupResult): string { return base + diskStateSuffix(result, err); } +/** + * The remediation button, if any, for a failed setup result. Only `E_UV_MISSING` + * carries one today: the CLI could neither find nor auto-install uv, so we point + * the user at uv's install guide. All other codes are actionable from the message + * and logs alone, so they get no extra button. + */ +export function getPythonSetupErrorAction( + result: PythonSetupResult +): PythonSetupErrorAction | undefined { + if (result.error?.code === "E_UV_MISSING") { + return {label: "Install uv", url: UV_INSTALL_DOCS_URL}; + } + return undefined; +} + /** * The detailed failure text for the "Databricks Python Environment Setup" output * channel — the counterpart to {@link getPythonSetupErrorMessage}'s concise From 1dd9bddc4c605e84749483ffe1a01e6b4cbb3242 Mon Sep 17 00:00:00 2001 From: "@rugpanov" Date: Thu, 13 Aug 2026 17:10:46 +0200 Subject: [PATCH 2/4] fix(python-setup): drop a remediation action that reuses the Show Logs label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *Why* Code review (Isaac + Codex + Claude) independently flagged that `showErrorMessage` returns the picked button as a bare label string, so two buttons sharing a label are indistinguishable. If a future `PythonSetupErrorAction` ever used the reserved "Show Logs" label, its URL branch would be dead code and the click would show the log instead. Harmless today (the only action is "Install uv") but worth guarding. *What* - pythonSetupDeps.ts: ignore an action whose label equals the reserved "Show Logs" (offer the single unambiguous Show Logs button instead of a colliding pair), and dispatch on that guarded `remediation`. *Verification* - TDD: new unit test asserts a colliding "Show Logs" action yields only one button and never opens the URL. - yarn test:unit — 740 passing, 0 failing. - tsc --noEmit clean; yarn test:lint + Prettier clean. Co-authored-by: Isaac --- .../controllers/pythonSetupDeps.test.ts | 34 +++++++++++++++++++ .../controllers/pythonSetupDeps.ts | 14 ++++++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts index 27d72c9b2..e48e30bc4 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts @@ -552,6 +552,40 @@ describe("makePythonSetupDeps showError", () => { } }); + it("ignores a remediation action that reuses the reserved Show Logs label", async () => { + // Defensive: the picked value comes back as a bare label string, so two + // buttons sharing "Show Logs" would be indistinguishable and the URL + // branch would be dead. Such an action is dropped (log-only) rather than + // silently mis-dispatched. + const originalOpen = env.openExternal; + const opened: string[] = []; + (env as unknown as {openExternal: unknown}).openExternal = async ( + uri: Uri + ) => { + opened.push(uri.toString(true)); + return true; + }; + try { + const deps = makePythonSetupDeps( + makeWiring({log: {append: () => {}, show: () => {}}}) + ); + reply = "Show Logs"; + + await deps.showError("uv missing", "detail", { + label: "Show Logs", + url: "https://docs.astral.sh/uv/getting-started/installation/", + }); + + // Only the single, unambiguous Show Logs button is offered... + expect(shownWith[0].actions).to.deep.equal(["Show Logs"]); + // ...and clicking it reveals the log, never opening the URL. + expect(opened).to.have.length(0); + } finally { + (env as unknown as {openExternal: unknown}).openExternal = + originalOpen; + } + }); + it("does not open the URL when the action button is not picked", async () => { const originalOpen = env.openExternal; const opened: string[] = []; diff --git a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts index e2fb56343..fe35c674b 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts @@ -227,14 +227,22 @@ export function makePythonSetupDeps( // notification (with its jump-to-logs button) as before. wiring.log.show(); const showLogs = "Show Logs"; + // `showErrorMessage` hands the picked value back as a bare label + // string, so two buttons sharing a label are indistinguishable. Drop + // a remediation action that reuses the reserved "Show Logs" label + // rather than offer an ambiguous button whose URL branch is dead. + const remediation = + action && action.label !== showLogs ? action : undefined; // Lead with the remediation button (e.g. "Install uv") when one is // attached, so the action the user most likely wants comes first. - const actions = action ? [action.label, showLogs] : [showLogs]; + const actions = remediation + ? [remediation.label, showLogs] + : [showLogs]; const picked = await window.showErrorMessage(message, ...actions); if (picked === showLogs) { wiring.log.show(); - } else if (action && picked === action.label) { - await openExternal(action.url); + } else if (remediation && picked === remediation.label) { + await openExternal(remediation.url); } }, showSuccess: async (result) => { From b842e04ce0b948768a055dff74026b29f77c2021 Mon Sep 17 00:00:00 2001 From: "@rugpanov" Date: Thu, 13 Aug 2026 23:48:15 +0200 Subject: [PATCH 3/4] fix(python-setup): contain openExternal failures in showError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *Why* Review flagged that showError is the failure-reporting path and its sole caller (PythonSetupEnvironmentSetup.setup) does not wrap it, so a rejecting `env.openExternal` when the user clicks "Install uv" would reject the whole setup flow — an unhandled rejection in the error path. *What* - pythonSetupDeps.ts: wrap the `openExternal(remediation.url)` call in try/catch; on failure append the error to the log channel instead of letting the popup reject. *Verification* - TDD: new unit test stubs `env.openExternal` to throw and asserts showError resolves and records the failure to the log. - yarn test:unit — 741 passing, 0 failing. - tsc --noEmit clean; yarn test:lint + Prettier clean. Co-authored-by: Isaac --- .../controllers/pythonSetupDeps.test.ts | 31 +++++++++++++++++++ .../controllers/pythonSetupDeps.ts | 13 +++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts index e48e30bc4..fb9b18800 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts @@ -586,6 +586,37 @@ describe("makePythonSetupDeps showError", () => { } }); + it("does not reject when opening the remediation URL fails", async () => { + // The popup is the failure-reporting path; a failed browser launch must + // not turn it into a rejected promise (the caller does not wrap it). + const originalOpen = env.openExternal; + (env as unknown as {openExternal: unknown}).openExternal = async () => { + throw new Error("no browser available"); + }; + const appended: string[] = []; + try { + const deps = makePythonSetupDeps( + makeWiring({ + log: {append: (c) => appended.push(c), show: () => {}}, + }) + ); + reply = "Install uv"; + + // Must resolve, not throw. + await deps.showError("uv missing", "detail", { + label: "Install uv", + url: "https://docs.astral.sh/uv/getting-started/installation/", + }); + + // The failure is recorded to the log channel rather than swallowed + // silently. + expect(appended.join("")).to.contain("no browser available"); + } finally { + (env as unknown as {openExternal: unknown}).openExternal = + originalOpen; + } + }); + it("does not open the URL when the action button is not picked", async () => { const originalOpen = env.openExternal; const opened: string[] = []; diff --git a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts index fe35c674b..3218fc696 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts @@ -242,7 +242,18 @@ export function makePythonSetupDeps( if (picked === showLogs) { wiring.log.show(); } else if (remediation && picked === remediation.label) { - await openExternal(remediation.url); + // showError is the failure-reporting path and its one caller does + // not wrap it, so a failed browser launch must be contained here + // rather than rejecting the whole setup flow. Record it and move on. + try { + await openExternal(remediation.url); + } catch (e) { + wiring.log.append( + `\nFailed to open ${remediation.url}: ${ + (e as Error).message + }\n` + ); + } } }, showSuccess: async (result) => { From 985578a7e065a3939d2de65eb01beac96da3fc1f Mon Sep 17 00:00:00 2001 From: "@rugpanov" Date: Thu, 13 Aug 2026 23:55:55 +0200 Subject: [PATCH 4/4] fix(python-setup): record a failed/false uv-docs browser launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *Why* Iteration-2 review (Codex + Claude + Isaac) noted showError's "Install uv" handler only caught a rejected launch: `env.openExternal` can instead *resolve false* when VS Code cannot open the URI, which was silently ignored despite the comment promising to record it. Two smaller nits: a non-Error throw logged "undefined", and the button-order intent was asserted with `.contain` (order not actually verified). *What* - urlUtils.ts: `openExternal` now forwards VS Code's boolean result (Promise) so callers can observe a false "could not open". - pythonSetupDeps.ts: log both a false result and a thrown error; use `e instanceof Error ? e.message : String(e)` for the thrown case. - pythonSetupDeps.test.ts: add a resolves-false case; tighten the button-order assertion to deep.equal(["Install uv","Show Logs"]). *Verification* - TDD: new false-resolve test (RED against the old catch-only code); ordering assertion tightened. - yarn test:unit — 742 passing, 0 failing. - tsc --noEmit clean; yarn test:lint + Prettier clean. Co-authored-by: Isaac --- .../controllers/pythonSetupDeps.test.ts | 34 +++++++++++++++++-- .../controllers/pythonSetupDeps.ts | 13 ++++--- .../databricks-vscode/src/utils/urlUtils.ts | 6 ++-- 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts index fb9b18800..abc4eeb4d 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.test.ts @@ -541,8 +541,11 @@ describe("makePythonSetupDeps showError", () => { url: "https://docs.astral.sh/uv/getting-started/installation/", }); - expect(shownWith[0].actions).to.contain("Install uv"); - expect(shownWith[0].actions).to.contain("Show Logs"); + // Order matters: the remediation button leads, "Show Logs" follows. + expect(shownWith[0].actions).to.deep.equal([ + "Install uv", + "Show Logs", + ]); expect(opened).to.deep.equal([ "https://docs.astral.sh/uv/getting-started/installation/", ]); @@ -586,6 +589,33 @@ describe("makePythonSetupDeps showError", () => { } }); + it("logs when the browser cannot open the remediation URL (resolves false)", async () => { + // env.openExternal resolves false (rather than rejecting) when VS Code + // cannot open the URI; that ineffective click must still be recorded. + const originalOpen = env.openExternal; + (env as unknown as {openExternal: unknown}).openExternal = async () => + false; + const appended: string[] = []; + try { + const deps = makePythonSetupDeps( + makeWiring({ + log: {append: (c) => appended.push(c), show: () => {}}, + }) + ); + reply = "Install uv"; + + await deps.showError("uv missing", "detail", { + label: "Install uv", + url: "https://docs.astral.sh/uv/getting-started/installation/", + }); + + expect(appended.join("")).to.match(/could not open/i); + } finally { + (env as unknown as {openExternal: unknown}).openExternal = + originalOpen; + } + }); + it("does not reject when opening the remediation URL fails", async () => { // The popup is the failure-reporting path; a failed browser launch must // not turn it into a rejected promise (the caller does not wrap it). diff --git a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts index 3218fc696..2e294ab39 100644 --- a/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts +++ b/packages/databricks-vscode/src/python-setup/controllers/pythonSetupDeps.ts @@ -243,14 +243,19 @@ export function makePythonSetupDeps( wiring.log.show(); } else if (remediation && picked === remediation.label) { // showError is the failure-reporting path and its one caller does - // not wrap it, so a failed browser launch must be contained here - // rather than rejecting the whole setup flow. Record it and move on. + // not wrap it, so neither a rejected launch nor a false "could not + // open" result may escape here — contain both and record them. try { - await openExternal(remediation.url); + const opened = await openExternal(remediation.url); + if (!opened) { + wiring.log.append( + `\nCould not open ${remediation.url} in a browser.\n` + ); + } } catch (e) { wiring.log.append( `\nFailed to open ${remediation.url}: ${ - (e as Error).message + e instanceof Error ? e.message : String(e) }\n` ); } diff --git a/packages/databricks-vscode/src/utils/urlUtils.ts b/packages/databricks-vscode/src/utils/urlUtils.ts index 5bb703229..59494ca99 100644 --- a/packages/databricks-vscode/src/utils/urlUtils.ts +++ b/packages/databricks-vscode/src/utils/urlUtils.ts @@ -3,8 +3,10 @@ import {env, Uri} from "vscode"; export function addHttpsIfNoProtocol(url: string) { return `${url}`.startsWith("http") ? `${url}` : `https://${url}`; } -export async function openExternal(url: string) { - await env.openExternal(Uri.parse(addHttpsIfNoProtocol(url), true)); +export async function openExternal(url: string): Promise { + // Forward VS Code's result: it resolves false when the URI could not be + // opened (rather than rejecting), which some callers need to observe. + return env.openExternal(Uri.parse(addHttpsIfNoProtocol(url), true)); } export class UrlError extends Error {