Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
22 changes: 22 additions & 0 deletions test/e2e/infra/playwrightDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,28 @@ export class PlaywrightDriver {
[await this.getDriverHandle(), id, args] as const
);
}

/**
* Whether `window.driver` is present on the page, i.e. whether the smoke-test driver
* (and with it `executeCommand` and the `registerCommandIfSmokeTestDriver` commands)
* is available.
*
* The driver only registers when the app runs with `--enable-smoke-test-driver`, which
* our Electron and browser harnesses pass themselves. The Workbench lane connects to a
* server that Posit Workbench launched, so there is no such flag and no driver -- tests
* that would otherwise use the driver need a UI-based path there. Note that `isAlive()`
* cannot answer this: `evaluateHandle('window.driver')` resolves to an `undefined`
* handle rather than throwing.
*/
async isDriverAvailable(): Promise<boolean> {
try {
return await this.page.evaluate(() => Reflect.get(window, 'driver') !== undefined);
} catch {
// Page navigating or closed; treat the driver as unavailable rather than throwing
// from what callers use as a capability probe.
return false;
}
}
// --- End Positron ---

private async getDriverHandle(): Promise<playwright.JSHandle<IWindowDriver>> {
Expand Down
68 changes: 67 additions & 1 deletion test/e2e/pages/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,11 +753,19 @@ export class Sessions {
*/
async getMetadata(sessionId?: string): Promise<SessionMetaData> {
return await test.step(`Get metadata for: ${sessionId ?? 'current session'}`, async () => {
// Read the metadata directly from the runtime session service via the
// Prefer reading the metadata directly from the runtime session service via the
// internal `_positron.session.getMetadata` command (registered in
// languageRuntimeActionsForSmokeTests.ts). This avoids selecting the
// session tab and scraping the info popup, which is slower and flaky
// when notification toasts overlay the tab.
//
// Both the command and the `executeCommand` bridge it travels over are gated on
// `--enable-smoke-test-driver`, which the Workbench lane never has: Posit Workbench
// launches the Positron server itself. Scrape the info popup there instead.
if (!await this.code.driver.isDriverAvailable()) {
return await this.getMetadataFromDialog(sessionId);
}

const metadata = await this.code.driver.executeCommand<SessionMetaData | undefined>(
'_positron.session.getMetadata',
sessionId
Expand All @@ -771,6 +779,64 @@ export class Sessions {
});
}

/**
* Helper: Read session metadata by opening the console session info popup.
*
* Fallback for environments without the smoke-test driver -- see `getMetadata()`. Unlike
* the command-based read this drives the UI, so it has to bring the target session tab to
* the foreground first and dismiss the popup afterwards.
*
* @param sessionId the session ID to get metadata for, otherwise will use the current session
* @returns the metadata of the session
*/
private async getMetadataFromDialog(sessionId?: string): Promise<SessionMetaData> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh no! it's all coming back? is there no way to make the command work on workbench? 😭

// Kept structurally identical to the pre-command implementation (including the
// `console.focus()` that getSessionCount() does on the way past), since that is the
// version proven against the Workbench lane.
const isSingleSession = (await this.getSessionCount()) === 1;

if (!isSingleSession && sessionId) {
const targetTab = this.getSessionTab(sessionId);

await expect(async () => {
// Toasts render over the session tab bar, and their auto-dismiss timer
// is held open while the pointer sits on them -- keep the mouse clear.
await this.page.mouse.move(0, 0);

// No force: let Playwright wait until the tab itself receives the
// click, rather than dispatching it into whatever is on top. Inner
// timeouts stay well under the outer budget, otherwise the first
// attempt consumes it and this never retries.
await targetTab.click({ timeout: 5000 });
await expect(targetTab).toHaveClass(/tab-button--active/, { timeout: 2000 });
}, `Select session tab: ${sessionId}`).toPass({ timeout: 30000 });
}

let metadata: SessionMetaData | undefined;

await expect(async () => {
await this.openMetadataDialog(sessionId);
const [name, id, state, path, source] = await Promise.all([
this.metadataDialog.getByTestId('session-name').textContent(),
this.metadataDialog.getByTestId('session-id').textContent(),
this.metadataDialog.getByTestId('session-state').textContent(),
this.metadataDialog.getByTestId('session-path').textContent(),
this.metadataDialog.getByTestId('session-source').textContent(),
]);
metadata = {
name: (name ?? '').trim(),
id: (id ?? '').replace('Session ID: ', ''),
state: (state ?? '').replace('State: ', '') as SessionState,
path: (path ?? '').replace('Path: ', ''),
source: (source ?? '').replace('Source: ', ''),
};
}, 'Extract session metadata').toPass({ intervals: [500], timeout: 10000 });

await this.page.keyboard.press('Escape');

return metadata!;
}

/**
* Helper: Get all available runtimes with their categories
*/
Expand Down
59 changes: 57 additions & 2 deletions test/e2e/pages/workbench/dashboard.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,27 @@
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

import { expect, BrowserContext } from '@playwright/test';
import { expect, BrowserContext, Page } from '@playwright/test';
import { Code } from '../../infra/code.js';
import { QuickInput } from '../quickInput.js';
import { generateTOTP } from '../../utils/totp.js';
import { isOktaLockedOut, otpRetryDelayMs } from '../../utils/otpRetry.js';

/**
* The OAuth flow is complete once the provider redirects back to Workbench.
*
* Matched on host rather than by searching the whole URL for the callback path: Databricks
* carries `redirect_uri=...%2Foauth_redirect_callback` in the query string of its own consent
* screen, so a substring match reports "complete" while still sitting on the provider's page.
*/
const isWorkbenchCallback = (url: URL) => url.host === 'localhost:8787';

/**
* Databricks ends its OAuth flow on an "Authorize as.." consent screen under `/oauth-prompt/`
* (currently `/oauth-prompt/select-group`) before redirecting to the callback.
*/
const isDatabricksAuthorizePrompt = (url: URL) => url.pathname.startsWith('/oauth-prompt/');

export class DashboardPage {
get title() { return this.code.driver.currentPage.getByRole('link', { name: 'Workbench projects' }); }
get launchButton() { return this.code.driver.currentPage.getByRole('button', { name: 'Launch' }); }
Expand Down Expand Up @@ -252,7 +267,13 @@ export class DashboardPage {
await verifyOtpButton.click();

try {
await oauthPage.waitForURL(/oauth_redirect_callback|localhost:8787/, { timeout: 15000 });
// Okta hands the flow back to Databricks, which either redirects straight to the
// callback or first shows its "Authorize as.." consent screen. Reaching either one
// means the OTP was accepted, so stop retrying and finish the flow below.
await oauthPage.waitForURL(
url => isWorkbenchCallback(url) || isDatabricksAuthorizePrompt(url),
{ timeout: 15000 }
);
otpAccepted = true;
break;
} catch {
Expand All @@ -274,6 +295,10 @@ export class DashboardPage {
}
}

if (otpAccepted && !oauthPage.isClosed()) {
await this.confirmDatabricksAuthorizePrompt(oauthPage);
}

try {
if (otpAccepted) {
await oauthPage.waitForTimeout(2000);
Expand All @@ -290,6 +315,36 @@ export class DashboardPage {
this.code.logger.log('Databricks OAuth setup complete');
}

/**
* Confirms the "Authorize as.." consent screen Databricks shows at the end of its OAuth flow.
*
* The screen preselects the group to authorize as (the service account belongs to one), so we
* only have to confirm it. "Continue" is rendered as a link, not a button. Databricks skips the
* screen in some cases (e.g. an existing grant), so a missing prompt is not a failure -- the
* caller's credential-widget assertion is the real verdict.
*
* @param oauthPage The OAuth tab, parked on the consent screen or already past it
*/
private async confirmDatabricksAuthorizePrompt(oauthPage: Page): Promise<void> {
if (!isDatabricksAuthorizePrompt(new URL(oauthPage.url()))) {
this.code.logger.log('No Databricks "Authorize as.." prompt; OAuth flow continued without it');
return;
}

const continueLink = oauthPage.getByRole('link', { name: 'Continue', exact: true });
await expect(continueLink).toBeVisible({ timeout: 15000 });
await continueLink.click();
this.code.logger.log('Confirmed Databricks "Authorize as.." prompt');

try {
await oauthPage.waitForURL(isWorkbenchCallback, { timeout: 15000 });
} catch {
// The tab often closes itself the moment the callback lands, which cancels the wait.
// Defer to the caller's credential-widget check rather than failing here.
this.code.logger.log('Did not observe the OAuth callback after confirming; deferring to widget check');
}
}

/**
* Sets up Snowflake managed credential via OAuth flow
* @param context BrowserContext for handling OAuth in new tab
Expand Down
Loading