diff --git a/.github/workflows/e2e-test-release-run-ubuntu.yml b/.github/workflows/e2e-test-release-run-ubuntu.yml index 9fd45607b8e..4edfb893107 100644 --- a/.github/workflows/e2e-test-release-run-ubuntu.yml +++ b/.github/workflows/e2e-test-release-run-ubuntu.yml @@ -5,7 +5,7 @@ on: inputs: e2e_grep: required: false - description: "Grep filter to apply to the e2e tests: @pr, @win, etc." + description: "Grep filter to apply to the e2e tests: @critical, @win, etc." default: "" type: string diff --git a/.github/workflows/positron-merge-to-branch.yml b/.github/workflows/positron-merge-to-branch.yml index 8308cc45f1d..2967113eba1 100644 --- a/.github/workflows/positron-merge-to-branch.yml +++ b/.github/workflows/positron-merge-to-branch.yml @@ -9,14 +9,14 @@ on: inputs: e2e_grep: required: false - description: "Grep filter to apply to the e2e tests: @pr, @win, etc." + description: "Grep filter to apply to the e2e tests: @critical, @win, etc." default: "" type: string workflow_dispatch: inputs: e2e_grep: required: false - description: "Grep filter to apply to the e2e tests: @pr, @win, etc." + description: "Grep filter to apply to the e2e tests: @critical, @win, etc." default: "" type: string diff --git a/.github/workflows/positron-pull-requests.yml b/.github/workflows/positron-pull-requests.yml index e1e4cb3d49c..ee4a57157f2 100644 --- a/.github/workflows/positron-pull-requests.yml +++ b/.github/workflows/positron-pull-requests.yml @@ -11,4 +11,4 @@ jobs: uses: ./.github/workflows/positron-merge-to-branch.yml secrets: inherit with: - e2e_grep: "@pr" + e2e_grep: "@critical" diff --git a/extensions/positron-notebook-controllers/src/commands.ts b/extensions/positron-notebook-controllers/src/commands.ts index 1a00671da86..fc5d4232437 100644 --- a/extensions/positron-notebook-controllers/src/commands.ts +++ b/extensions/positron-notebook-controllers/src/commands.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import { NotebookSessionService } from './notebookSessionService'; +import { getNotebookSession } from './utils'; export function registerCommands(context: vscode.ExtensionContext, notebookSessionService: NotebookSessionService): void { context.subscriptions.push(vscode.commands.registerCommand('positron.restartKernel', async () => { @@ -15,7 +16,7 @@ export function registerCommands(context: vscode.ExtensionContext, notebookSessi } // Get the session for the active notebook. - const session = notebookSessionService.getNotebookSession(notebook.uri); + const session = await getNotebookSession(notebook.uri); if (!session) { throw new Error('No session found for active notebook. This command should only be available when a session is running.'); } diff --git a/extensions/positron-notebook-controllers/src/extension.ts b/extensions/positron-notebook-controllers/src/extension.ts index f97183a883b..13e49c5ec10 100644 --- a/extensions/positron-notebook-controllers/src/extension.ts +++ b/extensions/positron-notebook-controllers/src/extension.ts @@ -10,6 +10,7 @@ import { NotebookSessionService } from './notebookSessionService'; import { registerCommands } from './commands'; import { JUPYTER_NOTEBOOK_TYPE } from './constants'; import { registerExecutionInfoStatusBar } from './statusBar'; +import { getNotebookSession } from './utils'; export const log = vscode.window.createOutputChannel('Positron Notebook Controllers', { log: true }); @@ -20,9 +21,7 @@ export async function activate(context: vscode.ExtensionContext): Promise // Shutdown any running sessions when a notebook is closed. context.subscriptions.push(vscode.workspace.onDidCloseNotebookDocument(async (notebook) => { log.debug(`Notebook closed: ${notebook.uri.path}`); - if (notebookSessionService.hasStartingOrRunningNotebookSession(notebook.uri)) { - await notebookSessionService.shutdownRuntimeSession(notebook.uri); - } + await notebookSessionService.shutdownRuntimeSession(notebook.uri); })); const manager = new NotebookControllerManager(notebookSessionService); @@ -53,15 +52,15 @@ export async function activate(context: vscode.ExtensionContext): Promise } // Set the hasRunningNotebookSession context when the active notebook editor changes. - context.subscriptions.push(vscode.window.onDidChangeActiveNotebookEditor((editor) => { - const value = notebookSessionService.hasRunningNotebookSession(editor?.notebook.uri); - setHasRunningNotebookSessionContext(value); + context.subscriptions.push(vscode.window.onDidChangeActiveNotebookEditor(async (editor) => { + const session = editor && await getNotebookSession(editor.notebook.uri); + setHasRunningNotebookSessionContext(Boolean(session)); })); // Set the hasRunningNotebookSession context when a session is started/shutdown for the active notebook. context.subscriptions.push(notebookSessionService.onDidChangeNotebookSession((e) => { - if (e.notebookUri === vscode.window.activeNotebookEditor?.notebook.uri) { - setHasRunningNotebookSessionContext(!!e.session); + if (e.notebookUri.toString() === vscode.window.activeNotebookEditor?.notebook.uri.toString()) { + setHasRunningNotebookSessionContext(Boolean(e.session)); } })); diff --git a/extensions/positron-notebook-controllers/src/notebookController.ts b/extensions/positron-notebook-controllers/src/notebookController.ts index 8d027e76751..6e9333ca164 100644 --- a/extensions/positron-notebook-controllers/src/notebookController.ts +++ b/extensions/positron-notebook-controllers/src/notebookController.ts @@ -8,6 +8,7 @@ import { NotebookSessionService } from './notebookSessionService'; import { JUPYTER_NOTEBOOK_TYPE } from './constants'; import { log } from './extension'; import { ResourceMap } from './map'; +import { getNotebookSession } from './utils'; /** The type of a Jupyter notebook cell output. */ enum NotebookCellOutputType { @@ -121,9 +122,7 @@ export class NotebookController implements vscode.Disposable { private async selectRuntimeSession(notebook: vscode.NotebookDocument): Promise { // If there's an existing session from another runtime, shut it down. - if (this._notebookSessionService.hasStartingOrRunningNotebookSession(notebook.uri)) { - await this._notebookSessionService.shutdownRuntimeSession(notebook.uri); - } + await this._notebookSessionService.shutdownRuntimeSession(notebook.uri); // Start the new session. await this.startRuntimeSession(notebook); @@ -166,7 +165,7 @@ export class NotebookController implements vscode.Disposable { */ private async interruptRuntimeSession(notebook: vscode.NotebookDocument): Promise { // If the notebook has a session, interrupt it. - const session = this._notebookSessionService.getNotebookSession(notebook.uri); + const session = await getNotebookSession(notebook.uri); if (session) { await session.interrupt(); return; @@ -207,7 +206,7 @@ export class NotebookController implements vscode.Disposable { try { await Promise.all(cells.map(cell => this.queueCellExecution(cell, notebook, tokenSource.token))); } catch (err) { - log.debug(`Error executing cells: ${err}`); + log.debug(`Error executing cells: ${err.stack ?? err}`); } finally { // Clean up the cancellation token source for this execution. if (this._executionTokenSourceByNotebookUri.get(notebook.uri) === tokenSource) { @@ -260,11 +259,14 @@ export class NotebookController implements vscode.Disposable { return; } + // If a session is shutting down for this notebook, wait for it to finish. + await this._notebookSessionService.waitForNotebookSessionToShutdown(notebook.uri); + // If a session is restarting for this notebook, wait for it to finish. await this._notebookSessionService.waitForNotebookSessionToRestart(notebook.uri); // Get the notebook's session. - let session = this._notebookSessionService.getNotebookSession(notebook.uri); + let session = await getNotebookSession(notebook.uri, this._runtimeMetadata.runtimeId); // No session has been started for this notebook, start one. if (!session) { diff --git a/extensions/positron-notebook-controllers/src/notebookSessionService.ts b/extensions/positron-notebook-controllers/src/notebookSessionService.ts index 9d298275654..112a9ef20e1 100644 --- a/extensions/positron-notebook-controllers/src/notebookSessionService.ts +++ b/extensions/positron-notebook-controllers/src/notebookSessionService.ts @@ -8,6 +8,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { log } from './extension'; import { ResourceMap } from './map'; +import { getNotebookSession } from './utils'; export interface INotebookSessionDidChangeEvent { /** The URI of the notebook corresponding to the session. */ @@ -47,9 +48,6 @@ export class NotebookSessionService implements vscode.Disposable { */ private readonly _restartingSessionsByNotebookUri = new ResourceMap>(); - /** A map of the currently active notebook sessions, keyed by notebook URI. */ - private readonly _notebookSessionsByNotebookUri = new ResourceMap(); - /** The event emitter for the onDidChangeNotebookSession event. */ private readonly _onDidChangeNotebookSession = this._register(new vscode.EventEmitter); @@ -62,28 +60,13 @@ export class NotebookSessionService implements vscode.Disposable { } /** - * Checks for a starting or running notebook for the given notebook URI. + * Wait for a notebook session to complete a shutdown sequence. * - * @param notebookUri The notebook URI to check for. - * @returns True if a starting or running notebook session exists for the given notebook URI. - */ - hasStartingOrRunningNotebookSession(notebookUri: vscode.Uri): boolean { - return this._startingSessionsByNotebookUri.has(notebookUri) || - this._restartingSessionsByNotebookUri.has(notebookUri) || - this._notebookSessionsByNotebookUri.has(notebookUri); - } - - /** - * Checks for a running notebook for the given notebook URI. - * - * @param notebookUri The notebook URI to check for. - * @returns True if a running notebook session exists for the given notebook URI. + * @param notebookUri The notebook URI to wait for. + * @returns A promise that resolves when the session has completed the shutdown sequence. */ - hasRunningNotebookSession(notebookUri: vscode.Uri | undefined): boolean { - if (!notebookUri) { - return false; - } - return this._notebookSessionsByNotebookUri.has(notebookUri); + async waitForNotebookSessionToShutdown(notebookUri: vscode.Uri): Promise { + await this._shuttingDownSessionsByNotebookUri.get(notebookUri); } /** @@ -96,31 +79,6 @@ export class NotebookSessionService implements vscode.Disposable { await this._restartingSessionsByNotebookUri.get(notebookUri); } - /** - * Get the running notebook session for the given notebook URI, if one exists. - * - * @param notebookUri The notebook URI of the session to retrieve. - * @returns The running notebook session for the given notebook URI, if one exists. - */ - getNotebookSession(notebookUri: vscode.Uri): positron.LanguageRuntimeSession | undefined { - return this._notebookSessionsByNotebookUri.get(notebookUri); - } - - /** - * Set a notebook session for a notebook URI. - * - * @param notebookUri The notebook URI of the session to set. - * @param session The session to set for the notebook URI, or undefined to delete the session. - */ - setNotebookSession(notebookUri: vscode.Uri, session: positron.LanguageRuntimeSession | undefined): void { - if (session) { - this._notebookSessionsByNotebookUri.set(notebookUri, session); - } else { - this._notebookSessionsByNotebookUri.delete(notebookUri); - } - this._onDidChangeNotebookSession.fire({ notebookUri, session }); - } - /** * Start a new runtime session for a notebook. * @@ -141,7 +99,7 @@ export class NotebookSessionService implements vscode.Disposable { try { const session = await this.doStartRuntimeSession(notebookUri, runtimeId); this._startingSessionsByNotebookUri.delete(notebookUri); - this.setNotebookSession(notebookUri, session); + this._onDidChangeNotebookSession.fire({ notebookUri, session }); log.info(`Session ${session.metadata.sessionId} is started`); return session; } catch (err) { @@ -167,27 +125,7 @@ export class NotebookSessionService implements vscode.Disposable { } } - // If there's already a session for this runtime e.g. one restored after a window reload, use it. - try { - const session = await positron.runtime.getNotebookSession(notebookUri); - if (session) { - // TODO: If it isn't running, log an error and start a new one. - // TODO: If it doesn't match the runtime ID, log an error, shut it down, and start a new one. - log.info( - `Restored session for language runtime ${session.metadata.sessionId} ` - + `(language: ${session.runtimeMetadata.languageName}, name: ${session.runtimeMetadata.runtimeName}, ` - + `version: ${session.runtimeMetadata.runtimeVersion}, notebook: ${notebookUri.path})` - ); - return session; - } - } catch (err) { - log.error( - `Getting existing session for notebook ${notebookUri.path}' failed. Reason: ${err}` - ); - throw err; - } - - // If we couldn't restore a session, start a new one. + // Start the session. try { const session = await positron.runtime.startLanguageRuntime( runtimeId, @@ -223,7 +161,7 @@ export class NotebookSessionService implements vscode.Disposable { try { await this.doShutdownRuntimeSession(notebookUri); this._shuttingDownSessionsByNotebookUri.delete(notebookUri); - this.setNotebookSession(notebookUri, undefined); + this._onDidChangeNotebookSession.fire({ notebookUri, session: undefined }); } catch (err) { this._startingSessionsByNotebookUri.delete(notebookUri); throw err; @@ -277,7 +215,7 @@ export class NotebookSessionService implements vscode.Disposable { private async getExistingOrPendingSession(notebookUri: vscode.Uri): Promise { // Check for an active session first. - const activeSession = this._notebookSessionsByNotebookUri.get(notebookUri); + const activeSession = await getNotebookSession(notebookUri); if (activeSession) { return activeSession; } @@ -317,7 +255,7 @@ export class NotebookSessionService implements vscode.Disposable { try { const session = await this.doRestartRuntimeSession(notebookUri); this._restartingSessionsByNotebookUri.delete(notebookUri); - this.setNotebookSession(notebookUri, session); + this._onDidChangeNotebookSession.fire({ notebookUri, session }); log.info(`Session ${session.metadata.sessionId} is restarted`); return session; } catch (err) { @@ -333,14 +271,14 @@ export class NotebookSessionService implements vscode.Disposable { async doRestartRuntimeSession(notebookUri: vscode.Uri): Promise { // Get the notebook's session. - const session = this._notebookSessionsByNotebookUri.get(notebookUri); + const session = await getNotebookSession(notebookUri); if (!session) { throw new Error(`Tried to restart runtime for notebook without a running runtime: ${notebookUri.path}`); } // Remove the session from the map of active notebooks in case it's accessed while we're // restarting. - this.setNotebookSession(notebookUri, undefined); + this._onDidChangeNotebookSession.fire({ notebookUri, session: undefined }); // If the notebook's session is still shutting down, wait for it to finish. const shuttingDownSessionPromise = this._shuttingDownSessionsByNotebookUri.get(notebookUri); diff --git a/extensions/positron-notebook-controllers/src/test/notebookController.test.ts b/extensions/positron-notebook-controllers/src/test/notebookController.test.ts index bc8110f107b..41e071cc935 100644 --- a/extensions/positron-notebook-controllers/src/test/notebookController.test.ts +++ b/extensions/positron-notebook-controllers/src/test/notebookController.test.ts @@ -28,6 +28,7 @@ suite('NotebookController', () => { let notebook: vscode.NotebookDocument; let cells: vscode.NotebookCell[]; let session: TestLanguageRuntimeSession; + let getNotebookSessionStub: sinon.SinonStub; let executions: TestNotebookCellExecution[]; let onDidCreateNotebookCellExecution: vscode.EventEmitter; @@ -79,9 +80,10 @@ suite('NotebookController', () => { } as vscode.NotebookCell]; // Create a test session. - session = new TestLanguageRuntimeSession(); + session = new TestLanguageRuntimeSession(runtime); disposables.push(session); - notebookSessionService.getNotebookSession.withArgs(notebook.uri).returns(session as any); + getNotebookSessionStub = sinon.stub(positron.runtime, 'getNotebookSession') + .withArgs(notebook.uri).resolves(session as any); // Stub the notebook controller to return a test cell execution. executions = []; @@ -244,13 +246,19 @@ suite('NotebookController', () => { const executionEndedPromise = executeNotebook([0]); await executionStartedPromise; + const sessionInterruptSpy = sinon.spy(session, 'interrupt'); + // Simulate the session exiting. - notebookSessionService.getNotebookSession.withArgs(notebook.uri).returns(undefined); + getNotebookSessionStub.withArgs(notebook.uri).resolves(undefined); - // Interrupt and wait for the execution to end. + // Interrupt and wait for the execution to end (it should actually end!). await interruptNotebook(); await executionEndedPromise; + // session.interrupt() should not be called. + sinon.assert.notCalled(sessionInterruptSpy); + + // The execution should still end unsuccessfully. assert.equal(executions.length, 1); executions[0].assertDidEndUnsuccessfully(); }); diff --git a/extensions/positron-notebook-controllers/src/test/testLanguageRuntimeSession.ts b/extensions/positron-notebook-controllers/src/test/testLanguageRuntimeSession.ts index 4633b0678df..d473a2aa249 100644 --- a/extensions/positron-notebook-controllers/src/test/testLanguageRuntimeSession.ts +++ b/extensions/positron-notebook-controllers/src/test/testLanguageRuntimeSession.ts @@ -19,6 +19,10 @@ export class TestLanguageRuntimeSession implements Partial setTimeout(resolve, ms)); } @@ -13,3 +17,29 @@ export function formatCount(count: number, unit: string): string { } return `${count} ${unit}s`; } + +/** + * Get the language runtime session for a notebook. + * + * @param notebookUri The URI of the notebook. + * @param runtimeId Optional runtime ID to filter the session by. + * @returns Promise that resolves with the language runtime session, or `undefined` if no session is found. + */ +export async function getNotebookSession( + notebookUri: vscode.Uri, runtimeId?: string, +): Promise { + // Get the session for the notebook. + const session = await positron.runtime.getNotebookSession(notebookUri); + if (!session) { + return undefined; + } + + // Ensure that the session is for the requested runtime. + if (runtimeId && session.runtimeMetadata.runtimeId !== runtimeId) { + log.warn(`Expected session for notebook ${notebookUri} to be for runtime ${runtimeId}, ` + + `but it is for runtime ${session.runtimeMetadata.runtimeId}`); + return undefined; + } + + return session; +} diff --git a/extensions/positron-python/.github/actions/build-vsix/action.yml b/extensions/positron-python/.github/actions/build-vsix/action.yml index fc3233b06ef..929ecb31a6d 100644 --- a/extensions/positron-python/.github/actions/build-vsix/action.yml +++ b/extensions/positron-python/.github/actions/build-vsix/action.yml @@ -87,7 +87,7 @@ runs: shell: bash - name: Upload VSIX - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ${{ inputs.artifact_name }} path: ${{ inputs.vsix_name }} diff --git a/extensions/positron-python/.github/actions/smoke-tests/action.yml b/extensions/positron-python/.github/actions/smoke-tests/action.yml index d4ac73b1a80..81cb7d2bc50 100644 --- a/extensions/positron-python/.github/actions/smoke-tests/action.yml +++ b/extensions/positron-python/.github/actions/smoke-tests/action.yml @@ -43,7 +43,7 @@ runs: # Bits from the VSIX are reused by smokeTest.ts to speed things up. - name: Download VSIX - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: ${{ inputs.artifact_name }} diff --git a/extensions/positron-r/src/provider.ts b/extensions/positron-r/src/provider.ts index ca5c2792d8f..0c0d12edc0c 100644 --- a/extensions/positron-r/src/provider.ts +++ b/extensions/positron-r/src/provider.ts @@ -12,7 +12,7 @@ import * as which from 'which'; import * as positron from 'positron'; import * as crypto from 'crypto'; -import { RInstallation, RMetadataExtra, getRHomePath } from './r-installation'; +import { RInstallation, RMetadataExtra, getRHomePath, ReasonDiscovered, friendlyReason } from './r-installation'; import { LOGGER } from './extension'; import { EXTENSION_ROOT_DIR, MINIMUM_R_VERSION } from './constants'; @@ -25,123 +25,102 @@ export const R_DOCUMENT_SELECTORS = [ { language: 'r', pattern: '**/*.{rprofile,Rprofile}' }, ]; -/** - * Enum represents the source from which an R binary was discovered. - */ -enum BinarySource { - /* eslint-disable-next-line @typescript-eslint/naming-convention */ - HQ = 'HQ', - adHoc = 'ad hoc location', - registry = 'Windows registry', - /* eslint-disable-next-line @typescript-eslint/naming-convention */ - PATH = 'PATH', - user = 'user-specified directory' +export interface RBinary { + path: string; + reasons: ReasonDiscovered[] } /** - * Discovers R language runtimes for Positron; implements - * positron.LanguageRuntimeDiscoverer. + * Discovers R language runtimes for Positron; implements positron.LanguageRuntimeDiscoverer. * * @param context The extension context. */ export async function* rRuntimeDiscoverer(): AsyncGenerator { - let rInstallations: Array = []; - const binaries = new Map(); + const binaries = new Map(); - // look for R executables in the well-known place(s) for R installations on this OS - const systemHqBinaries = discoverHQBinaries(rHeadquarters()); - for (const b of systemHqBinaries) { - binaries.set(b, BinarySource.HQ); + function updateBinaries(binary: RBinary | RBinary[] | undefined): void { + if (!binary) { + return; + } + const input = Array.isArray(binary) ? binary : [binary]; + for (const binary of input) { + if (binaries.has(binary.path)) { + const reasons = new Set(binaries.get(binary.path)!.reasons.concat(binary.reasons)); + binaries.get(binary.path)!.reasons = Array.from(reasons); + } else { + binaries.set(binary.path, binary); + } + } } - // consult user-specified, HQ-like directories - const userHqBinaries = discoverHQBinaries(userRHeadquarters()); - for (const b of userHqBinaries) { - binaries.set(b, BinarySource.user); - } + // Find the current R binary(ies) for various definitions of "current". + // The first item here will eventually be marked as The Current R Binary. + const currentBinaries = await currentRBinaryCandidates(); + updateBinaries(currentBinaries); + + // Now we scour the system for all R binaries we can find. + const systemHqBinaries = discoverHQBinaries(rHeadquarters()); + updateBinaries(systemHqBinaries); + + const registryBinaries = await discoverRegistryBinaries(); + updateBinaries(registryBinaries); - // other conventional places we might find an R binary (or a symlink to one) - const possibleBinaries = [ + const moreBinaries = discoverAdHocBinaries([ '/usr/bin/R', '/usr/local/bin/R', '/opt/local/bin/R', '/opt/homebrew/bin/R' - ]; - const moreBinaries = possibleBinaries - .filter(b => fs.existsSync(b)) - .map(b => fs.realpathSync(b)); - for (const b of moreBinaries) { - if (!binaries.has(b)) { - binaries.set(b, BinarySource.adHoc); - } - } - - // same as above but user-specified, ad hoc binaries - const userPossibleBinaries = userRBinaries(); - const userMoreBinaries = userPossibleBinaries - .filter(b => fs.existsSync(b)) - .map(b => fs.realpathSync(b)); - for (const b of userMoreBinaries) { - if (!binaries.has(b)) { - binaries.set(b, BinarySource.user); - } - } + ]); + updateBinaries(moreBinaries); - const registryBinaries = await discoverRegistryBinaries(); - for (const b of registryBinaries) { - if (!binaries.has(b)) { - binaries.set(b, BinarySource.registry); - } - } + // Optional, user-specified root directories or binaries + const userHqBinaries = discoverHQBinaries(userRHeadquarters()); + updateBinaries(userHqBinaries); - const pathBinary = await findRBinaryFromPATH(); - if (pathBinary && !binaries.has(pathBinary)) { - binaries.set(pathBinary, BinarySource.PATH); - } + const userMoreBinaries = discoverAdHocBinaries(userRBinaries()); + updateBinaries(userMoreBinaries); - // make sure we include the "current" version of R, for some definition of "current" - // we've probably already discovered it, but we still want to single it out, so that we mark - // that particular R installation as the current one - const curBin = await findCurrentRBinary(); - if (curBin) { - rInstallations.push(new RInstallation(curBin, true)); - binaries.delete(curBin); + // (Try to) promote each RBinary to a proper RInstallation + let rInstallations: Array = []; + if (currentBinaries.length > 0) { + const currentBinary = currentBinaries[0]; + rInstallations.push(new RInstallation(currentBinary.path, true, currentBinary.reasons)); + binaries.delete(currentBinary.path); } - binaries.forEach((source, bin) => { - rInstallations.push(new RInstallation(bin)); + binaries.forEach(rbin => { + rInstallations.push(new RInstallation(rbin.path, false, rbin.reasons)); }); - // TODO: possible location to tell the user why certain R installations are being omitted from - // the interpreter drop-down and, in some cases, offer to help fix the situation: - // * version < minimum R version supported by positron-r - // * (macOS only) version is not orthogonal and is not the current version of R - // * invalid R installation + const rejectedRInstallations: Array = []; rInstallations = rInstallations .filter(r => { - if (!r.valid) { - LOGGER.info(`Filtering out ${r.binpath}: invalid R installation.`); - return false; - } - return true; - }) - .filter(r => { - if (!(r.current || r.orthogonal)) { - LOGGER.info(`Filtering out ${r.binpath}: not current and also not orthogonal.`); - return false; - } - return true; - }) - .filter(r => { - if (!r.supported) { - LOGGER.info(`Filtering out ${r.binpath}: version is < ${MINIMUM_R_VERSION}`); + if (!r.usable) { + LOGGER.info(`Filtering out ${r.binpath}, reason: ${friendlyReason(r.reasonRejected)}.`); + rejectedRInstallations.push(r); return false; } return true; }); - // FIXME? should I explicitly check that there is <= 1 R installation - // marked as 'current'? + if (rejectedRInstallations.length > 0) { + if (rInstallations.length === 0) { + LOGGER.warn(`All discovered R installations are unusable by Positron.`); + LOGGER.warn('Learn more about R discovery at https://positron.posit.co/r-installations'); + const showLog = await positron.window.showSimpleModalDialogPrompt( + vscode.l10n.t('No usable R installations'), + vscode.l10n.t('All discovered R installations are unusable by Positron. Learn more about R discovery at
https://positron.posit.co/r-installations'), + vscode.l10n.t('View logs'), + vscode.l10n.t('Dismiss') + ); + if (showLog) { + LOGGER.show(); + } + } else { + LOGGER.warn(`Some discovered R installations are unusable by Positron.`); + LOGGER.warn('Learn more about R discovery at https://positron.posit.co/r-installations'); + } + } rInstallations.sort((a, b) => { if (a.current || b.current) { @@ -233,7 +212,8 @@ export async function makeMetadata( const extraRuntimeData: RMetadataExtra = { homepath: rInst.homepath, binpath: rInst.binpath, - current: rInst.current + current: rInst.current, + reasonDiscovered: rInst.reasonDiscovered, }; const metadata: positron.LanguageRuntimeMetadata = { @@ -258,198 +238,101 @@ export async function makeMetadata( return metadata; } -// directory(ies) where this OS is known to keep its R installations -function rHeadquarters(): string[] { - switch (process.platform) { - case 'darwin': - return [path.join('/Library', 'Frameworks', 'R.framework', 'Versions')]; - case 'linux': - return [path.join('/opt', 'R')]; - case 'win32': { - const paths = [ - path.join(process.env['ProgramW6432'] || 'C:\\Program Files', 'R') - ]; - if (process.env['LOCALAPPDATA']) { - paths.push(path.join(process.env['LOCALAPPDATA'], 'Programs', 'R')); - } - return [...new Set(paths)]; - } - default: - throw new Error('Unsupported platform'); - } -} +// functions relating to the current R binary +let cachedRBinaryCurrent: RBinary | undefined; -// directory(ies) where this user keeps R installations -function userRHeadquarters(): string[] { - const config = vscode.workspace.getConfiguration('positron.r'); - const userHqDirs = config.get('customRootFolders'); - if (userHqDirs && userHqDirs.length > 0) { - const formattedPaths = JSON.stringify(userHqDirs, null, 2); - LOGGER.info(`User-specified directories to scan for R installations:\n${formattedPaths}`); - return userHqDirs; - } else { - return []; +export async function currentRBinary(): Promise { + if (cachedRBinaryCurrent !== undefined) { + return cachedRBinaryCurrent; } -} -// ad hoc binaries this user wants Positron to know about -function userRBinaries(): string[] { - const config = vscode.workspace.getConfiguration('positron.r'); - const userBinaries = config.get('customBinaries'); - if (userBinaries && userBinaries.length > 0) { - const formattedPaths = JSON.stringify(userBinaries, null, 2); - LOGGER.info(`User-specified R binaries:\n${formattedPaths}`); - return userBinaries; + const candidates = await currentRBinaryCandidates(); + if (candidates.length === 0) { + return undefined; } else { - return []; + cachedRBinaryCurrent = candidates[0]; + return cachedRBinaryCurrent; } } -function firstExisting(base: string, fragments: string[]): string { - const potentialPaths = fragments.map(f => path.join(base, f)); - const existingPath = potentialPaths.find(p => fs.existsSync(p)); - return existingPath || ''; -} +async function currentRBinaryCandidates(): Promise { + const candidates: RBinary[] = []; + let candidate: RBinary | undefined; -function discoverHQBinaries(hqDirs: string[]): string[] { - const existingHqDirs = hqDirs.filter(dir => fs.existsSync(dir)); - if (existingHqDirs.length === 0) { - return []; + if (os.platform() === 'win32') { + candidate = await currentRBinaryFromRegistry(); + if (candidate) { + candidates.push(candidate); + } } - const versionDirs = existingHqDirs - .map(hqDir => fs.readdirSync(hqDir).map(file => path.join(hqDir, file))) - // Windows: rig creates 'bin/', which is a directory of .bat files (at least, for now) - // https://github.com/r-lib/rig/issues/189 - .map(listing => listing.filter(path => !path.endsWith('bin'))) - // macOS: 'Current' (uppercase 'C'), if it exists, is a symlink to an actual version - // linux: 'current' (lowercase 'c'), if it exists, is a symlink to an actual version - .map(listing => listing.filter(path => !path.toLowerCase().endsWith('current'))); - - // On Windows: - // In the case that both (1) and (2) exist we prefer (1). - // (1) C:\Program Files\R\R-4.3.2\bin\x64\R.exe - // (2) C:\Program Files\R\R-4.3.2\bin\R.exe - // Because we require R >= 4.2, we don't need to consider bin\i386\R.exe. - const binaries = versionDirs - .map(vd => vd.map(x => firstExisting(x, binFragments()))) - .flat() - // macOS: By default, the CRAN installer deletes previous R installations, but sometimes - // it doesn't do a thorough job of it and a nearly-empty version directory lingers on. - .filter(b => fs.existsSync(b)); - return binaries; -} - -function binFragments(): string[] { - switch (process.platform) { - case 'darwin': - return [path.join('Resources', 'bin', 'R')]; - case 'linux': - return [path.join('bin', 'R')]; - case 'win32': - return [ - path.join('bin', 'x64', 'R.exe'), - path.join('bin', 'R.exe') - ]; - default: - throw new Error('Unsupported platform'); + candidate = await currentRBinaryFromPATH(); + if (candidate) { + candidates.push(candidate); } -} - -/** - * Generates all possible R versions that we might find recorded in the Windows registry. - * Sort of. - * Only considers the major version of Positron's current minimum R version and that major - * version plus one. - * Naively tacks " Pre-release" onto each version numbers, because that's how r-devel shows up. -*/ -function generateVersions(): string[] { - const minimumSupportedVersion = semver.coerce(MINIMUM_R_VERSION)!; - const major = minimumSupportedVersion.major; - const minor = minimumSupportedVersion.minor; - const patch = minimumSupportedVersion.patch; - const versions: string[] = []; - for (let x = major; x <= major + 1; x++) { - for (let y = (x === major ? minor : 0); y <= 9; y++) { - for (let z = (x === major && y === minor ? patch : 0); z <= 9; z++) { - versions.push(`${x}.${y}.${z}`); - versions.push(`${x}.${y}.${z} Pre-release`); - } + if (os.platform() !== 'win32') { + candidate = currentRBinaryFromHq(rHeadquarters()); + if (candidate) { + candidates.push(candidate); } } - return versions; + return candidates; } -async function discoverRegistryBinaries(): Promise { +let cachedRBinaryFromRegistry: RBinary | undefined; + +async function currentRBinaryFromRegistry(): Promise { if (os.platform() !== 'win32') { LOGGER.info('Skipping registry check on non-Windows platform'); - return []; + return undefined; + } + + if (cachedRBinaryFromRegistry !== undefined) { + return cachedRBinaryFromRegistry; } // eslint-disable-next-line @typescript-eslint/naming-convention const Registry = await import('@vscode/windows-registry'); const hives: any[] = ['HKEY_CURRENT_USER', 'HKEY_LOCAL_MACHINE']; - // R's install path is written to a WOW (Windows on Windows) node when e.g. an x86 build of - // R is installed on an ARM version of Windows. const wows = ['', 'WOW6432Node']; - // The @vscode/windows-registry module is so minimalistic that it can't list the registry. - // Therefore we explicitly generate the R versions that might be there and check for each one. - const versions = generateVersions(); - - const discoveredKeys: string[] = []; + let installPath = undefined; for (const hive of hives) { for (const wow of wows) { - for (const version of versions) { - const R64_KEY: string = `SOFTWARE\\${wow ? wow + '\\' : ''}R-core\\R64\\${version}`; - try { - const key = Registry.GetStringRegKey(hive, R64_KEY, 'InstallPath'); - if (key) { - LOGGER.info(`Registry key ${hive}\\${R64_KEY}\\InstallPath reports an R installation at ${key}`); - discoveredKeys.push(key); - } - } catch { } - } + const R64_KEY: string = `SOFTWARE\\${wow ? wow + '\\' : ''}R-core\\R64`; + try { + const key = Registry.GetStringRegKey(hive, R64_KEY, 'InstallPath'); + if (key) { + installPath = key; + LOGGER.info(`Registry key ${hive}\\${R64_KEY}\\InstallPath reports the current R installation is at ${key}`); + break; + } + } catch { } } } - const binPaths = discoveredKeys - .map(installPath => firstExisting(installPath, binFragments())) - .filter(binPath => binPath !== undefined); - - return binPaths; -} - -let cachedRBinary: string | undefined; - -export async function findCurrentRBinary(): Promise { - if (cachedRBinary !== undefined) { - return cachedRBinary; + if (installPath === undefined) { + LOGGER.info('Cannot determine current version of R from the registry.'); + return undefined; } - if (os.platform() === 'win32') { - const registryBinary = await findCurrentRBinaryFromRegistry(); - if (registryBinary) { - cachedRBinary = registryBinary; - return registryBinary; - } + const binPath = firstExisting(installPath, binFragments()); + if (!binPath) { + return undefined; } - // TODO: for macOS, this should arguably be whatever - // /Library/Frameworks/R.framework/Versions/Current/ resolves to - // that would remove overlap between `findCurrentBinary()` and `findRBinaryFromPATH()` - - cachedRBinary = await findRBinaryFromPATH(); - return cachedRBinary; + LOGGER.info(`Identified the current R binary: ${binPath}`); + cachedRBinaryFromRegistry = { path: binPath, reasons: [ReasonDiscovered.registry] }; + return cachedRBinaryFromRegistry; } -let cachedRBinaryFromPATH: string | undefined; +let cachedRBinaryFromPATH: RBinary | undefined; -async function findRBinaryFromPATH(): Promise { +async function currentRBinaryFromPATH(): Promise { if (cachedRBinaryFromPATH !== undefined) { return cachedRBinaryFromPATH; } @@ -458,9 +341,9 @@ async function findRBinaryFromPATH(): Promise { if (whichR) { LOGGER.info(`Possibly found R on PATH: ${whichR}.`); if (os.platform() === 'win32') { - cachedRBinaryFromPATH = await findRBinaryFromPATHWindows(whichR); + cachedRBinaryFromPATH = await currentRBinaryFromPATHWindows(whichR); } else { - cachedRBinaryFromPATH = await findRBinaryFromPATHNotWindows(whichR); + cachedRBinaryFromPATH = await currentRBinaryFromPATHNotWindows(whichR); } } else { cachedRBinaryFromPATH = undefined; @@ -469,7 +352,7 @@ async function findRBinaryFromPATH(): Promise { return cachedRBinaryFromPATH; } -export async function findRBinaryFromPATHWindows(whichR: string): Promise { +export async function currentRBinaryFromPATHWindows(whichR: string): Promise { // The CRAN Windows installer does NOT put R on the PATH. // If we are here, it is because the user has arranged it so. const ext = path.extname(whichR).toLowerCase(); @@ -496,61 +379,234 @@ export async function findRBinaryFromPATHWindows(whichR: string): Promise { +async function currentRBinaryFromPATHNotWindows(whichR: string): Promise { const whichRCanonical = fs.realpathSync(whichR); LOGGER.info(`Resolved R binary at ${whichRCanonical}`); - return whichRCanonical; + return { path: whichRCanonical, reasons: [ReasonDiscovered.PATH] }; +} + +function currentRBinaryFromHq(hqDirs: string[]): RBinary | undefined { + // this is not relevant on Windows + if (os.platform() === 'win32') { + return undefined; + } + + // and, on not-Windows, hqDirs is expected to be a singleton + if (hqDirs.length > 1) { + LOGGER.error('Expected exactly one R HQ directory on this platform.'); + } + const hqDir = hqDirs[0]; + + if (!fs.existsSync(hqDir)) { + return undefined; + } + + const currentDirs = fs.readdirSync(hqDir) + .map(file => path.join(hqDir, file)) + // macOS: 'Current' (uppercase 'C'), if it exists, is a symlink to an actual version + // linux: 'current' (lowercase 'c'), if it exists, is a symlink to an actual version + .filter(path => path.toLowerCase().endsWith('current')); + + if (currentDirs.length !== 1) { + return undefined; + } + const currentDir = currentDirs[0]; + + const binpath = firstExisting(currentDir, binFragments()); + if (!binpath) { + return undefined; + } + + const binary = { path: fs.realpathSync(binpath), reasons: [ReasonDiscovered.HQ] }; + return binary; } -async function findCurrentRBinaryFromRegistry(): Promise { +// Consult various sources of other, perhaps non-current, R binaries +function discoverHQBinaries(hqDirs: string[]): RBinary[] { + const existingHqDirs = hqDirs.filter(dir => fs.existsSync(dir)); + if (existingHqDirs.length === 0) { + return []; + } + + const versionDirs = existingHqDirs + .map(hqDir => fs.readdirSync(hqDir).map(file => path.join(hqDir, file))) + // Windows: rig creates 'bin/', which is a directory of .bat files (at least, for now) + // https://github.com/r-lib/rig/issues/189 + .map(listing => listing.filter(path => !path.endsWith('bin'))) + // macOS: 'Current' (uppercase 'C'), if it exists, is a symlink to an actual version + // linux: 'current' (lowercase 'c'), if it exists, is a symlink to an actual version + .map(listing => listing.filter(path => !path.toLowerCase().endsWith('current'))); + + // On Windows: + // In the case that both (1) and (2) exist we prefer (1). + // (1) C:\Program Files\R\R-4.3.2\bin\x64\R.exe + // (2) C:\Program Files\R\R-4.3.2\bin\R.exe + // Because we require R >= 4.2, we don't need to consider bin\i386\R.exe. + const binaries = versionDirs + .map(vd => vd.map(x => firstExisting(x, binFragments()))) + .flat() + // macOS: By default, the CRAN installer deletes previous R installations, but sometimes + // it doesn't do a thorough job of it and a nearly-empty version directory lingers on. + .filter(b => fs.existsSync(b)) + .map(b => ({ path: b, reasons: [ReasonDiscovered.HQ] })); + return binaries; +} + +async function discoverRegistryBinaries(): Promise { if (os.platform() !== 'win32') { LOGGER.info('Skipping registry check on non-Windows platform'); - return undefined; + return []; } // eslint-disable-next-line @typescript-eslint/naming-convention const Registry = await import('@vscode/windows-registry'); const hives: any[] = ['HKEY_CURRENT_USER', 'HKEY_LOCAL_MACHINE']; + // R's install path is written to a WOW (Windows on Windows) node when e.g. an x86 build of + // R is installed on an ARM version of Windows. const wows = ['', 'WOW6432Node']; - let installPath = undefined; + // The @vscode/windows-registry module is so minimalistic that it can't list the registry. + // Therefore we explicitly generate the R versions that might be there and check for each one. + const versions = generateVersions(); + + const discoveredKeys: string[] = []; for (const hive of hives) { for (const wow of wows) { - const R64_KEY: string = `SOFTWARE\\${wow ? wow + '\\' : ''}R-core\\R64`; - try { - const key = Registry.GetStringRegKey(hive, R64_KEY, 'InstallPath'); - if (key) { - installPath = key; - LOGGER.info(`Registry key ${hive}\\${R64_KEY}\\InstallPath reports the current R installation is at ${key}`); - break; - } - } catch { } + for (const version of versions) { + const R64_KEY: string = `SOFTWARE\\${wow ? wow + '\\' : ''}R-core\\R64\\${version}`; + try { + const key = Registry.GetStringRegKey(hive, R64_KEY, 'InstallPath'); + if (key) { + LOGGER.info(`Registry key ${hive}\\${R64_KEY}\\InstallPath reports an R installation at ${key}`); + discoveredKeys.push(key); + } + } catch { } + } } } - if (installPath === undefined) { - LOGGER.info('Cannot determine current version of R from the registry.'); - return undefined; + const binPaths = discoveredKeys + .map(installPath => firstExisting(installPath, binFragments())) + .filter(binPath => binPath !== undefined) + .map(binPath => ({ path: binPath, reasons: [ReasonDiscovered.registry] })); + + return binPaths; +} + +function discoverAdHocBinaries(paths: string[]): RBinary[] { + return paths + .filter(b => fs.existsSync(b)) + .map(b => fs.realpathSync(b)) + .map(b => ({ path: b, reasons: [ReasonDiscovered.adHoc] })); +} + +// R discovery helpers + +// directory(ies) where this OS is known to keep its R installations +function rHeadquarters(): string[] { + switch (process.platform) { + case 'darwin': + return [path.join('/Library', 'Frameworks', 'R.framework', 'Versions')]; + case 'linux': + return [path.join('/opt', 'R')]; + case 'win32': { + const paths = [ + path.join(process.env['ProgramW6432'] || 'C:\\Program Files', 'R') + ]; + if (process.env['LOCALAPPDATA']) { + paths.push(path.join(process.env['LOCALAPPDATA'], 'Programs', 'R')); + } + return [...new Set(paths)]; + } + default: + throw new Error('Unsupported platform'); } +} - const binPath = firstExisting(installPath, binFragments()); - if (!binPath) { - return undefined; +// directory(ies) where this user keeps R installations +function userRHeadquarters(): string[] { + const config = vscode.workspace.getConfiguration('positron.r'); + const userHqDirs = config.get('customRootFolders'); + if (userHqDirs && userHqDirs.length > 0) { + const formattedPaths = JSON.stringify(userHqDirs, null, 2); + LOGGER.info(`User-specified directories to scan for R installations:\n${formattedPaths}`); + return userHqDirs; + } else { + return []; } - LOGGER.info(`Identified the current R binary: ${binPath}`); +} - return binPath; +// ad hoc binaries this user wants Positron to know about +function userRBinaries(): string[] { + const config = vscode.workspace.getConfiguration('positron.r'); + const userBinaries = config.get('customBinaries'); + if (userBinaries && userBinaries.length > 0) { + const formattedPaths = JSON.stringify(userBinaries, null, 2); + LOGGER.info(`User-specified R binaries:\n${formattedPaths}`); + return userBinaries; + } else { + return []; + } } +function firstExisting(base: string, fragments: string[]): string { + const potentialPaths = fragments.map(f => path.join(base, f)); + const existingPath = potentialPaths.find(p => fs.existsSync(p)); + return existingPath || ''; +} + +function binFragments(): string[] { + switch (process.platform) { + case 'darwin': + return [path.join('Resources', 'bin', 'R')]; + case 'linux': + return [path.join('bin', 'R')]; + case 'win32': + return [ + path.join('bin', 'x64', 'R.exe'), + path.join('bin', 'R.exe') + ]; + default: + throw new Error('Unsupported platform'); + } +} + +/** + * Generates all possible R versions that we might find recorded in the Windows registry. + * Sort of. + * Only considers the major version of Positron's current minimum R version and that major + * version plus one. + * Naively tacks " Pre-release" onto each version numbers, because that's how r-devel shows up. +*/ +function generateVersions(): string[] { + const minimumSupportedVersion = semver.coerce(MINIMUM_R_VERSION)!; + const major = minimumSupportedVersion.major; + const minor = minimumSupportedVersion.minor; + const patch = minimumSupportedVersion.patch; + + const versions: string[] = []; + for (let x = major; x <= major + 1; x++) { + for (let y = (x === major ? minor : 0); y <= 9; y++) { + for (let z = (x === major && y === minor ? patch : 0); z <= 9; z++) { + versions.push(`${x}.${y}.${z}`); + versions.push(`${x}.${y}.${z} Pre-release`); + } + } + } + + return versions; +} + + // Should we recommend an R runtime for the workspace? async function shouldRecommendForWorkspace(): Promise { // Check if the workspace contains R-related files. diff --git a/extensions/positron-r/src/r-installation.ts b/extensions/positron-r/src/r-installation.ts index e07d0c991f8..a813641155a 100644 --- a/extensions/positron-r/src/r-installation.ts +++ b/extensions/positron-r/src/r-installation.ts @@ -25,6 +25,64 @@ export interface RMetadataExtra { * https://github.com/posit-dev/positron/issues/2659 */ readonly current: boolean; + + /** + * How did we discover this R binary? + */ + readonly reasonDiscovered: ReasonDiscovered[] | null; +} + +/** + * Enum represents how we discovered an R binary. + */ +export enum ReasonDiscovered { + affiliated = "affiliated", + registry = "registry", + /* eslint-disable @typescript-eslint/naming-convention */ + PATH = "PATH", + HQ = "HQ", + /* eslint-enable @typescript-eslint/naming-convention */ + adHoc = "adHoc", + user = "user" +} + +/** + * Enum represents why we rejected an R binary. + */ +export enum ReasonRejected { + invalid = "invalid", + unsupported = "unsupported", + nonOrthogonal = "nonOrthogonal" +} + +export function friendlyReason(reason: ReasonDiscovered | ReasonRejected | null): string { + if (Object.values(ReasonDiscovered).includes(reason as ReasonDiscovered)) { + switch (reason) { + case ReasonDiscovered.affiliated: + return 'Runtime previously affiliated with this workspace'; + case ReasonDiscovered.registry: + return 'Found in Windows registry'; + case ReasonDiscovered.PATH: + return 'Found in PATH, via the `which` command'; + case ReasonDiscovered.HQ: + return 'Found in the primary location for R versions on this operating system'; + case ReasonDiscovered.adHoc: + return 'Found in a conventional location for symlinked R binaries'; + case ReasonDiscovered.user: + return 'User-specified location'; + } + } else if (Object.values(ReasonRejected).includes(reason as ReasonRejected)) { + switch (reason) { + case ReasonRejected.invalid: + return 'Invalid installation'; + case ReasonRejected.unsupported: + return `Unsupported version, i.e. version is less than ${MINIMUM_R_VERSION}`; + case ReasonRejected.nonOrthogonal: + return 'Non-orthogonal installation that is also not the current version'; + } + } + + return 'Unknown reason'; } /** @@ -32,13 +90,17 @@ export interface RMetadataExtra { */ export class RInstallation { // there are many reasons that we might deem a putative R installation to be unusable - // downstream users of RInstallation should filter for `valid` is `true` - public readonly valid: boolean = false; + // downstream users of RInstallation should filter for `usable` is `true` + public readonly usable: boolean = false; - // we have a minimum version of R - // downstream users of RInstallation should filter for `supported` is `true` + // is the version >= positron's minimum version? public readonly supported: boolean = false; + // we are gradually increasing user visibility into how the list of available R installations + // is determined; these fields are part of that plan + public readonly reasonDiscovered: ReasonDiscovered[] | null = null; + public readonly reasonRejected: ReasonRejected | null = null; + public readonly binpath: string = ''; public readonly homepath: string = ''; // The semVersion field was added because changing the version field from a string that's @@ -56,15 +118,24 @@ export class RInstallation { * * @param pth Filepath for an R "binary" (on macOS and linux, this is actually a shell script) * @param current Whether this installation is known to be the current version of R + * @param reasonDiscovered How we discovered this R binary (and there could be more than one + * reason) */ - constructor(pth: string, current: boolean = false) { + constructor( + pth: string, + current: boolean = false, + reasonDiscovered: ReasonDiscovered[] | null = null + ) { LOGGER.info(`Candidate R binary at ${pth}`); this.binpath = pth; this.current = current; + this.reasonDiscovered = reasonDiscovered; const rHomePath = getRHomePath(pth); if (!rHomePath) { + this.reasonRejected = ReasonRejected.invalid; + this.usable = false; return; } this.homepath = rHomePath; @@ -84,12 +155,16 @@ export class RInstallation { // https://github.com/posit-dev/positron/issues/1314 if (!fs.existsSync(descPath)) { LOGGER.info(`Can\'t find DESCRIPTION for the utils package at ${descPath}`); + this.reasonRejected = ReasonRejected.invalid; + this.usable = false; return; } const descLines = readLines(descPath); const targetLine2 = descLines.filter(line => line.match('Built'))[0]; if (!targetLine2) { LOGGER.info(`Can't find 'Built' field for the utils package in its DESCRIPTION: ${descPath}`); + this.reasonRejected = ReasonRejected.invalid; + this.usable = false; return; } // macOS arm64: Built: R 4.3.1; aarch64-apple-darwin20; 2023-06-16 21:52:54 UTC; unix @@ -106,6 +181,16 @@ export class RInstallation { const minimumSupportedVersion = semver.coerce(MINIMUM_R_VERSION)!; this.supported = semver.gte(this.semVersion, minimumSupportedVersion); + if (this.supported) { + this.usable = this.current || this.orthogonal; + if (!this.usable) { + this.reasonRejected = ReasonRejected.nonOrthogonal; + } + } else { + this.reasonRejected = ReasonRejected.unsupported; + this.usable = false; + } + const platformPart = builtParts[1]; const architecture = platformPart.match('^(aarch64|x86_64)'); @@ -128,10 +213,16 @@ export class RInstallation { this.arch = ''; } - this.valid = true; - LOGGER.info(`R installation discovered: ${JSON.stringify(this, null, 2)}`); } + + toJSON() { + return { + ...this, + reasonDiscovered: this.reasonDiscovered?.map(friendlyReason) ?? null, + reasonRejected: this.reasonRejected ? friendlyReason(this.reasonRejected) : null + }; + } } export function getRHomePath(binpath: string): string | undefined { diff --git a/extensions/positron-r/src/runtime-manager.ts b/extensions/positron-r/src/runtime-manager.ts index 4553bfe6531..6cff441c6b3 100644 --- a/extensions/positron-r/src/runtime-manager.ts +++ b/extensions/positron-r/src/runtime-manager.ts @@ -5,8 +5,8 @@ import * as positron from 'positron'; import * as vscode from 'vscode'; -import { findCurrentRBinary, makeMetadata, rRuntimeDiscoverer } from './provider'; -import { RInstallation, RMetadataExtra } from './r-installation'; +import { currentRBinary, makeMetadata, rRuntimeDiscoverer } from './provider'; +import { RInstallation, RMetadataExtra, ReasonDiscovered, friendlyReason } from './r-installation'; import { RSession, createJupyterKernelExtra } from './session'; import { createJupyterKernelSpec } from './kernel-spec'; @@ -44,7 +44,7 @@ export class RRuntimeManager implements positron.LanguageRuntimeManager { // Validate that the metadata has all of the extra data we need if (!metadataExtra) { - throw new Error('R metadata is missing binary path'); + throw new Error('R metadata is missing extra fields needed for validation'); } if (!metadataExtra.homepath) { throw new Error('R metadata is missing home path'); @@ -56,7 +56,7 @@ export class RRuntimeManager implements positron.LanguageRuntimeManager { // Look for the current R binary. Note that this can return undefined, // if there are no current/default R installations on the system. This // is okay. - const curBin = await findCurrentRBinary(); + const curBin = await currentRBinary(); let inst: RInstallation; if (curBin && metadataExtra.current) { @@ -66,24 +66,22 @@ export class RRuntimeManager implements positron.LanguageRuntimeManager { // The motivation for this mindset is immediate launch of an affiliated runtime. // More thoughts in this issue: // https://github.com/posit-dev/positron/issues/2659 - inst = new RInstallation(curBin, true); + curBin.reasons.unshift(ReasonDiscovered.affiliated); + inst = new RInstallation(curBin.path, true, curBin.reasons); } else { - inst = new RInstallation(metadataExtra.binpath, curBin === metadataExtra.binpath); + inst = new RInstallation(metadataExtra.binpath, curBin?.path === metadataExtra.binpath, [ReasonDiscovered.affiliated]); } // Check the installation for validity - if (!inst.valid) { + if (!inst.usable) { - // Consider future improvements: - // - // We could name the specific reason the installation is invalid, if - // only for logging purposes. + // Possible future improvement: // // It'd be helpful to select and return a valid installation if it's // available and reasonably compatible with the installation we were // asked for. This is probably going to be common for cases wherein // R is upgraded in place. - throw new Error(`R installation at ${metadataExtra.binpath} is not usable.`); + throw new Error(`R installation at ${metadataExtra.binpath} is not usable. Reason: ${friendlyReason(inst.reasonRejected)}`); } // Looks like a valid R installation. diff --git a/extensions/positron-r/src/test/discovery.test.ts b/extensions/positron-r/src/test/discovery.test.ts index 5ddcf69ebf2..317e25d5c83 100644 --- a/extensions/positron-r/src/test/discovery.test.ts +++ b/extensions/positron-r/src/test/discovery.test.ts @@ -6,7 +6,7 @@ import * as assert from 'assert'; import * as Fs from "fs"; import * as Sinon from 'sinon'; -import { findRBinaryFromPATHWindows } from '../provider'; +import { currentRBinaryFromPATHWindows } from '../provider'; import path = require('path'); @@ -65,8 +65,8 @@ suite('Discovery', () => { }); test('Find R on Windows path', async () => { - const result = await findRBinaryFromPATHWindows(r432); - assert.strictEqual(result, r432); + const result = await currentRBinaryFromPATHWindows(r432); + assert.strictEqual(result?.path, r432); }); }); @@ -83,7 +83,7 @@ suite('Discovery', () => { }); test('Find R on Windows path', async () => { - const result = await findRBinaryFromPATHWindows(rbat); + const result = await currentRBinaryFromPATHWindows(rbat); assert.strictEqual(result, undefined); }); }); @@ -102,8 +102,8 @@ suite('Discovery', () => { }); test('Find R on Windows path', async () => { - const result = await findRBinaryFromPATHWindows(smartshim); - assert.strictEqual(result, x64); + const result = await currentRBinaryFromPATHWindows(smartshim); + assert.strictEqual(result?.path, x64); }); }); }); diff --git a/extensions/positron-r/src/util.ts b/extensions/positron-r/src/util.ts index a073ea0439b..dabcf102ba1 100644 --- a/extensions/positron-r/src/util.ts +++ b/extensions/positron-r/src/util.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as fs from 'fs'; +import { LOGGER } from './extension'; export class PromiseHandles { resolve!: (value: T | Promise) => void; @@ -42,8 +43,13 @@ export function timeout(ms: number, reason: string) { } export function readLines(pth: string): Array { - const bigString = fs.readFileSync(pth, 'utf8'); - return bigString.split(/\r?\n/); + try { + const bigString = fs.readFileSync(pth, 'utf8'); + return bigString.split(/\r?\n/); + } catch (error) { + LOGGER.error(`Error reading file: "${error}"`); + return []; + } } // extractValue('KEY=VALUE', 'KEY') --> 'VALUE' diff --git a/extensions/positron-supervisor/package.json b/extensions/positron-supervisor/package.json index 937f9d913d3..1e55ea4d228 100644 --- a/extensions/positron-supervisor/package.json +++ b/extensions/positron-supervisor/package.json @@ -39,6 +39,11 @@ "default": false, "description": "%configuration.showTerminal.description%" }, + "positronKernelSupervisor.connectionTimeout": { + "type": "integer", + "default": 30, + "description": "%configuration.connectionTimeout.description%" + }, "positronKernelSupervisor.logLevel": { "scope": "window", "type": "string", @@ -97,7 +102,7 @@ }, "positron": { "binaryDependencies": { - "kallichore": "0.1.21" + "kallichore": "0.1.22" } }, "dependencies": { diff --git a/extensions/positron-supervisor/package.nls.json b/extensions/positron-supervisor/package.nls.json index ce4d37addbb..8f4b97ae176 100644 --- a/extensions/positron-supervisor/package.nls.json +++ b/extensions/positron-supervisor/package.nls.json @@ -9,6 +9,7 @@ "configuration.logLevel.description": "Log level for the kernel supervisor (restart Positron to apply)", "configuration.enable.description": "Run Jupyter kernels under the Positron kernel supervisor.", "configuration.showTerminal.description": "Show the host terminal for the Positron kernel supervisor", + "configuration.connectionTimeout.description": "Timeout in seconds for connecting to the kernel's sockets", "configuration.attachOnStartup.description": "Run before starting up Jupyter kernel (when supported)", "configuration.sleepOnStartup.description": "Sleep for n seconds before starting up Jupyter kernel (when supported)", "command.positron.supervisor.category": "Kernel Supervisor", diff --git a/extensions/positron-supervisor/src/KallichoreSession.ts b/extensions/positron-supervisor/src/KallichoreSession.ts index 4bb8333ac85..8bac75d7a81 100644 --- a/extensions/positron-supervisor/src/KallichoreSession.ts +++ b/extensions/positron-supervisor/src/KallichoreSession.ts @@ -254,6 +254,7 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { const config = vscode.workspace.getConfiguration('positronKernelSupervisor'); const attachOnStartup = config.get('attachOnStartup', false) && this._extra?.attachOnStartup; const sleepOnStartup = config.get('sleepOnStartup', undefined) && this._extra?.sleepOnStartup; + const connectionTimeout = config.get('connectionTimeout', 30); if (attachOnStartup) { this._extra!.attachOnStartup!.init(args); } @@ -273,7 +274,8 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { env, workingDirectory: workingDir, username: os.userInfo().username, - interruptMode + interruptMode, + connectionTimeout, }; await this._api.newSession(session); this.log(`${kernelSpec.display_name} session '${this.metadata.sessionId}' created in ${workingDir} with command:`, vscode.LogLevel.Info); diff --git a/extensions/positron-supervisor/src/kcclient/.openapi-generator/FILES b/extensions/positron-supervisor/src/kcclient/.openapi-generator/FILES index aa0614849ba..29853d7ec5e 100644 --- a/extensions/positron-supervisor/src/kcclient/.openapi-generator/FILES +++ b/extensions/positron-supervisor/src/kcclient/.openapi-generator/FILES @@ -4,6 +4,8 @@ api/apis.ts api/defaultApi.ts git_push.sh model/activeSession.ts +model/adoptedSession.ts +model/connectionInfo.ts model/executionQueue.ts model/interruptMode.ts model/modelError.ts diff --git a/extensions/positron-supervisor/src/kcclient/api/defaultApi.ts b/extensions/positron-supervisor/src/kcclient/api/defaultApi.ts index 8056d7edd21..3cd6bed4a83 100644 --- a/extensions/positron-supervisor/src/kcclient/api/defaultApi.ts +++ b/extensions/positron-supervisor/src/kcclient/api/defaultApi.ts @@ -16,6 +16,7 @@ import http from 'http'; /* tslint:disable:no-unused-locals */ import { ActiveSession } from '../model/activeSession'; +import { AdoptedSession } from '../model/adoptedSession'; import { ModelError } from '../model/modelError'; import { NewSession } from '../model/newSession'; import { NewSession200Response } from '../model/newSession200Response'; @@ -98,6 +99,75 @@ export class DefaultApi { this.interceptors.push(interceptor); } + /** + * + * @summary Adopt an existing session + * @param adoptedSession + */ + public async adoptSession (adoptedSession: AdoptedSession, options: {headers: {[name: string]: string}} = {headers: {}}) : Promise<{ response: http.IncomingMessage; body: NewSession200Response; }> { + const localVarPath = this.basePath + '/sessions/adopt'; + let localVarQueryParameters: any = {}; + let localVarHeaderParams: any = (Object).assign({}, this._defaultHeaders); + const produces = ['application/json']; + // give precedence to 'application/json' + if (produces.indexOf('application/json') >= 0) { + localVarHeaderParams.Accept = 'application/json'; + } else { + localVarHeaderParams.Accept = produces.join(','); + } + let localVarFormParams: any = {}; + + // verify required parameter 'adoptedSession' is not null or undefined + if (adoptedSession === null || adoptedSession === undefined) { + throw new Error('Required parameter adoptedSession was null or undefined when calling adoptSession.'); + } + + (Object).assign(localVarHeaderParams, options.headers); + + let localVarUseFormData = false; + + let localVarRequestOptions: localVarRequest.Options = { + method: 'PUT', + qs: localVarQueryParameters, + headers: localVarHeaderParams, + uri: localVarPath, + useQuerystring: this._useQuerystring, + json: true, + body: ObjectSerializer.serialize(adoptedSession, "AdoptedSession") + }; + + let authenticationPromise = Promise.resolve(); + authenticationPromise = authenticationPromise.then(() => this.authentications.default.applyToRequest(localVarRequestOptions)); + + let interceptorPromise = authenticationPromise; + for (const interceptor of this.interceptors) { + interceptorPromise = interceptorPromise.then(() => interceptor(localVarRequestOptions)); + } + + return interceptorPromise.then(() => { + if (Object.keys(localVarFormParams).length) { + if (localVarUseFormData) { + (localVarRequestOptions).formData = localVarFormParams; + } else { + localVarRequestOptions.form = localVarFormParams; + } + } + return new Promise<{ response: http.IncomingMessage; body: NewSession200Response; }>((resolve, reject) => { + localVarRequest(localVarRequestOptions, (error, response, body) => { + if (error) { + reject(error); + } else { + if (response.statusCode && response.statusCode >= 200 && response.statusCode <= 299) { + body = ObjectSerializer.deserialize(body, "NewSession200Response"); + resolve({ response: response, body: body }); + } else { + reject(new HttpError(response, body, response.statusCode)); + } + } + }); + }); + }); + } /** * * @summary Upgrade to a WebSocket for channel communication diff --git a/extensions/positron-supervisor/src/kcclient/model/adoptedSession.ts b/extensions/positron-supervisor/src/kcclient/model/adoptedSession.ts new file mode 100644 index 00000000000..b094e6b27a5 --- /dev/null +++ b/extensions/positron-supervisor/src/kcclient/model/adoptedSession.ts @@ -0,0 +1,42 @@ +/** + * Kallichore API + * Kallichore is a Jupyter kernel gateway and supervisor + * + * The version of the OpenAPI document: 1.0.0 + * Contact: info@posit.co + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { RequestFile } from './models'; +import { ConnectionInfo } from './connectionInfo'; +import { NewSession } from './newSession'; + +/** +* The session to adopt +*/ +export class AdoptedSession { + 'session': NewSession; + 'connectionInfo': ConnectionInfo; + + static discriminator: string | undefined = undefined; + + static attributeTypeMap: Array<{name: string, baseName: string, type: string}> = [ + { + "name": "session", + "baseName": "session", + "type": "NewSession" + }, + { + "name": "connectionInfo", + "baseName": "connection_info", + "type": "ConnectionInfo" + } ]; + + static getAttributeTypeMap() { + return AdoptedSession.attributeTypeMap; + } +} + diff --git a/extensions/positron-supervisor/src/kcclient/model/connectionInfo.ts b/extensions/positron-supervisor/src/kcclient/model/connectionInfo.ts new file mode 100644 index 00000000000..59c5ecb7e55 --- /dev/null +++ b/extensions/positron-supervisor/src/kcclient/model/connectionInfo.ts @@ -0,0 +1,109 @@ +/** + * Kallichore API + * Kallichore is a Jupyter kernel gateway and supervisor + * + * The version of the OpenAPI document: 1.0.0 + * Contact: info@posit.co + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { RequestFile } from './models'; + +/** +* Connection information for an existing session +*/ +export class ConnectionInfo { + /** + * The port for control messages + */ + 'controlPort': number; + /** + * The port for shell messages + */ + 'shellPort': number; + /** + * The port for stdin messages + */ + 'stdinPort': number; + /** + * The port for heartbeat messages + */ + 'hbPort': number; + /** + * The port for IOPub messages + */ + 'iopubPort': number; + /** + * The signature scheme for messages + */ + 'signatureScheme': string; + /** + * The key for messages + */ + 'key': string; + /** + * The transport protocol + */ + 'transport': string; + /** + * The IP address for the connection + */ + 'ip': string; + + static discriminator: string | undefined = undefined; + + static attributeTypeMap: Array<{name: string, baseName: string, type: string}> = [ + { + "name": "controlPort", + "baseName": "control_port", + "type": "number" + }, + { + "name": "shellPort", + "baseName": "shell_port", + "type": "number" + }, + { + "name": "stdinPort", + "baseName": "stdin_port", + "type": "number" + }, + { + "name": "hbPort", + "baseName": "hb_port", + "type": "number" + }, + { + "name": "iopubPort", + "baseName": "iopub_port", + "type": "number" + }, + { + "name": "signatureScheme", + "baseName": "signature_scheme", + "type": "string" + }, + { + "name": "key", + "baseName": "key", + "type": "string" + }, + { + "name": "transport", + "baseName": "transport", + "type": "string" + }, + { + "name": "ip", + "baseName": "ip", + "type": "string" + } ]; + + static getAttributeTypeMap() { + return ConnectionInfo.attributeTypeMap; + } +} + diff --git a/extensions/positron-supervisor/src/kcclient/model/models.ts b/extensions/positron-supervisor/src/kcclient/model/models.ts index 31465e3cbf2..dd9ed9fd296 100644 --- a/extensions/positron-supervisor/src/kcclient/model/models.ts +++ b/extensions/positron-supervisor/src/kcclient/model/models.ts @@ -1,6 +1,8 @@ import localVarRequest from 'request'; export * from './activeSession'; +export * from './adoptedSession'; +export * from './connectionInfo'; export * from './executionQueue'; export * from './interruptMode'; export * from './modelError'; @@ -25,6 +27,8 @@ export type RequestFile = string | Buffer | fs.ReadStream | RequestDetailedFile; import { ActiveSession } from './activeSession'; +import { AdoptedSession } from './adoptedSession'; +import { ConnectionInfo } from './connectionInfo'; import { ExecutionQueue } from './executionQueue'; import { InterruptMode } from './interruptMode'; import { ModelError } from './modelError'; @@ -54,6 +58,8 @@ let enumsMap: {[index: string]: any} = { let typeMap: {[index: string]: any} = { "ActiveSession": ActiveSession, + "AdoptedSession": AdoptedSession, + "ConnectionInfo": ConnectionInfo, "ExecutionQueue": ExecutionQueue, "ModelError": ModelError, "NewSession": NewSession, diff --git a/extensions/positron-supervisor/src/kcclient/model/newSession.ts b/extensions/positron-supervisor/src/kcclient/model/newSession.ts index 565fbd30fb8..d6294de749b 100644 --- a/extensions/positron-supervisor/src/kcclient/model/newSession.ts +++ b/extensions/positron-supervisor/src/kcclient/model/newSession.ts @@ -50,6 +50,10 @@ export class NewSession { * Environment variables to set for the session */ 'env': { [key: string]: string; }; + /** + * The number of seconds to wait for a connection to the session\'s ZeroMQ sockets before timing out + */ + 'connectionTimeout'?: number = 30; 'interruptMode': InterruptMode; static discriminator: string | undefined = undefined; @@ -100,6 +104,11 @@ export class NewSession { "baseName": "env", "type": "{ [key: string]: string; }" }, + { + "name": "connectionTimeout", + "baseName": "connection_timeout", + "type": "number" + }, { "name": "interruptMode", "baseName": "interrupt_mode", diff --git a/src/positron-dts/positron.d.ts b/src/positron-dts/positron.d.ts index d0a5d06320f..861dca93303 100644 --- a/src/positron-dts/positron.d.ts +++ b/src/positron-dts/positron.d.ts @@ -93,11 +93,20 @@ declare module 'positron' { /** The runtime is busy executing code. */ Busy = 'busy', + /** The runtime is in the process of restarting. */ + Restarting = 'restarting', + + /** The runtime is in the process of shutting down. */ + Exiting = 'exiting', + /** The runtime's host process has ended. */ Exited = 'exited', /** The runtime is not responding to heartbeats and is presumed offline. */ Offline = 'offline', + + /** The user has interrupted a busy runtime, but the runtime is not idle yet. */ + Interrupting = 'interrupting', } /** diff --git a/src/vs/workbench/services/positronDataExplorer/common/positronDataExplorerDuckDBBackend.ts b/src/vs/workbench/services/positronDataExplorer/common/positronDataExplorerDuckDBBackend.ts index 629411eff46..4dd77ab43ef 100644 --- a/src/vs/workbench/services/positronDataExplorer/common/positronDataExplorerDuckDBBackend.ts +++ b/src/vs/workbench/services/positronDataExplorer/common/positronDataExplorerDuckDBBackend.ts @@ -38,7 +38,7 @@ import { TableSchema, TableSelection } from '../../languageRuntime/common/positronDataExplorerComm.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ICommandService, CommandsRegistry } from '../../../../platform/commands/common/commands.js'; /** @@ -115,9 +115,26 @@ export class PositronDataExplorerDuckDBBackend extends Disposable implements IDa private async _execRpc(rpc: DataExplorerRpc): Promise { await this.initialSetup; - const response = await this._commandService.executeCommand( - 'positron-duckdb.dataExplorerRpc', rpc - ); + + const commandName = 'positron-duckdb.dataExplorerRpc'; + if (CommandsRegistry.getCommand(commandName) === undefined) { + await (new Promise((resolve, reject) => { + // Reject if command not registered within 30 seconds + const timeoutId = setTimeout(() => { + reject(new Error(`${commandName} not registered within 30 seconds`)); + }, 30000); + + CommandsRegistry.onDidRegisterCommand((id: string) => { + if (id === commandName) { + clearTimeout(timeoutId); + resolve(); + } + }); + })); + } + + const response = await this._commandService.executeCommand(commandName, rpc); + if (response === undefined) { return Promise.reject( new Error('Sending request to positron-duckdb failed for unknown reason') diff --git a/src/vs/workbench/services/runtimeStartup/common/runtimeStartup.ts b/src/vs/workbench/services/runtimeStartup/common/runtimeStartup.ts index eeb7be00786..90d94a19130 100644 --- a/src/vs/workbench/services/runtimeStartup/common/runtimeStartup.ts +++ b/src/vs/workbench/services/runtimeStartup/common/runtimeStartup.ts @@ -166,11 +166,18 @@ export class RuntimeStartupService extends Disposable implements IRuntimeStartup // auto-start a runtime. this._register(this.onDidChangeRuntimeStartupPhase(phase => { if (phase === RuntimeStartupPhase.Complete) { - if (!this.hasAffiliatedRuntime() && + // if no runtimes were found, notify the user about the problem + if (this._languageRuntimeService.registeredRuntimes.length === 0) { + this._notificationService.error(nls.localize('positron.runtimeStartupService.noRuntimesMessage', + "No interpreters found. Please see the [Get Started](https://positron.posit.co/start) \ + documentation to learn how to prepare your Python and/or R environments to work with Positron.")); + } + + // If there are no affiliated runtimes, and no starting or running + // runtimes, start the first runtime that has Immediate startup + // behavior. + else if (!this.hasAffiliatedRuntime() && !this._runtimeSessionService.hasStartingOrRunningConsole()) { - // If there are no affiliated runtimes, and no starting or running - // runtimes, start the first runtime that has Immediate startup - // behavior. const languageRuntimes = this._languageRuntimeService.registeredRuntimes .filter(metadata => metadata.startupBehavior === LanguageRuntimeStartupBehavior.Immediate); @@ -384,7 +391,7 @@ export class RuntimeStartupService extends Disposable implements IRuntimeStartup /** * Activates all of the extensions that provides language runtimes, then - * entires the discovery phase, in which each extension is asked to supply + * enters the discovery phase, in which each extension is asked to supply * its language runtime metadata. */ private async discoverAllRuntimes() { diff --git a/test/automation/src/positron/positronConnections.ts b/test/automation/src/positron/positronConnections.ts index 053cfc6087f..dc8f35bd877 100644 --- a/test/automation/src/positron/positronConnections.ts +++ b/test/automation/src/positron/positronConnections.ts @@ -20,12 +20,14 @@ export class PositronConnections { disconnectButton: Locator; connectIcon: Locator; connectionItems: Locator; + resumeConnectionButton: Locator; constructor(private code: Code, private quickaccess: QuickAccess) { this.deleteConnectionButton = code.driver.page.getByLabel('Delete Connection'); this.disconnectButton = code.driver.page.getByLabel('Disconnect'); this.connectIcon = code.driver.page.locator('.codicon-arrow-circle-right'); this.connectionItems = code.driver.page.locator('.connections-list-item'); + this.resumeConnectionButton = code.driver.page.locator('.positron-modal-dialog-box').getByRole('button', { name: 'Resume Connection' }); } async openConnectionsNodes(nodes: string[]) { diff --git a/test/automation/src/positron/positronNotebooks.ts b/test/automation/src/positron/positronNotebooks.ts index 705d6509cff..1569ba816ea 100644 --- a/test/automation/src/positron/positronNotebooks.ts +++ b/test/automation/src/positron/positronNotebooks.ts @@ -94,4 +94,11 @@ export class PositronNotebooks { await expect(markdownLocator).toBeVisible(); await expect(markdownLocator).toHaveText(expectedText); } + + async runAllCells(timeout: number = 30000) { + await this.code.driver.page.getByLabel('Run All').click(); + const stopExecutionLocator = this.code.driver.page.locator('a').filter({ hasText: /Stop Execution|Interrupt/ }); + await expect(stopExecutionLocator).toBeVisible(); + await expect(stopExecutionLocator).not.toBeVisible({ timeout: timeout }); + } } diff --git a/test/e2e/README.md b/test/e2e/README.md index 9d828781d3c..15290e35762 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -209,6 +209,6 @@ In order to get the "golden screenshots" used for plot comparison is CI, you wil ## Tests run on PRs -If you think your test should be run when PRs are created, [tag the test with @pr](https://playwright.dev/docs/test-annotations#tag-tests). The existing @pr cases were selected to give good overall coverage while keeping the overall execution time down to ten minutes or less. If your new test functionality covers a part of the application that no other tests cover, it is probably a good idea to include it in the @pr set. +If you think your test should be run when PRs are created, [tag the test with @critical](https://playwright.dev/docs/test-annotations#tag-tests). The existing @critical cases were selected to give good overall coverage while keeping the overall execution time down to ten minutes or less. If your new test functionality covers a part of the application that no other tests cover, it is probably a good idea to include it in the @critical set. diff --git a/test/e2e/features/_test.setup.ts b/test/e2e/features/_test.setup.ts index 173f01292f0..15be8a3ff3a 100644 --- a/test/e2e/features/_test.setup.ts +++ b/test/e2e/features/_test.setup.ts @@ -22,7 +22,7 @@ import { randomUUID } from 'crypto'; import archiver from 'archiver'; // Local imports -import { createLogger, createApp } from '../helpers'; +import { createLogger, createApp, TestTags } from '../helpers'; import { Application, Logger, PositronPythonFixtures, PositronRFixtures, PositronUserSettingsFixtures, UserSetting } from '../../automation'; const TEMP_DIR = `temp-${randomUUID()}`; @@ -298,6 +298,7 @@ test.afterAll(async function ({ logger }, testInfo) { }); export { playwrightExpect as expect }; +export { TestTags as tags }; async function moveAndOverwrite(sourcePath, destinationPath) { try { diff --git a/test/e2e/features/apps/python-apps.test.ts b/test/e2e/features/apps/python-apps.test.ts index 4966d1d0478..e5ac928ab22 100644 --- a/test/e2e/features/apps/python-apps.test.ts +++ b/test/e2e/features/apps/python-apps.test.ts @@ -3,14 +3,16 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; import { join } from 'path'; test.use({ suiteId: __filename }); -test.describe('Python Applications', { tag: ['@pr', '@apps', '@viewer', '@editor'] }, () => { +test.describe('Python Applications', { + tag: [tags.CRITICAL, tags.APPS, tags.VIEWER, tags.EDITOR] +}, () => { test.afterEach(async function ({ app }) { await app.workbench.quickaccess.runCommand('workbench.action.closeAllEditors'); @@ -22,7 +24,7 @@ test.describe('Python Applications', { tag: ['@pr', '@apps', '@viewer', '@editor await app.workbench.positronViewer.clearViewer(); }); - test('Python - Verify Basic Dash App [C903305]', { tag: ['@win'] }, async function ({ app, python }) { + test('Python - Verify Basic Dash App [C903305]', { tag: [tags.WIN] }, async function ({ app, python }) { const viewer = app.workbench.positronViewer; await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'workspaces', 'python_apps', 'dash_example', 'dash_example.py')); @@ -58,7 +60,7 @@ test.describe('Python Applications', { tag: ['@pr', '@apps', '@viewer', '@editor // TODO: update for pop out to editor when issue resolved test('Python - Verify Basic Gradio App [C903307]', { - tag: ['@win'], + tag: [tags.WIN], }, async function ({ app, python }) { const viewer = app.workbench.positronViewer; @@ -71,7 +73,9 @@ test.describe('Python Applications', { tag: ['@pr', '@apps', '@viewer', '@editor await app.workbench.quickaccess.runCommand('workbench.action.toggleSidebarVisibility'); }); - test('Python - Verify Basic Streamlit App [C903308]', { tag: ['@web', '@win'] }, async function ({ app, python }) { + test('Python - Verify Basic Streamlit App [C903308]', { + tag: [tags.WEB, tags.WIN] + }, async function ({ app, python }) { const viewer = app.workbench.positronViewer; await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'workspaces', 'python_apps', 'streamlit_example', 'streamlit_example.py')); @@ -105,7 +109,7 @@ test.describe('Python Applications', { tag: ['@pr', '@apps', '@viewer', '@editor }); test('Python - Verify Basic Flask App [C1013655]', { - tag: ['@web', '@win'] + tag: [tags.WEB, tags.WIN] }, async function ({ app, python, page }) { const viewer = app.workbench.positronViewer; diff --git a/test/e2e/features/apps/shiny.test.ts b/test/e2e/features/apps/shiny.test.ts index 6150934a375..921817d6b5d 100644 --- a/test/e2e/features/apps/shiny.test.ts +++ b/test/e2e/features/apps/shiny.test.ts @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { join } from 'path'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); -test.describe('Shiny Application', { tag: ['@apps', '@viewer'] }, () => { +test.describe('Shiny Application', { tag: [tags.APPS, tags.VIEWER] }, () => { test.beforeAll(async function ({ app }) { try { await app.workbench.extensions.installExtension('posit.shiny', true); diff --git a/test/e2e/features/connections/connections-db.test.ts b/test/e2e/features/connections/connections-db.test.ts index e31aa3e5c6c..2dc15ac2a6a 100644 --- a/test/e2e/features/connections/connections-db.test.ts +++ b/test/e2e/features/connections/connections-db.test.ts @@ -4,15 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import { join } from 'path'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); -test.describe('SQLite DB Connection', { tag: ['@web', '@win', '@pr', '@connections'] }, () => { +test.describe('SQLite DB Connection', { + tag: [tags.WEB, tags.WIN, tags.CRITICAL, tags.CONNECTIONS] +}, () => { test.beforeAll(async function ({ userSettings }) { - await userSettings.set([['positron.connections.showConnectionPane', 'true']], true); + await userSettings.set([['positron.connections.showConnectionPane', 'true']]); }); test.afterEach(async function ({ app }) { @@ -21,7 +23,9 @@ test.describe('SQLite DB Connection', { tag: ['@web', '@win', '@pr', '@connectio await app.workbench.positronConnections.deleteConnection(); }); - test('Python - SQLite DB Connection [C628636]', async function ({ app, python }) { + test.skip('Python - SQLite DB Connection [C628636]', { + annotation: [{ type: 'issue', description: 'https://github.com/posit-dev/positron/issues/5692' }] + }, async function ({ app, python }) { await test.step('Open a Python file and run it', async () => { await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'workspaces', 'chinook-db-py', 'chinook-sqlite.py')); await app.workbench.quickaccess.runCommand('python.execInConsole'); @@ -40,6 +44,15 @@ test.describe('SQLite DB Connection', { tag: ['@web', '@win', '@pr', '@connectio await app.workbench.positronConnections.openConnectionsNodes(['main']); await app.workbench.positronConnections.assertConnectionNodes(['albums']); }); + + await test.step('Disconnect, reconnect with dialog, & reverify', async () => { + await app.workbench.positronConnections.disconnectButton.click(); + await app.workbench.positronConnections.connectIcon.click(); + await app.workbench.positronConnections.resumeConnectionButton.click(); + + await app.workbench.positronConnections.openConnectionsNodes(['main']); + await app.workbench.positronConnections.assertConnectionNodes(['albums']); + }); }); test('R - SQLite DB Connection [C628637]', async function ({ app, r }) { @@ -57,6 +70,16 @@ test.describe('SQLite DB Connection', { tag: ['@web', '@win', '@pr', '@connectio await app.workbench.positronConnections.openConnectionsNodes(['SQLiteConnection', 'Default']); await app.workbench.positronConnections.openConnectionsNodes(tables); }); + + await test.step('Disconnect, reconnect with dialog, & reverify', async () => { + await app.workbench.positronConnections.disconnectButton.click(); + await app.workbench.positronConnections.connectIcon.click(); + await app.workbench.positronConnections.resumeConnectionButton.click(); + + await app.workbench.positronConnections.openConnectionsNodes(['SQLiteConnection', 'Default']); + await app.workbench.positronConnections.openConnectionsNodes(tables); + }); + }); test('R - Connections are update after adding a database,[C663724]', async function ({ app, page, r }) { diff --git a/test/e2e/features/console/console-ansi.test.ts b/test/e2e/features/console/console-ansi.test.ts index 44b81ce66b6..b1759185538 100644 --- a/test/e2e/features/console/console-ansi.test.ts +++ b/test/e2e/features/console/console-ansi.test.ts @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { join } from 'path'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); -test.describe('Console ANSI styling', { tag: ['@pr', '@console'] }, () => { +test.describe('Console ANSI styling', { tag: [tags.CRITICAL, tags.CONSOLE] }, () => { test.beforeEach(async function ({ app }) { await app.workbench.positronLayouts.enterLayout('fullSizedPanel'); }); diff --git a/test/e2e/features/console/console-autocomplete.test.ts b/test/e2e/features/console/console-autocomplete.test.ts index c7a25f2cbe5..310d58d8432 100644 --- a/test/e2e/features/console/console-autocomplete.test.ts +++ b/test/e2e/features/console/console-autocomplete.test.ts @@ -3,7 +3,7 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test } from '../_test.setup'; +import { test, tags } from '../_test.setup'; import { fail } from 'assert'; test.use({ @@ -11,7 +11,7 @@ test.use({ }); test.describe('Console Autocomplete', { - tag: ['@web', '@win', '@console'] + tag: [tags.WEB, tags.WIN, tags.CONSOLE] }, () => { test('Python - Verify Console Autocomplete [C947968]', async function ({ app, python }) { await app.workbench.positronConsole.pasteCodeToConsole('import pandas as pd'); diff --git a/test/e2e/features/console/console-clipboard.test.ts b/test/e2e/features/console/console-clipboard.test.ts index 5e38cc2ee36..ed96ecbfc47 100644 --- a/test/e2e/features/console/console-clipboard.test.ts +++ b/test/e2e/features/console/console-clipboard.test.ts @@ -4,14 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import * as os from 'os'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; import { Application } from '../../../automation'; test.use({ suiteId: __filename }); -test.describe('Console - Clipboard', { tag: ['@console'] }, () => { +test.describe('Console - Clipboard', { tag: [tags.CONSOLE] }, () => { test('Python - Copy from console & paste to console [C608100]', async function ({ app, python }) { await testBody(app); }); diff --git a/test/e2e/features/console/console-history.test.ts b/test/e2e/features/console/console-history.test.ts index bee1cabafaf..ee121675721 100644 --- a/test/e2e/features/console/console-history.test.ts +++ b/test/e2e/features/console/console-history.test.ts @@ -3,14 +3,14 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); test.describe('Console History', { - tag: ['@web', '@win', '@console'] + tag: [tags.WEB, tags.WIN, tags.CONSOLE] }, () => { test.afterEach(async function ({ app }) { app.workbench.positronConsole.sendKeyboardKey('Escape'); diff --git a/test/e2e/features/console/console-input.test.ts b/test/e2e/features/console/console-input.test.ts index 8eb3b5a3b20..b215da18ab7 100644 --- a/test/e2e/features/console/console-input.test.ts +++ b/test/e2e/features/console/console-input.test.ts @@ -3,14 +3,14 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); test.describe('Console Input', { - tag: ['@web', '@pr', '@win', '@console'] + tag: [tags.WEB, tags.CRITICAL, tags.WIN, tags.CONSOLE] }, () => { test.describe('Console Input - Python', () => { diff --git a/test/e2e/features/console/console-output.test.ts b/test/e2e/features/console/console-output.test.ts index 7dbbd66e124..16d9cced525 100644 --- a/test/e2e/features/console/console-output.test.ts +++ b/test/e2e/features/console/console-output.test.ts @@ -3,13 +3,13 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test } from '../_test.setup'; +import { test, tags } from '../_test.setup'; test.use({ suiteId: __filename }); -test.describe('Console Output', { tag: ['@win', '@console'] }, () => { +test.describe('Console Output', { tag: [tags.WIN, tags.CONSOLE] }, () => { test('R - Console output in a loop with short pauses [C885225]', async function ({ app, r }) { await app.workbench.positronConsole.pasteCodeToConsole(rCode); await app.workbench.positronConsole.sendEnterKey(); diff --git a/test/e2e/features/console/console-python.test.ts b/test/e2e/features/console/console-python.test.ts index fa48b0fd742..3e0b993a18d 100644 --- a/test/e2e/features/console/console-python.test.ts +++ b/test/e2e/features/console/console-python.test.ts @@ -3,13 +3,13 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); -test.describe('Console Pane: Python', { tag: ['@web', '@win', '@console'] }, () => { +test.describe('Console Pane: Python', { tag: [tags.WEB, tags.WIN, tags.CONSOLE] }, () => { test('Verify restart button inside the console [C377918]', async function ({ app, python }) { await expect(async () => { diff --git a/test/e2e/features/console/console-r.test.ts b/test/e2e/features/console/console-r.test.ts index f1110979bcd..4cab842430f 100644 --- a/test/e2e/features/console/console-r.test.ts +++ b/test/e2e/features/console/console-r.test.ts @@ -3,14 +3,14 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); test.describe('Console Pane: R', { - tag: ['@web', '@win', '@console'] + tag: [tags.WEB, tags.WIN, tags.CONSOLE] }, () => { test.beforeAll(async function ({ app }) { // Need to make console bigger to see all bar buttons diff --git a/test/e2e/features/data-explorer/100x100-pandas.test.ts b/test/e2e/features/data-explorer/100x100-pandas.test.ts index 796e89eb200..e8e3945245e 100644 --- a/test/e2e/features/data-explorer/100x100-pandas.test.ts +++ b/test/e2e/features/data-explorer/100x100-pandas.test.ts @@ -3,7 +3,7 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test } from '../_test.setup'; +import { test, tags } from '../_test.setup'; import { join } from 'path'; import { parquetFilePath, testDataExplorer } from './helpers/100x100'; @@ -11,7 +11,9 @@ test.use({ suiteId: __filename }); -test('Data Explorer 100x100 - Python - Pandas [C557563]', { tag: ['@win', '@data-explorer'] }, async function ({ app, python }) { +test('Data Explorer 100x100 - Python - Pandas [C557563]', { + tag: [tags.WIN, tags.DATA_EXPLORER] +}, async function ({ app, python }) { test.slow(); const dataFrameName = 'pandas100x100'; diff --git a/test/e2e/features/data-explorer/100x100-polars.test.ts b/test/e2e/features/data-explorer/100x100-polars.test.ts index 8d5ec0242f9..6c7de9559b8 100644 --- a/test/e2e/features/data-explorer/100x100-polars.test.ts +++ b/test/e2e/features/data-explorer/100x100-polars.test.ts @@ -3,7 +3,7 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test } from '../_test.setup'; +import { test, tags } from '../_test.setup'; import { join } from 'path'; import { parquetFilePath, testDataExplorer } from './helpers/100x100'; @@ -11,7 +11,9 @@ test.use({ suiteId: __filename }); -test('Data Explorer 100x100 - Python - Polars [C674520]', { tag: ['@win', '@data-explorer'] }, async function ({ app, python }) { +test('Data Explorer 100x100 - Python - Polars [C674520]', { + tag: [tags.WIN, tags.DATA_EXPLORER] +}, async function ({ app, python }) { test.slow(); const dataFrameName = 'polars100x100'; diff --git a/test/e2e/features/data-explorer/100x100-r.test.ts b/test/e2e/features/data-explorer/100x100-r.test.ts index 525feb05001..1a4142487d2 100644 --- a/test/e2e/features/data-explorer/100x100-r.test.ts +++ b/test/e2e/features/data-explorer/100x100-r.test.ts @@ -3,7 +3,7 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test } from '../_test.setup'; +import { test, tags } from '../_test.setup'; import { join } from 'path'; import { parquetFilePath, testDataExplorer } from './helpers/100x100'; @@ -11,7 +11,9 @@ test.use({ suiteId: __filename }); -test('Data Explorer 100x100 - R [C674521]', { tag: ['@win', '@data-explorer'] }, async function ({ app, r }) { +test('Data Explorer 100x100 - R [C674521]', { + tag: [tags.WIN, tags.DATA_EXPLORER] +}, async function ({ app, r }) { test.slow(); // Test the data explorer. diff --git a/test/e2e/features/data-explorer/data-explorer-headless.test.ts b/test/e2e/features/data-explorer/data-explorer-headless.test.ts index 9f5c5a37f2f..f9a6eac1cf2 100644 --- a/test/e2e/features/data-explorer/data-explorer-headless.test.ts +++ b/test/e2e/features/data-explorer/data-explorer-headless.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { join } from 'path'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; import { Application, Logger } from '../../../automation'; test.use({ @@ -12,7 +12,7 @@ test.use({ }); test.describe('Headless Data Explorer - Large Data Frame', { - tag: ['@web', '@data-explorer', '@duck-db'] + tag: [tags.WEB, tags.DATA_EXPLORER, tags.DUCK_DB] }, () => { // python fixture not actually needed but serves as a long wait so that we can be sure // headless/duckdb open will work diff --git a/test/e2e/features/data-explorer/data-explorer-python-pandas.test.ts b/test/e2e/features/data-explorer/data-explorer-python-pandas.test.ts index 25f1bf0c7dc..837b5faabaa 100644 --- a/test/e2e/features/data-explorer/data-explorer-python-pandas.test.ts +++ b/test/e2e/features/data-explorer/data-explorer-python-pandas.test.ts @@ -4,14 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { join } from 'path'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); test.describe('Data Explorer - Python Pandas', { - tag: ['@web', '@win', '@pr', '@data-explorer'] + tag: [tags.WEB, tags.WIN, tags.CRITICAL, tags.DATA_EXPLORER] }, () => { test('Python Pandas - Verifies basic data explorer functionality [C557556]', async function ({ app, python, logger }) { // modified snippet from https://www.geeksforgeeks.org/python-pandas-dataframe/ diff --git a/test/e2e/features/data-explorer/data-explorer-python-polars.test.ts b/test/e2e/features/data-explorer/data-explorer-python-polars.test.ts index 9edd59f9534..5044fe78793 100644 --- a/test/e2e/features/data-explorer/data-explorer-python-polars.test.ts +++ b/test/e2e/features/data-explorer/data-explorer-python-polars.test.ts @@ -4,14 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { join } from 'path'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); test.describe('Data Explorer - Python Polars', { - tag: ['@win', '@web', '@pr', '@data-explorer'] + tag: [tags.WIN, tags.WEB, tags.CRITICAL, tags.DATA_EXPLORER] }, () => { test('Python Polars - Verifies basic data explorer functionality [C644538]', async function ({ app, python, logger }) { await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'workspaces', 'polars-dataframe-py', 'polars_basic.py')); diff --git a/test/e2e/features/data-explorer/data-explorer-r.test.ts b/test/e2e/features/data-explorer/data-explorer-r.test.ts index bf4ac265251..0772ceafc49 100644 --- a/test/e2e/features/data-explorer/data-explorer-r.test.ts +++ b/test/e2e/features/data-explorer/data-explorer-r.test.ts @@ -3,16 +3,16 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); test.describe('Data Explorer - R ', { - tag: ['@web', '@win', '@data-explorer'] + tag: [tags.WEB, tags.WIN, tags.DATA_EXPLORER] }, () => { - test('R - Verifies basic data explorer functionality [C609620]', { tag: ['@pr'] }, async function ({ app, r, logger }) { + test('R - Verifies basic data explorer functionality [C609620]', { tag: [tags.CRITICAL] }, async function ({ app, r, logger }) { // snippet from https://www.w3schools.com/r/r_data_frames.asp const script = `Data_Frame <- data.frame ( Training = c("Strength", "Stamina", "Other"), @@ -45,7 +45,7 @@ test.describe('Data Explorer - R ', { }); test('R - Verifies basic data explorer column info functionality [C734265]', { - tag: ['@pr'] + tag: [tags.CRITICAL] }, async function ({ app, r }) { await app.workbench.positronDataExplorer.expandSummary(); diff --git a/test/e2e/features/data-explorer/duckdb-sparklines.test.ts b/test/e2e/features/data-explorer/duckdb-sparklines.test.ts index 66aa57625cd..a30919d1f7e 100644 --- a/test/e2e/features/data-explorer/duckdb-sparklines.test.ts +++ b/test/e2e/features/data-explorer/duckdb-sparklines.test.ts @@ -4,14 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { join } from 'path'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); test.describe('Data Explorer - DuckDB Column Summary', { - tag: ['@web', '@win', '@pr', '@data-explorer', '@duck-db'] + tag: [tags.WEB, tags.WIN, tags.CRITICAL, tags.DATA_EXPLORER, tags.DUCK_DB] }, () => { // python fixture not actually needed but serves as a long wait so that we can be sure // headless/duckdb open will work diff --git a/test/e2e/features/data-explorer/large-data-frame.test.ts b/test/e2e/features/data-explorer/large-data-frame.test.ts index 18ec4c85577..f37ef631ef9 100644 --- a/test/e2e/features/data-explorer/large-data-frame.test.ts +++ b/test/e2e/features/data-explorer/large-data-frame.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { join } from 'path'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; const LAST_CELL_CONTENTS = '2013-09-30 08:00:00'; const FILTER_PARAMS = ['distance', 'is equal to', '2586']; @@ -15,7 +15,7 @@ test.use({ }); test.describe('Data Explorer - Large Data Frame', { - tag: ['@pr', '@web', '@win', '@data-explorer'] + tag: [tags.CRITICAL, tags.WEB, tags.WIN, tags.DATA_EXPLORER] }, () => { test.beforeEach(async function ({ app }) { await app.workbench.positronLayouts.enterLayout('stacked'); @@ -58,7 +58,7 @@ test.describe('Data Explorer - Large Data Frame', { }); test('R - Verifies data explorer functionality with large data frame [C557554]', { - tag: ['@web', '@pr'] + tag: [tags.WEB, tags.CRITICAL] }, async function ({ app, logger, r }) { await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'workspaces', 'nyc-flights-data-r', 'flights-data-frame.r')); await app.workbench.quickaccess.runCommand('r.sourceCurrentFile'); diff --git a/test/e2e/features/data-explorer/sparklines.test.ts b/test/e2e/features/data-explorer/sparklines.test.ts index 7c0e0a177f2..51a75a55409 100644 --- a/test/e2e/features/data-explorer/sparklines.test.ts +++ b/test/e2e/features/data-explorer/sparklines.test.ts @@ -4,14 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { Application } from '../../../automation'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); test.describe('Data Explorer - Sparklines', { - tag: ['@web', '@win', '@data-explorer'] + tag: [tags.WEB, tags.WIN, tags.DATA_EXPLORER] }, () => { test.beforeEach(async function ({ app }) { diff --git a/test/e2e/features/data-explorer/very-large-data-frame.test.ts b/test/e2e/features/data-explorer/very-large-data-frame.test.ts index 44cc8602b53..49ba79c9d75 100644 --- a/test/e2e/features/data-explorer/very-large-data-frame.test.ts +++ b/test/e2e/features/data-explorer/very-large-data-frame.test.ts @@ -6,7 +6,7 @@ import { fail } from 'assert'; import { join } from 'path'; import { downloadFileFromS3, S3FileDownloadOptions } from '../../../automation'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename @@ -19,7 +19,7 @@ const objectKey = "largeParquet.parquet"; const githubActions = process.env.GITHUB_ACTIONS === "true"; -test.describe('Data Explorer - Very Large Data Frame', { tag: ['@win', '@data-explorer'] }, () => { +test.describe('Data Explorer - Very Large Data Frame', { tag: [tags.WIN, tags.DATA_EXPLORER] }, () => { test.beforeAll(async function ({ app }) { if (githubActions) { const localFilePath = join(app.workspacePathOrFolder, "data-files", objectKey); diff --git a/test/e2e/features/data-explorer/xlsx-data-frame.test.ts b/test/e2e/features/data-explorer/xlsx-data-frame.test.ts index 062097c2ba8..5b664e6c808 100644 --- a/test/e2e/features/data-explorer/xlsx-data-frame.test.ts +++ b/test/e2e/features/data-explorer/xlsx-data-frame.test.ts @@ -4,14 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { join } from 'path'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); test.describe('Data Explorer - XLSX', { - tag: ['@web', '@win', '@data-explorer'] + tag: [tags.WEB, tags.WIN, tags.DATA_EXPLORER] }, () => { test.afterEach(async function ({ app }) { diff --git a/test/e2e/features/editor/fast-execution.test.ts b/test/e2e/features/editor/fast-execution.test.ts index 83e9c7ffe71..82265472278 100644 --- a/test/e2e/features/editor/fast-execution.test.ts +++ b/test/e2e/features/editor/fast-execution.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { join } from 'path'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename @@ -12,7 +12,7 @@ test.use({ const FILENAME = 'fast-execution.r'; -test.describe('R Fast Execution', { tag: ['@web', '@editor'] }, () => { +test.describe('R Fast Execution', { tag: [tags.WEB, tags.EDITOR] }, () => { test('Verify fast execution is not out of order [C712539]', async function ({ app, r }) { await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'workspaces', 'fast-statement-execution', FILENAME)); diff --git a/test/e2e/features/help/f1.test.ts b/test/e2e/features/help/f1.test.ts index 703e9c9df29..d14e48aa529 100644 --- a/test/e2e/features/help/f1.test.ts +++ b/test/e2e/features/help/f1.test.ts @@ -4,15 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import { join } from 'path'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); -test.describe('F1 Help #web #win', { - tag: ['@web', '@win', '@help'] +test.describe('F1 Help', { + tag: [tags.WEB, tags.WIN, tags.HELP] }, () => { test.afterEach(async function ({ app }) { diff --git a/test/e2e/features/help/help.test.ts b/test/e2e/features/help/help.test.ts index dfd3f826a39..e7d710b6928 100644 --- a/test/e2e/features/help/help.test.ts +++ b/test/e2e/features/help/help.test.ts @@ -3,13 +3,13 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); -test.describe('Help', { tag: ['@help'] }, () => { +test.describe('Help', { tag: [tags.HELP] }, () => { test('Python - Verifies basic help functionality [C633814]', async function ({ app, python }) { await app.workbench.positronConsole.executeCode('Python', `?load`, '>>>'); diff --git a/test/e2e/features/layouts/layouts.test.ts b/test/e2e/features/layouts/layouts.test.ts index 1f92cca4494..8d08990e1be 100644 --- a/test/e2e/features/layouts/layouts.test.ts +++ b/test/e2e/features/layouts/layouts.test.ts @@ -3,13 +3,13 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); -test.describe('Layouts', { tag: ['@web', '@layouts'] }, () => { +test.describe('Layouts', { tag: [tags.WEB, tags.LAYOUTS] }, () => { test.describe('Stacked Layout', () => { diff --git a/test/e2e/features/new-project-wizard/new-project-python.test.ts b/test/e2e/features/new-project-wizard/new-project-python.test.ts index bded9b76400..42e61411100 100644 --- a/test/e2e/features/new-project-wizard/new-project-python.test.ts +++ b/test/e2e/features/new-project-wizard/new-project-python.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { PositronPythonFixtures, ProjectType, ProjectWizardNavigateAction } from '../../../automation'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename @@ -14,7 +14,7 @@ test.beforeEach(async function ({ app }) { await app.workbench.positronConsole.waitForReadyOrNoInterpreter(); }); -test.describe('Python - New Project Wizard', { tag: ['@new-project-wizard'] }, () => { +test.describe('Python - New Project Wizard', { tag: [tags.NEW_PROJECT_WIZARD] }, () => { const defaultProjectName = 'my-python-project'; test('Create a new Conda environment [C628628]', async function ({ app, page }) { @@ -43,7 +43,7 @@ test.describe('Python - New Project Wizard', { tag: ['@new-project-wizard'] }, ( await app.workbench.quickaccess.runCommand('workbench.action.toggleAuxiliaryBar'); }); - test('Create a new Venv environment [C627912]', { tag: ['@pr'] }, async function ({ app, page }) { + test('Create a new Venv environment [C627912]', { tag: [tags.CRITICAL] }, async function ({ app, page }) { // This is the default behavior for a new Python Project in the Project Wizard const projSuffix = addRandomNumSuffix('_new_venv'); const pw = app.workbench.positronNewProjectWizard; @@ -149,7 +149,7 @@ test.describe('Python - New Project Wizard', { tag: ['@new-project-wizard'] }, ( await app.workbench.quickaccess.runCommand('workbench.action.toggleAuxiliaryBar'); }); - test('Default Python Project with git init [C674522]', { tag: ['@pr', '@win'] }, async function ({ app, page }) { + test('Default Python Project with git init [C674522]', { tag: [tags.CRITICAL, tags.WIN] }, async function ({ app, page }) { const projSuffix = addRandomNumSuffix('_gitInit'); const pw = app.workbench.positronNewProjectWizard; await pw.startNewProject(ProjectType.PYTHON_PROJECT); diff --git a/test/e2e/features/new-project-wizard/new-project-r-jupyter.test.ts b/test/e2e/features/new-project-wizard/new-project-r-jupyter.test.ts index c039e720f43..c96cba1f260 100644 --- a/test/e2e/features/new-project-wizard/new-project-r-jupyter.test.ts +++ b/test/e2e/features/new-project-wizard/new-project-r-jupyter.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { ProjectType, ProjectWizardNavigateAction } from '../../../automation'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename @@ -15,12 +15,12 @@ test.beforeEach(async function ({ app }) { await app.workbench.positronLayouts.enterLayout("stacked"); }); -test.describe('R - New Project Wizard', { tag: ['@new-project-wizard'] }, () => { +test.describe('R - New Project Wizard', { tag: [tags.NEW_PROJECT_WIZARD] }, () => { test.describe.configure({ mode: 'serial' }); const defaultProjectName = 'my-r-project'; - test('R - Project Defaults [C627913]', { tag: ['@pr', '@win'] }, async function ({ app }) { + test('R - Project Defaults [C627913]', { tag: [tags.CRITICAL, tags.WIN] }, async function ({ app }) { const projSuffix = addRandomNumSuffix('_defaults'); const pw = app.workbench.positronNewProjectWizard; await pw.startNewProject(ProjectType.R_PROJECT); @@ -138,7 +138,7 @@ test.describe('R - New Project Wizard', { tag: ['@new-project-wizard'] }, () => test.describe('Jupyter - New Project Wizard', () => { const defaultProjectName = 'my-jupyter-notebook'; - test('Jupyter Project Defaults [C629352]', { tag: ['@pr'] }, async function ({ app }) { + test('Jupyter Project Defaults [C629352]', { tag: [tags.CRITICAL] }, async function ({ app }) { const projSuffix = addRandomNumSuffix('_defaults'); const pw = app.workbench.positronNewProjectWizard; await pw.startNewProject(ProjectType.JUPYTER_NOTEBOOK); diff --git a/test/e2e/features/notebook/notebook-create.test.ts b/test/e2e/features/notebook/notebook-create.test.ts index 7e1594ab917..a3948669cc4 100644 --- a/test/e2e/features/notebook/notebook-create.test.ts +++ b/test/e2e/features/notebook/notebook-create.test.ts @@ -3,13 +3,15 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test } from '../_test.setup'; +import { test, tags } from '../_test.setup'; test.use({ suiteId: __filename }); -test.describe('Notebooks', { tag: ['@pr', '@web', '@win', '@notebook'] }, () => { +test.describe('Notebooks', { + tag: [tags.CRITICAL, tags.WEB, tags.WIN, tags.NOTEBOOK] +}, () => { test.describe('Python Notebooks', () => { test.beforeEach(async function ({ app, python }) { await app.workbench.positronLayouts.enterLayout('notebook'); diff --git a/test/e2e/features/notebook/notebook-large-python.test.ts b/test/e2e/features/notebook/notebook-large-python.test.ts index 35b6b8d6d17..6e1b862b158 100644 --- a/test/e2e/features/notebook/notebook-large-python.test.ts +++ b/test/e2e/features/notebook/notebook-large-python.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { join } from 'path'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename, @@ -12,7 +12,9 @@ test.use({ }); // Note that this test is too heavy to pass on web and windows -test.describe('Large Python Notebook', { tag: ['@notebook'] }, () => { +test.describe('Large Python Notebook', { + tag: [tags.NOTEBOOK] +}, () => { test('Python - Large notebook execution [C983592]', async function ({ app, python }) { test.setTimeout(480_000); // huge timeout because this is a heavy test @@ -22,11 +24,7 @@ test.describe('Large Python Notebook', { tag: ['@notebook'] }, () => { await app.workbench.positronQuickaccess.openDataFile(join(app.workspacePathOrFolder, 'workspaces', 'large_py_notebook', 'spotify.ipynb')); await notebooks.selectInterpreter('Python Environments', process.env.POSITRON_PY_VER_SEL!); - await app.code.driver.page.getByLabel('Run All').click(); - - const stopExecutionLocator = app.code.driver.page.locator('a').filter({ hasText: /Stop Execution|Interrupt/ }); - await expect(stopExecutionLocator).toBeVisible(); - await expect(stopExecutionLocator).not.toBeVisible({ timeout: 120000 }); + await notebooks.runAllCells(120000); await app.workbench.quickaccess.runCommand('notebook.focusTop'); await app.code.driver.page.locator('span').filter({ hasText: 'import pandas as pd' }).locator('span').first().click(); diff --git a/test/e2e/features/outline/outline.test.ts b/test/e2e/features/outline/outline.test.ts index 16fe6d37a31..0f30ddcc898 100644 --- a/test/e2e/features/outline/outline.test.ts +++ b/test/e2e/features/outline/outline.test.ts @@ -4,14 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { join } from 'path'; -import { test } from '../_test.setup'; +import { test, tags } from '../_test.setup'; test.use({ suiteId: __filename }); test.describe('Outline #web #win', { - tag: ['@web', '@win', '@outline'] + tag: [tags.WEB, tags.WIN, tags.OUTLINE] }, () => { test('Python - Verify Outline Contents [C956870]', async function ({ app, python }) { diff --git a/test/e2e/features/output/console-ouput-log.test.ts b/test/e2e/features/output/console-ouput-log.test.ts index b10ef5c1058..ed682287f18 100644 --- a/test/e2e/features/output/console-ouput-log.test.ts +++ b/test/e2e/features/output/console-ouput-log.test.ts @@ -3,13 +3,13 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test } from '../_test.setup'; +import { test, tags } from '../_test.setup'; test.use({ suiteId: __filename }); -test.describe('Console Output Log', { tag: ['@web', '@output', '@console'] }, () => { +test.describe('Console Output Log', { tag: [tags.WEB, tags.OUTPUT, tags.CONSOLE] }, () => { test.beforeEach(async function ({ app }) { await app.workbench.positronLayouts.enterLayout('stacked'); }); diff --git a/test/e2e/features/plots/matplotlib-interact.test.ts b/test/e2e/features/plots/matplotlib-interact.test.ts new file mode 100644 index 00000000000..1f5931d7cee --- /dev/null +++ b/test/e2e/features/plots/matplotlib-interact.test.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { join } from 'path'; +import { tags, test } from '../_test.setup'; +import { expect } from '@playwright/test'; + +test.use({ + suiteId: __filename +}); + +test.describe('Matplotlib Interact', { tag: [tags.PLOTS, tags.NOTEBOOK] }, () => { + + test('Python - Matplotlib Interact Test [C1067443]', { + tag: [tags.CRITICAL, tags.WEB, tags.WIN], + }, async function ({ app, python }) { + + const notebooks = app.workbench.positronNotebooks; + + await app.workbench.positronQuickaccess.openDataFile(join(app.workspacePathOrFolder, 'workspaces', 'matplotlib', 'interact.ipynb')); + + await notebooks.selectInterpreter('Python Environments', process.env.POSITRON_PY_VER_SEL!); + + await notebooks.runAllCells(); + + await app.workbench.quickaccess.runCommand('workbench.action.togglePanel'); + + const plotLocator = app.workbench.positronNotebooks.frameLocator.locator('.widget-output'); + + const plotImageLocator = plotLocator.locator('img'); + + const imgSrcBefore = await plotImageLocator.getAttribute('src'); + + const sliders = await app.workbench.positronNotebooks.frameLocator.locator('.slider-container .slider').all(); + + for (const slider of sliders) { + await slider.hover(); + await slider.click(); + } + + const imgSrcAfter = await plotImageLocator.getAttribute('src'); + + expect(imgSrcBefore).not.toBe(imgSrcAfter); + + }); + +}); diff --git a/test/e2e/features/plots/plots.test.ts b/test/e2e/features/plots/plots.test.ts index f0a1d8dc285..69640fc2943 100644 --- a/test/e2e/features/plots/plots.test.ts +++ b/test/e2e/features/plots/plots.test.ts @@ -4,18 +4,18 @@ *--------------------------------------------------------------------------------------------*/ import * as path from 'path'; +import { test, expect, tags } from '../_test.setup'; const compareImages = require('resemblejs/compareImages'); import { ComparisonOptions } from 'resemblejs'; import * as fs from 'fs'; import { fail } from 'assert'; -import { test, expect } from '../_test.setup'; import { Application } from '../../../automation'; test.use({ suiteId: __filename }); -test.describe('Plots', { tag: ['@plots', '@editor'] }, () => { +test.describe('Plots', { tag: [tags.PLOTS, tags.EDITOR] }, () => { // Some tests are not tagged @win because they woould require a new master image. test.describe('Python Plots', () => { @@ -40,7 +40,7 @@ test.describe('Plots', { tag: ['@plots', '@editor'] }, () => { }); test('Python - Verifies basic plot functionality - Dynamic Plot [C608114]', { - tag: ['@pr', '@web'] + tag: [tags.CRITICAL, tags.WEB] }, async function ({ app, logger, headless }) { // modified snippet from https://www.geeksforgeeks.org/python-pandas-dataframe/ logger.log('Sending code to console'); @@ -86,7 +86,7 @@ test.describe('Plots', { tag: ['@plots', '@editor'] }, () => { await app.workbench.positronPlots.waitForNoPlots(); }); - test('Python - Verifies basic plot functionality - Static Plot [C654401]', { tag: ['@pr', '@web'] }, async function ({ app, logger }) { + test('Python - Verifies basic plot functionality - Static Plot [C654401]', { tag: [tags.CRITICAL, tags.WEB] }, async function ({ app, logger }) { logger.log('Sending code to console'); await app.workbench.positronConsole.executeCode('Python', pythonStaticPlot, '>>>'); await app.workbench.positronPlots.waitForCurrentStaticPlot(); @@ -114,7 +114,7 @@ test.describe('Plots', { tag: ['@plots', '@editor'] }, () => { }); - test('Python - Verifies the plots pane action bar - Plot actions [C656297]', { tag: ['@web', '@win'] }, async function ({ app }) { + test('Python - Verifies the plots pane action bar - Plot actions [C656297]', { tag: [tags.WEB, tags.WIN] }, async function ({ app }) { const plots = app.workbench.positronPlots; // default plot pane state for action bar @@ -161,7 +161,7 @@ test.describe('Plots', { tag: ['@plots', '@editor'] }, () => { await expect(plots.plotSizeButton).not.toBeDisabled(); }); - test('Python - Verifies saving a Python plot [C557005]', { tag: ['@win'] }, async function ({ app, logger }) { + test('Python - Verifies saving a Python plot [C557005]', { tag: [tags.WIN] }, async function ({ app, logger }) { logger.log('Sending code to console'); await app.workbench.positronConsole.executeCode('Python', savePlot, '>>>'); await app.workbench.positronPlots.waitForCurrentPlot(); @@ -172,23 +172,23 @@ test.describe('Plots', { tag: ['@plots', '@editor'] }, () => { await app.workbench.positronExplorer.waitForProjectFileToAppear('Python-scatter.jpeg'); }); - test('Python - Verifies bqplot Python widget [C720869]', { tag: ['@web', '@win'] }, async function ({ app }) { + test('Python - Verifies bqplot Python widget [C720869]', { tag: [tags.WEB, tags.WIN] }, async function ({ app }) { await runScriptAndValidatePlot(app, bgplot, '.svg-figure'); }); - test('Python - Verifies ipydatagrid Python widget [C720870]', { tag: ['@web', '@win'] }, async function ({ app }) { + test('Python - Verifies ipydatagrid Python widget [C720870]', { tag: [tags.WEB, tags.WIN] }, async function ({ app }) { await runScriptAndValidatePlot(app, ipydatagrid, 'canvas:nth-child(1)'); }); - test('Python - Verifies ipyleaflet Python widget [C720871]', { tag: ['@web', '@win'] }, async function ({ app }) { + test('Python - Verifies ipyleaflet Python widget [C720871]', { tag: [tags.WEB, tags.WIN] }, async function ({ app }) { await runScriptAndValidatePlot(app, ipyleaflet, '.leaflet-container'); }); - test('Python - Verifies hvplot can load with plotly extension [C766660]', { tag: ['@web', '@win'] }, async function ({ app }) { + test('Python - Verifies hvplot can load with plotly extension [C766660]', { tag: [tags.WEB, tags.WIN] }, async function ({ app }) { await runScriptAndValidatePlot(app, plotly, '.plotly'); }); - test('Python - Verifies ipytree Python widget [C720872]', { tag: ['@web', '@win'] }, async function ({ app }) { + test('Python - Verifies ipytree Python widget [C720872]', { tag: [tags.WEB, tags.WIN] }, async function ({ app }) { await runScriptAndValidatePlot(app, ipytree, '.jstree-container-ul'); // fullauxbar layout needed for some smaller windows @@ -203,7 +203,7 @@ test.describe('Plots', { tag: ['@plots', '@editor'] }, () => { await expect(treeNodes).toHaveCount(3); }); - test('Python - Verifies ipywidget.Output Python widget', { tag: ['@web', '@win'] }, async function ({ app }) { + test('Python - Verifies ipywidget.Output Python widget', { tag: [tags.WEB, tags.WIN] }, async function ({ app }) { await app.workbench.positronConsole.pasteCodeToConsole(ipywidgetOutput); await app.workbench.positronConsole.sendEnterKey(); await app.workbench.positronPlots.waitForWebviewPlot('.widget-output', 'attached'); @@ -220,7 +220,7 @@ test.describe('Plots', { tag: ['@plots', '@editor'] }, () => { expect(lines).not.toContain('Hello, world!'); }); - test('Python - Verifies bokeh Python widget [C730343]', { tag: ['@web', '@win'] }, async function ({ app }) { + test('Python - Verifies bokeh Python widget [C730343]', { tag: [tags.WEB, tags.WIN] }, async function ({ app }) { await app.workbench.positronConsole.pasteCodeToConsole(bokeh); await app.workbench.positronConsole.sendEnterKey(); @@ -278,7 +278,7 @@ test.describe('Plots', { tag: ['@plots', '@editor'] }, () => { await app.workbench.positronPlots.waitForNoPlots(); }); - test('R - Verifies basic plot functionality [C628633]', { tag: ['@pr', '@web'] }, async function ({ app, logger, headless }) { + test('R - Verifies basic plot functionality [C628633]', { tag: [tags.CRITICAL, tags.WEB] }, async function ({ app, logger, headless }) { logger.log('Sending code to console'); await app.workbench.positronConsole.executeCode('R', rBasicPlot, '>'); await app.workbench.positronPlots.waitForCurrentPlot(); @@ -321,7 +321,7 @@ test.describe('Plots', { tag: ['@plots', '@editor'] }, () => { await app.workbench.positronPlots.waitForNoPlots(); }); - test('R - Verifies saving an R plot [C557006]', { tag: ['@win'] }, async function ({ app, logger }) { + test('R - Verifies saving an R plot [C557006]', { tag: [tags.WIN] }, async function ({ app, logger }) { logger.log('Sending code to console'); await app.workbench.positronConsole.executeCode('R', rSavePlot, '>'); @@ -334,21 +334,21 @@ test.describe('Plots', { tag: ['@plots', '@editor'] }, () => { await app.workbench.positronExplorer.waitForProjectFileToAppear('R-cars.svg'); }); - test('R - Verifies rplot plot [C720873]', { tag: ['@web', '@win'] }, async function ({ app }) { + test('R - Verifies rplot plot [C720873]', { tag: [tags.WEB, tags.WIN] }, async function ({ app }) { await app.workbench.positronConsole.pasteCodeToConsole(rplot); await app.workbench.positronConsole.sendEnterKey(); await app.workbench.positronPlots.waitForCurrentPlot(); }); - test('R - Verifies highcharter plot [C720874]', { tag: ['@web', '@win'] }, async function ({ app }) { + test('R - Verifies highcharter plot [C720874]', { tag: [tags.WEB, tags.WIN] }, async function ({ app }) { await runScriptAndValidatePlot(app, highcharter, 'svg', app.web); }); - test('R - Verifies leaflet plot [C720875]', { tag: ['@web', '@win'] }, async function ({ app }) { + test('R - Verifies leaflet plot [C720875]', { tag: [tags.WEB, tags.WIN] }, async function ({ app }) { await runScriptAndValidatePlot(app, leaflet, '.leaflet', app.web); }); - test('R - Verifies plotly plot [C720876]', { tag: ['@web', '@win'] }, async function ({ app }) { + test('R - Verifies plotly plot [C720876]', { tag: [tags.WEB, tags.WIN] }, async function ({ app }) { await runScriptAndValidatePlot(app, rPlotly, '.plot-container', app.web); }); }); diff --git a/test/e2e/features/quarto/quarto.test.ts b/test/e2e/features/quarto/quarto.test.ts index d20399f4ffb..0c00b705ae0 100644 --- a/test/e2e/features/quarto/quarto.test.ts +++ b/test/e2e/features/quarto/quarto.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Application } from '../../../automation'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; const path = require('path'); const fs = require('fs-extra'); @@ -14,7 +14,7 @@ test.use({ suiteId: __filename }); -test.describe('Quarto', { tag: ['@web', '@quarto'] }, () => { +test.describe('Quarto', { tag: [tags.WEB, tags.QUARTO] }, () => { test.beforeAll(async function ({ app, browserName }) { await app.workbench.quickaccess.openFile(path.join(app.workspacePathOrFolder, 'workspaces', 'quarto_basic', 'quarto_basic.qmd')); isWeb = browserName === 'chromium'; diff --git a/test/e2e/features/r-markdown/r-markdown.test.ts b/test/e2e/features/r-markdown/r-markdown.test.ts index b61ff25424b..d585cfe5882 100644 --- a/test/e2e/features/r-markdown/r-markdown.test.ts +++ b/test/e2e/features/r-markdown/r-markdown.test.ts @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { join } from 'path'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); -test.describe('R Markdown', { tag: ['@web', '@r-markdown'] }, () => { +test.describe('R Markdown', { tag: [tags.WEB, tags.R_MARKDOWN] }, () => { test('Render R Markdown [C680618]', async function ({ app, r }) { await app.workbench.quickaccess.openFile(join(app.workspacePathOrFolder, 'workspaces', 'basic-rmd-file', 'basicRmd.rmd')); diff --git a/test/e2e/features/r-pkg-development/r-pkg-development.test.ts b/test/e2e/features/r-pkg-development/r-pkg-development.test.ts index 198e80a05d8..47cac9041c4 100644 --- a/test/e2e/features/r-pkg-development/r-pkg-development.test.ts +++ b/test/e2e/features/r-pkg-development/r-pkg-development.test.ts @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import path = require('path'); -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); -test.describe('R Package Development', { tag: ['@web', '@r-pkg-development'] }, () => { +test.describe('R Package Development', { tag: [tags.WEB, tags.R_PKG_DEVELOPMENT] }, () => { test.beforeAll(async function ({ app, r, userSettings }) { try { // don't use native file picker diff --git a/test/e2e/features/reticulate/reticulate.test.ts b/test/e2e/features/reticulate/reticulate.test.ts index ca866ea4e4b..c25f58aa9a4 100644 --- a/test/e2e/features/reticulate/reticulate.test.ts +++ b/test/e2e/features/reticulate/reticulate.test.ts @@ -3,7 +3,7 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename @@ -14,7 +14,7 @@ test.use({ // to the installed python path test.describe('Reticulate', { - tag: ['@web', '@reticulate'], + tag: [tags.WEB, tags.RETICULATE], annotation: [{ type: 'issue', description: 'https://github.com/posit-dev/positron/issues/5226' }] }, () => { test.beforeAll(async function ({ app, userSettings }) { diff --git a/test/e2e/features/test-explorer/test-explorer.test.ts b/test/e2e/features/test-explorer/test-explorer.test.ts index 5a75d49b4c1..40fb43499fd 100644 --- a/test/e2e/features/test-explorer/test-explorer.test.ts +++ b/test/e2e/features/test-explorer/test-explorer.test.ts @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import path = require('path'); -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); -test.describe('Test Explorer', { tag: ['@test-explorer'] }, () => { +test.describe('Test Explorer', { tag: [tags.TEST_EXPLORER] }, () => { test.beforeAll(async function ({ app, r, userSettings }) { try { // don't use native file picker diff --git a/test/e2e/features/top-action-bar/interpreter-dropdown.test.ts b/test/e2e/features/top-action-bar/interpreter-dropdown.test.ts index 037746e7965..7e08f4268d7 100644 --- a/test/e2e/features/top-action-bar/interpreter-dropdown.test.ts +++ b/test/e2e/features/top-action-bar/interpreter-dropdown.test.ts @@ -8,13 +8,13 @@ import { PositronInterpreterDropdown, } from '../../../automation'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); -test.describe.skip('Interpreter Dropdown in Top Action Bar', { tag: ['@web', '@top-action-bar'] }, () => { +test.describe.skip('Interpreter Dropdown in Top Action Bar', { tag: [tags.WEB, tags.TOP_ACTION_BAR] }, () => { let interpreterDropdown: PositronInterpreterDropdown; let positronConsole: PositronConsole; diff --git a/test/e2e/features/top-action-bar/top-action-bar-save.test.ts b/test/e2e/features/top-action-bar/top-action-bar-save.test.ts index b3fee5f5173..18c5db8fbd9 100644 --- a/test/e2e/features/top-action-bar/top-action-bar-save.test.ts +++ b/test/e2e/features/top-action-bar/top-action-bar-save.test.ts @@ -4,14 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { join } from 'path'; -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); test.describe('Top Action Bar - Save Actions', { - tag: ['@web', '@top-action-bar'] + tag: [tags.WEB, tags.TOP_ACTION_BAR] }, () => { test.beforeAll(async function ({ app, userSettings }) { diff --git a/test/e2e/features/variables/variables-expanded.test.ts b/test/e2e/features/variables/variables-expanded.test.ts index 155d3142636..0bccaeae186 100644 --- a/test/e2e/features/variables/variables-expanded.test.ts +++ b/test/e2e/features/variables/variables-expanded.test.ts @@ -3,13 +3,13 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); -test.describe('Variables - Expanded View', { tag: ['@web', '@variables'] }, () => { +test.describe('Variables - Expanded View', { tag: [tags.WEB, tags.VARIABLES] }, () => { test.beforeEach(async function ({ app, python }) { await app.workbench.positronConsole.executeCode('Python', script, '>>>'); await app.workbench.positronLayouts.enterLayout('fullSizedAuxBar'); diff --git a/test/e2e/features/variables/variables-notebook.test.ts b/test/e2e/features/variables/variables-notebook.test.ts index c0d897f0141..e3609c95ac4 100644 --- a/test/e2e/features/variables/variables-notebook.test.ts +++ b/test/e2e/features/variables/variables-notebook.test.ts @@ -3,7 +3,7 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename @@ -14,7 +14,9 @@ test.afterEach(async function ({ app }) { await app.workbench.positronLayouts.enterLayout('stacked'); }); -test.describe('Variables Pane - Notebook', { tag: ['@pr', '@web', '@variables', '@notebook'] }, () => { +test.describe('Variables Pane - Notebook', { + tag: [tags.CRITICAL, tags.WEB, tags.VARIABLES, tags.NOTEBOOK] +}, () => { test('Python - Verifies Variables pane basic function for notebook [C669188]', async function ({ app, python }) { await app.workbench.positronNotebooks.createNewNotebook(); diff --git a/test/e2e/features/variables/variables-pane.test.ts b/test/e2e/features/variables/variables-pane.test.ts index c9b20d40cfa..f988a771ac2 100644 --- a/test/e2e/features/variables/variables-pane.test.ts +++ b/test/e2e/features/variables/variables-pane.test.ts @@ -3,13 +3,15 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); -test.describe('Variables Pane', { tag: ['@web', '@win', '@pr', '@variables'] }, () => { +test.describe('Variables Pane', { + tag: [tags.WEB, tags.WIN, tags.CRITICAL, tags.VARIABLES] +}, () => { test.beforeEach(async function ({ app }) { await app.workbench.positronLayouts.enterLayout('stacked'); }); diff --git a/test/e2e/features/viewer/viewer.test.ts b/test/e2e/features/viewer/viewer.test.ts index c453ddaa24e..e32229610f5 100644 --- a/test/e2e/features/viewer/viewer.test.ts +++ b/test/e2e/features/viewer/viewer.test.ts @@ -3,13 +3,13 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test } from '../_test.setup'; +import { test, tags } from '../_test.setup'; test.use({ suiteId: __filename }); -test.describe('Viewer', { tag: ['@viewer'] }, () => { +test.describe('Viewer', { tag: [tags.VIEWER] }, () => { test.afterEach(async function ({ app }) { await app.workbench.positronViewer.clearViewer(); @@ -61,7 +61,6 @@ test.describe('Viewer', { tag: ['@viewer'] }, () => { test('R - Verify Viewer functionality with reactable [C784930]', async function ({ app, logger, r }) { - logger.log('Sending code to console'); await app.workbench.positronConsole.executeCode('R', rReactableScript, '>'); diff --git a/test/e2e/features/welcome/welcome.test.ts b/test/e2e/features/welcome/welcome.test.ts index ee3c0ac1390..e717ff82a54 100644 --- a/test/e2e/features/welcome/welcome.test.ts +++ b/test/e2e/features/welcome/welcome.test.ts @@ -3,13 +3,13 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { test, expect } from '../_test.setup'; +import { test, expect, tags } from '../_test.setup'; test.use({ suiteId: __filename }); -test.describe('Welcome Page', { tag: ['@welcome'] }, () => { +test.describe('Welcome Page', { tag: [tags.WELCOME] }, () => { test.beforeEach(async function ({ app }) { await app.workbench.quickaccess.runCommand('Help: Welcome'); }); diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index 2d69ebe28fd..1e81e30c14f 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -7,3 +7,4 @@ export { prepareTestEnv } from './test-setup'; export { cloneTestRepo } from './utils'; export { createApp } from './create-app'; export { createLogger } from './logger'; +export { TestTags } from './test-tags'; diff --git a/test/e2e/helpers/test-tags.ts b/test/e2e/helpers/test-tags.ts new file mode 100644 index 00000000000..f941096eb67 --- /dev/null +++ b/test/e2e/helpers/test-tags.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +export enum TestTags { + APPS = '@apps', + CONNECTIONS = '@connections', + CONSOLE = '@console', + CRITICAL = '@critical', + DATA_EXPLORER = '@data-explorer', + DUCK_DB = '@duck-db', + HELP = '@help', + LAYOUTS = '@layouts', + VIEWER = '@viewer', + EDITOR = '@editor', + QUARTO = '@quarto', + NEW_PROJECT_WIZARD = '@new-project-wizard', + NOTEBOOK = '@notebook', + OUTLINE = '@outline', + OUTPUT = '@output', + PLOTS = '@plots', + R_MARKDOWN = '@r-markdown', + R_PKG_DEVELOPMENT = '@r-pkg-development', + RETICULATE = '@reticulate', + TEST_EXPLORER = '@test-explorer', + TOP_ACTION_BAR = '@top-action-bar', + VARIABLES = '@variables', + WEB = '@web', + WELCOME = '@welcome', + WIN = '@win' +}