Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import {
} from "../models/PythonSetupResult";
import {
formatSetupFailureDetail,
getPythonSetupErrorAction,
getPythonSetupErrorMessage,
NO_COMPUTE_TARGET_MESSAGE,
PythonSetupErrorAction,
} from "../utils/errorMessages";
import {SetupLocalInvocation} from "../utils/setupLocalArgs";
import {
Expand Down Expand Up @@ -132,8 +134,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<void>;
showError: (
message: string,
detail?: string,
action?: PythonSetupErrorAction
) => Promise<void>;

showSuccess: (result: PythonSetupResult) => Promise<void>;

Expand Down Expand Up @@ -375,7 +384,8 @@ export class PythonSetupEnvironmentSetup implements Disposable {
this.present(
this.deps.showError(
getPythonSetupErrorMessage(result),
formatSetupFailureDetail(result)
formatSetupFailureDetail(result),
getPythonSetupErrorAction(result)
)
);
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {expect} from "chai";
import {Uri, window} from "vscode";
import {env, Uri, window} from "vscode";
import {
makePythonSetupDeps,
makePythonSetupVisibility,
Expand Down Expand Up @@ -520,6 +520,159 @@ 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/",
});

// 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/",
]);
} finally {
(env as unknown as {openExternal: unknown}).openExternal =
originalOpen;
}
});

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("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).
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[] = [];
(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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -221,9 +227,38 @@ 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);
// `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 = remediation
? [remediation.label, showLogs]
: [showLogs];
const picked = await window.showErrorMessage(message, ...actions);
if (picked === showLogs) {
wiring.log.show();
} else if (remediation && picked === remediation.label) {
// showError is the failure-reporting path and its one caller does
// not wrap it, so neither a rejected launch nor a false "could not
// open" result may escape here — contain both and record them.
try {
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 instanceof Error ? e.message : String(e)
}\n`
);
}
}
},
showSuccess: async (result) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {expect} from "chai";
import {
formatSetupFailureDetail,
getPythonSetupErrorAction,
getPythonSetupErrorMessage,
UV_INSTALL_DOCS_URL,
} from "./errorMessages";
import {
PythonSetupResult,
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading