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 @@ -125,6 +125,7 @@ function makeDeps(
adoptInterpreter: async () => {},
saveState: () => {},
notify: async () => {},
showReauthPrompt: async () => {},
showError: async () => {},
showSuccess: async () => {},
reportEnvironment: {
Expand Down Expand Up @@ -572,6 +573,74 @@ describe("PythonSetupEnvironmentSetup.setup", () => {
});
});

it("prompts re-login (no report) when the CLI aborts telling the user to re-authenticate", async () => {
const shownErrors: {
message: string;
action?: PythonSetupErrorAction;
}[] = [];
let reauthPrompts = 0;
const telemetry = makeTelemetryRecorder();
const setup = new PythonSetupEnvironmentSetup(
makeDeps({
// A no-result abort whose stderr carries the reauth signal.
cli: makeCli({
reject: new Error(
"CLI did not return valid JSON: Unexpected end of JSON input\n" +
"Error: the refresh token is invalid. To reauthenticate, " +
"run: databricks auth login --profile dev"
),
}),
recordSetupAttempt: telemetry.recordSetupAttempt,
showReauthPrompt: async () => {
reauthPrompts++;
},
showError: async (message, _detail, action) => {
shownErrors.push({message, action});
},
})
);

await setup.setup();

expect(setup.ready).to.equal(false);
// The re-login prompt replaces the hard error + report action entirely.
expect(reauthPrompts).to.equal(1);
expect(shownErrors).to.have.length(0);
expect(telemetry.results[0]).to.include({
outcome: "not_started",
reportOffered: false,
});
});

it("reports a spawn/parse defect (not re-login) when the abort has no reauth signal", async () => {
const shown: {action?: PythonSetupErrorAction}[] = [];
let reauthPrompts = 0;
const telemetry = makeTelemetryRecorder();
const setup = new PythonSetupEnvironmentSetup(
makeDeps({
// A no-result abort with no auth signal (e.g. offline / crash):
// it must stay on the report path, not be mislabeled as expiry.
cli: makeCli({reject: new Error("spawn databricks ENOENT")}),
recordSetupAttempt: telemetry.recordSetupAttempt,
showReauthPrompt: async () => {
reauthPrompts++;
},
showError: async (_m, _detail, action) => {
shown.push({action});
},
})
);

await setup.setup();

expect(reauthPrompts).to.equal(0);
expect(shown[0].action?.label).to.equal("Report this problem");
expect(telemetry.results[0]).to.include({
outcome: "not_started",
reportOffered: true,
});
});

