diff --git a/test/e2e/infra/playwrightDriver.ts b/test/e2e/infra/playwrightDriver.ts index 41cc694ab566..01afa8ab34cf 100644 --- a/test/e2e/infra/playwrightDriver.ts +++ b/test/e2e/infra/playwrightDriver.ts @@ -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 { + 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> { diff --git a/test/e2e/pages/sessions.ts b/test/e2e/pages/sessions.ts index 61a45d35b63c..b0b2d3564502 100644 --- a/test/e2e/pages/sessions.ts +++ b/test/e2e/pages/sessions.ts @@ -753,11 +753,19 @@ export class Sessions { */ async getMetadata(sessionId?: string): Promise { 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( '_positron.session.getMetadata', sessionId @@ -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 { + // 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 */ diff --git a/test/e2e/pages/workbench/dashboard.page.ts b/test/e2e/pages/workbench/dashboard.page.ts index fda623753fbd..f666b68f46b5 100644 --- a/test/e2e/pages/workbench/dashboard.page.ts +++ b/test/e2e/pages/workbench/dashboard.page.ts @@ -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' }); } @@ -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 { @@ -274,6 +295,10 @@ export class DashboardPage { } } + if (otpAccepted && !oauthPage.isClosed()) { + await this.confirmDatabricksAuthorizePrompt(oauthPage); + } + try { if (otpAccepted) { await oauthPage.waitForTimeout(2000); @@ -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 { + 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