it("handles a non-Error rejection without throwing on the spawn path", async () => {
const shown: {message: string; actions?: PythonSetupErrorAction[]}[] =
[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
reportRepoForResult,
ReportEnvironment,
} from "../utils/reportSetupIssue";
import {isReauthRequiredError} from "../utils/authErrors";
import {SetupLocalInvocation} from "../utils/setupLocalArgs";
import {
PythonSetupAttempt,
Expand Down Expand Up @@ -138,6 +139,15 @@ export interface PythonSetupSetupDeps {
*/
notify: (message: string) => Promise<void>;

/**
* Prompt the user to re-authenticate when setup-local aborted because the
* profile's session expired (see {@link isReauthRequiredError}). A plain
* warning with a "Login" action that runs the extension's re-auth flow — no
* error styling, no log reveal, and no "Report this problem": an expired
* session is an expected, self-service condition, not a defect to report.
*/
showReauthPrompt: () => Promise<void>;

/**
* Shows the mapped, user-facing copy — not raw CLI text — with a "Show Logs"
* action that reveals the setup output channel. `detail`, when given, is
Expand Down Expand Up @@ -397,6 +407,15 @@ export class PythonSetupEnvironmentSetup implements Disposable {
// non-Error rejection so `.message` is never undefined (the redactor
// would throw on it, swallowing the original failure).
const message = e instanceof Error ? e.message : String(e);
// An expired session is expected, not a defect: route it to a
// re-login prompt, not the hard error + "Report this problem". A
// positive gate — anything unmatched (real defect, network blip)
// falls through to the report path.
if (isReauthRequiredError(message)) {
reportResult({outcome: "not_started", reportOffered: false});
this.present(this.deps.showReauthPrompt());
return;
}
const reportAction = buildExtensionFailureReportAction(reportEnv, {
phase: "spawn",
message,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -957,3 +957,82 @@ describe("makePythonSetupDeps showSuccess", () => {
expect(shown).to.equal(2);
});
});

describe("makePythonSetupDeps showReauthPrompt", () => {
let originalShowWarning: typeof window.showWarningMessage;
let originalExecuteCommand: typeof commands.executeCommand;
let shownWith: {message: string; actions: string[]}[];
let executed: string[];
let reply: string | undefined;

beforeEach(() => {
originalShowWarning = window.showWarningMessage;
originalExecuteCommand = commands.executeCommand;
shownWith = [];
executed = [];
reply = undefined;
(
window as unknown as {showWarningMessage: unknown}
).showWarningMessage = async (
message: string,
...actions: string[]
) => {
shownWith.push({message, actions});
return reply;
};
(commands as unknown as {executeCommand: unknown}).executeCommand =
async (command: string) => {
executed.push(command);
return undefined;
};
});

afterEach(() => {
(
window as unknown as {showWarningMessage: unknown}
).showWarningMessage = originalShowWarning;
(commands as unknown as {executeCommand: unknown}).executeCommand =
originalExecuteCommand;
});

it("shows a warning with a Login action and does not reveal the log", async () => {
let logShown = 0;
const deps = makePythonSetupDeps(
makeWiring({
log: {
append: () => {},
show: () => {
logShown++;
},
},
})
);

await deps.showReauthPrompt();

expect(shownWith).to.have.length(1);
expect(shownWith[0].actions).to.deep.equal(["Login"]);
// An expired session is expected, not a defect: no log channel reveal.
expect(logShown).to.equal(0);
});

it("runs the re-auth command when Login is picked", async () => {
const deps = makePythonSetupDeps(makeWiring());
reply = "Login";

await deps.showReauthPrompt();

expect(executed).to.deep.equal([
"databricks.connection.configureLogin",
]);
});

it("runs nothing when the prompt is dismissed", async () => {
const deps = makePythonSetupDeps(makeWiring());
reply = undefined;

await deps.showReauthPrompt();

expect(executed).to.have.length(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,17 @@ export interface PythonSetupWiringDeps {
telemetry: Telemetry;
}

/** User-facing copy for the expired-session re-login prompt. */
const REAUTH_PROMPT_MESSAGE =
"Your Databricks session has expired. Log in again to set up the Python environment.";

/**
* The extension's re-auth command (opens the login flow for the active profile
* and reconnects). Reused rather than shelling out to `databricks auth login`
* so host, profile, and workspace-id routing stay owned by one place.
*/
const RELOGIN_COMMAND_ID = "databricks.connection.configureLogin";

/**
* Assemble the {@link PythonSetupSetupDeps} for the real extension: the gate and
* compute resolution come from the pure helpers above, the interpreter is
Expand Down Expand Up @@ -247,6 +258,20 @@ export function makePythonSetupDeps(
// revealing the (empty) output channel.
await window.showWarningMessage(message);
},
showReauthPrompt: async () => {
// A warning, not an error, and no log reveal: an expired session is
// expected, not a defect. The "Login" button runs the extension's
// existing re-auth flow for the active profile; the user re-runs
// setup once connected (we deliberately don't auto-retry).
const login = "Login";
const picked = await window.showWarningMessage(
REAUTH_PROMPT_MESSAGE,
login
);
if (picked === login) {
await commands.executeCommand(RELOGIN_COMMAND_ID);
}
},
showError: async (
message: string,
detail?: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
SUCCESS_DEFAULT,
ERROR_NO_TARGET,
} from "../models/fixtures/setupLocalResults";
import {isReauthRequiredError} from "../utils/authErrors";

/**
* A fake child process that emits scripted stdout/stderr then closes. Lets us
Expand Down Expand Up @@ -242,6 +243,30 @@ describe("PythonSetupCliClient", () => {
expect(threw).to.equal(true);
});

it("produces a rejection the reauth matcher recognizes (gateway stderr → reauth gate)", async () => {
// The real shape of an expired-session abort: no JSON on stdout, the
// CLI's reauth guidance on stderr. This links the gateway's
// stderr-appending format to `isReauthRequiredError` (the controller's
// positive gate), so a change to either side that would silently break
// the re-login prompt fails here instead of in production.
const authStderr =
"Error: a new access token could not be retrieved because the " +
"refresh token is invalid. To reauthenticate, run the following " +
"command:\n databricks auth login --profile dev\n";
const client = new PythonSetupCliClient(
() => "/fake/databricks",
() => ({}),
fakeSpawn({stdout: "", stderr: authStderr, code: 1})
);
let message = "";
try {
await client.run(inv, {cwd: "/proj"});
} catch (e) {
message = (e as Error).message;
}
expect(isReauthRequiredError(message)).to.equal(true);
});

it("decodes a multi-byte char split across two stdout chunks", async () => {
// "…" (U+2026) is 3 bytes in UTF-8; split it down the middle so each
// chunk holds a partial code point. Naive per-chunk toString() would
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {expect} from "chai";
import {isReauthRequiredError} from "./authErrors";

/**
* The verbatim CLI abort the extension sees when the active profile's session
* has expired: a parse error (no JSON on stdout) with the CLI's auth diagnostic
* appended from stderr by the setup-local gateway.
*/
const EXPIRED_SESSION_MSG =
"CLI did not return valid JSON: Unexpected end of JSON input\n" +
"Error: A new access token could not be retrieved because the refresh " +
"token is invalid. To reauthenticate, run the following command: " +
"$ databricks auth login --profile dev";

describe("isReauthRequiredError", () => {
it("matches the expired-session CLI abort", () => {
expect(isReauthRequiredError(EXPIRED_SESSION_MSG)).to.be.true;
});

it("matches the hyphenated re-authenticate spelling", () => {
expect(
isReauthRequiredError(
"Error: token expired. To re-authenticate, run: databricks auth login --profile dev"
)
).to.be.true;
});

it("matches when the cause is split across lines (whitespace collapsed)", () => {
expect(
isReauthRequiredError(
"Error: the refresh token\nis invalid.\nRun: databricks auth login"
)
).to.be.true;
});

it("matches the access-token-could-not-be-retrieved cause", () => {
expect(
isReauthRequiredError(
"a new access token could not be retrieved. run databricks auth login to continue"
)
).to.be.true;
});

it("is case-insensitive", () => {
expect(
isReauthRequiredError(
"THE REFRESH TOKEN IS INVALID. RUN DATABRICKS AUTH LOGIN"
)
).to.be.true;
});

// Precision: a false positive would mislabel a real defect as expiry and
// suppress its report, so these must NOT match.
it("does not match a generic parse error with no auth signature", () => {
expect(
isReauthRequiredError(
"CLI did not return valid JSON: Unexpected end of JSON input"
)
).to.be.false;
});

it("does not match a network failure that merely mentions a login host", () => {
expect(
isReauthRequiredError(
"error: failed to reach https://login.example.com: connection refused"
)
).to.be.false;
});

it("does not match a package-index fetch failure", () => {
expect(
isReauthRequiredError(
"error: Failed to fetch: `https://pypi.org/simple/ipykernel/`\n" +
" Caused by: tcp connect error: Connection refused (os error 61)"
)
).to.be.false;
});

it("does not match a reauth cause without the CLI login remediation", () => {
// The remediation command is the precise half of the signal; a cause
// phrase alone (no `databricks auth login`) stays on the report path.
expect(isReauthRequiredError("the refresh token is invalid")).to.be
.false;
});

it("is safe on empty and non-string input", () => {
expect(isReauthRequiredError("")).to.be.false;
expect(isReauthRequiredError(undefined as unknown as string)).to.be
.false;
});
});
Loading
Loading