From 108eda901a65d336d5cf19d2c4fe85fbe0984e5c Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 14 Nov 2026 00:50:58 +0000 Subject: [PATCH 1/7] test: remove Windows races in sess setup R integration tests Wait for the jgd startup profile child to publish completion before reading its status file instead of polling a file the child replaces by rename, which fails on Windows with Permission denied or blocks the rename. Give the parallel sess setup follower and installer fixture budgets that cover a slow Windows source install. --- R/tests/attach_sess.R | 16 +++++++++------ R/tests/prepare-jgd.R | 48 ++++++++++++++++++++++++++++++------------- 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/R/tests/attach_sess.R b/R/tests/attach_sess.R index ea04e4e37..726ce6b42 100644 --- a/R/tests/attach_sess.R +++ b/R/tests/attach_sess.R @@ -356,8 +356,11 @@ writeLines(c( "} else if (mode %in% c('parallel_approve', 'parallel_decline', 'parallel_third',", " 'parallel_failure', 'parallel_retry')) {", " timeout <- 10", + # The follower budget covers the owner's whole source install, which can + # take well over 20 seconds on Windows runners. + " parallel_setup_timeout <- 60", " if (mode == 'parallel_approve') {", - " result <- attach_sess(timeout=timeout, setup_timeout=20)", + " result <- attach_sess(timeout=timeout, setup_timeout=parallel_setup_timeout)", " stopifnot(identical(result, TRUE), 'sess' %in% loadedNamespaces())", " stopifnot(identical(sess_loaded_source_revision(), revision))", " actual <- getNamespaceInfo(asNamespace('sess'), 'path')", @@ -370,7 +373,7 @@ writeLines(c( " stopifnot(!file.exists(ready_path), !dir.exists(lock_path))", " stopifnot(same_paths(.libPaths(), ordinary))", " } else {", - " result <- attach_sess(timeout=timeout, setup_timeout=20)", + " result <- attach_sess(timeout=timeout, setup_timeout=parallel_setup_timeout)", " stopifnot(identical(result, FALSE), !('sess' %in% loadedNamespaces()))", " if (mode == 'parallel_retry') stopifnot(file.exists(file.path(managed_library, 'sess', 'DESCRIPTION')))", " else stopifnot(!file.exists(file.path(managed_library, 'sess', 'DESCRIPTION'))) ", @@ -772,7 +775,8 @@ start_parallel_child <- function(case_root, mode, role, result_file, install_hel } wait_for_child <- function(paths, description) { - deadline <- Sys.time() + 30 + # Must exceed the parallel follower's setup budget so its own diagnostic wins. + deadline <- Sys.time() + 90 while (!file.exists(paths$result) && Sys.time() < deadline) Sys.sleep(0.01) child_output <- read_child_logs(paths) if (!file.exists(paths$result)) { @@ -818,7 +822,7 @@ run_parallel_setup <- function(approve) { " utils::install.packages(pkg_path, repos=NULL, type='source', lib=library, quiet=TRUE)", paste0(" if (!file.exists(file.path(library, 'sess', 'DESCRIPTION'))) stop('install failed')"), paste0(" file.create(", r_literal(description_visible), ")"), - paste0(" deadline <- Sys.time() + 20; release <- ", r_literal(install_release)), + paste0(" deadline <- Sys.time() + 60; release <- ", r_literal(install_release)), " while (!file.exists(release) && Sys.time() < deadline) Sys.sleep(0.01)", " if (!file.exists(release)) stop('installer fixture release timed out')", paste0(" file.create(", r_literal(install_complete), ")"), @@ -839,11 +843,11 @@ run_parallel_setup <- function(approve) { wait_for_file(follower_observed, 10, "follower observation of the held setup lock") if (approve) { - wait_for_file(description_visible, 10, "installed package metadata while owner holds lock") + wait_for_file(description_visible, 60, "installed package metadata while owner holds lock") stopifnot(dir.exists(lock_path), file.exists(file.path(managed_library, "sess", "DESCRIPTION"))) stopifnot(!file.exists(load_log), !file.exists(ready_path)) file.create(install_release) - wait_for_file(install_complete, 10, "installer verification completion") + wait_for_file(install_complete, 60, "installer verification completion") } wait_for_child(owner, paste(mode, "role=owner")) wait_for_child(follower, paste(mode, "role=follower")) diff --git a/R/tests/prepare-jgd.R b/R/tests/prepare-jgd.R index 78bc055b7..5a8d3b46c 100644 --- a/R/tests/prepare-jgd.R +++ b/R/tests/prepare-jgd.R @@ -482,6 +482,11 @@ local({ ), stub_attach) startup_file <- file.path(profile_root, "startup.status") startup_profile <- file.path(profile_root, "run-profile.R") + # Publish completion only after profile.R returns, so the status file is final. + # Polling the status while the child replaces it fails on Windows: an open + # reader blocks the rename and a reader racing the rename gets Permission denied. + profile_done <- file.path(profile_root, "profile-done") + profile_done_temporary <- paste0(profile_done, ".tmp") writeLines(c( paste0("Sys.setenv(VSCODE_R_SESS_PKG_PATH=", r_literal(package), ","), paste0(" VSCODE_R_SESS_SOURCE_HELPER=", r_literal(source_helper), ","), @@ -503,7 +508,10 @@ local({ paste0(" R_PROFILE_USER_OLD=", r_literal(empty_user_profile), ","), " SESS_PLOT_BACKEND='jgd', SESS_RSTUDIOAPI='FALSE', JGD_SOCKET='fixture-socket')", paste0("options(repos=c(CRAN=", r_literal(repo), "), pkgType='source')"), - "source(file.path(getwd(), 'R', 'profile.R'))" + paste0("tryCatch(source(file.path(getwd(), 'R', 'profile.R')), finally = {", + "writeLines('done', ", r_literal(profile_done_temporary), "); ", + "stopifnot(file.rename(", r_literal(profile_done_temporary), ", ", + r_literal(profile_done), ")) })") ), startup_profile) profile_result <- file.path(profile_root, "result") profile_process <- system2_with_child_environment( @@ -514,22 +522,34 @@ local({ ) request <- wait_requests(profile_consent, 1L) answer_requests(request, "decline") - deadline <- Sys.time() + 30 - while (!file.exists(profile_log) && Sys.time() < deadline) Sys.sleep(0.01) - if (!file.exists(profile_log)) stop("Declined startup profile did not connect sess.") + profile_logs <- function() { + # Redirected output can stay open briefly after the done marker on Windows. + unlist(lapply(c("stdout", "stderr"), function(name) { + path <- file.path(profile_root, name) + deadline <- Sys.time() + 10 + repeat { + lines <- suppressWarnings(tryCatch( + if (file.exists(path)) readLines(path, warn = FALSE) else character(), + error = function(error) NULL + )) + if (!is.null(lines)) return(lines) + if (Sys.time() >= deadline) return(paste("could not read", path)) + Sys.sleep(0.1) + } + })) + } + deadline <- Sys.time() + 60 + while (!file.exists(profile_done) && Sys.time() < deadline) Sys.sleep(0.05) + if (!file.exists(profile_done)) { + stop("Declined startup profile did not finish: ", paste(profile_logs(), collapse = "\n")) + } + if (!file.exists(profile_log)) { + stop("Declined startup profile did not connect sess: ", paste(profile_logs(), collapse = "\n")) + } stopifnot(identical(readLines(profile_log), c("fixture-socket", "FALSE", "FALSE"))) startup_status <- readLines(startup_file, warn = FALSE) - deadline <- Sys.time() + 10 - while (length(startup_status) == 6L && startup_status[[6L]] == "pending" && - Sys.time() < deadline) { - Sys.sleep(0.01) - startup_status <- readLines(startup_file, warn = FALSE) - } if (length(startup_status) != 6L || startup_status[[6L]] != "ready") { - logs <- unlist(lapply(c("stdout", "stderr"), function(name) { - path <- file.path(profile_root, name) - if (file.exists(path)) readLines(path, warn = FALSE) else character() - })) + logs <- profile_logs() stop("Unexpected profile startup status: ", paste(startup_status, collapse = " | "), " logs: ", paste(logs, collapse = "\n")) } From b418c73aa05565c74fd30705cc0fd1de188f7988 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 14 Nov 2026 00:55:05 +0000 Subject: [PATCH 2/7] test: count only readiness timers in terminal lifecycle leak checks Fake timers in the extension host also capture timers from VS Code and unrelated extension code, so clock.countTimers() intermittently reported foreign timers. Track timeouts scheduled from the session and terminal modules and assert that those are cleared. --- src/test/common/fakeTimers.ts | 62 +++++++++++++++++++ .../suite/sessionTerminalLifecycle.test.ts | 21 +++++-- 2 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 src/test/common/fakeTimers.ts diff --git a/src/test/common/fakeTimers.ts b/src/test/common/fakeTimers.ts new file mode 100644 index 000000000..21315c027 --- /dev/null +++ b/src/test/common/fakeTimers.ts @@ -0,0 +1,62 @@ +/** + * Tracks timeouts scheduled from specific modules while fake timers are installed. + * + * Fake timers installed in the extension host also capture timers from VS Code + * and other extension code, so `clock.countTimers()` is not a stable leak check. + * Install this after `useFakeTimers()` and before spying on `setTimeout`, and + * call `restore()` before restoring the fake clock. + */ +export interface ModuleTimerTracker { + /** Number of tracked timeouts that have neither fired nor been cleared. */ + pending(): number; + restore(): void; +} + +export function trackModuleTimers(moduleFiles: readonly string[]): ModuleTimerTracker { + const pending = new Set(); + const fakeSetTimeout = globalThis.setTimeout; + const fakeClearTimeout = globalThis.clearTimeout; + const tracked = (( + callback: (...args: unknown[]) => void, + delay?: number, + ...args: unknown[] + ) => { + const stack = new Error().stack ?? ''; + if (!moduleFiles.some((file) => stack.includes(file))) { + return fakeSetTimeout(callback, delay, ...args); + } + let id: number | undefined; + const handle = fakeSetTimeout( + (...callbackArgs: unknown[]) => { + if (id !== undefined) { + pending.delete(id); + } + callback(...callbackArgs); + }, + delay, + ...args, + ); + id = Number(handle); + pending.add(id); + return handle; + }) as unknown as typeof setTimeout; + const clear = ((handle?: Parameters[0]) => { + if (handle !== undefined && handle !== null) { + pending.delete(Number(handle)); + } + fakeClearTimeout(handle); + }) as typeof clearTimeout; + globalThis.setTimeout = tracked; + globalThis.clearTimeout = clear; + return { + pending: () => pending.size, + restore: () => { + if (globalThis.setTimeout === tracked) { + globalThis.setTimeout = fakeSetTimeout; + } + if (globalThis.clearTimeout === clear) { + globalThis.clearTimeout = fakeClearTimeout; + } + }, + }; +} diff --git a/src/test/suite/sessionTerminalLifecycle.test.ts b/src/test/suite/sessionTerminalLifecycle.test.ts index 2517619fb..784a72820 100644 --- a/src/test/suite/sessionTerminalLifecycle.test.ts +++ b/src/test/suite/sessionTerminalLifecycle.test.ts @@ -11,6 +11,7 @@ import * as processTree from '../../processTree'; import * as rTerminal from '../../rTerminal'; import * as session from '../../session'; import * as util from '../../util'; +import { trackModuleTimers } from '../common/fakeTimers'; import { mockExtensionContext } from '../common/mockvscode'; import { deferred, @@ -18,6 +19,9 @@ import { waitForValue as waitFor, } from '../common/sessionConnections'; +// Count only readiness timers; the extension host owns unrelated fake timers. +const readinessModules = [require.resolve('../../session'), require.resolve('../../rTerminal')]; + async function settleWithFakeTime( promise: Promise, clock: sinon.SinonFakeTimers, @@ -763,6 +767,7 @@ suite('Session Terminal Lifecycle', () => { const clock = timerSandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'], }); + const readinessTimers = trackModuleTimers(readinessModules); const timerSpy = timerSandbox.spy(globalThis, 'setTimeout'); const waiting = session.waitForTerminalReady(terminal, 30000, file, token); try { @@ -771,13 +776,14 @@ suite('Session Terminal Lifecycle', () => { close.fire(terminal); assert.strictEqual(await waiting, false); assert.strictEqual( - clock.countTimers(), + readinessTimers.pending(), 0, 'closing the terminal removes the pending startup deadline and poll', ); } finally { status.releaseRead.resolve(); close.fire(terminal); + readinessTimers.restore(); timerSandbox.restore(); await waiting; close.dispose(); @@ -1078,6 +1084,7 @@ suite('Session Terminal Lifecycle', () => { const clock = timerSandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'], }); + const readinessTimers = trackModuleTimers(readinessModules); const timerSpy = timerSandbox.spy(globalThis, 'setTimeout'); let pendingCommand: Promise | undefined; @@ -1101,11 +1108,12 @@ suite('Session Terminal Lifecycle', () => { sinon.assert.notCalled(sendText); sinon.assert.calledOnce(warning); assert.strictEqual( - clock.countTimers(), + readinessTimers.pending(), 0, 'failed startup clears its bounded wait and poll', ); + readinessTimers.restore(); timerSandbox.restore(); await connections.attach('startup-late-attach', rPid); assert.strictEqual( @@ -1124,6 +1132,7 @@ suite('Session Terminal Lifecycle', () => { } finally { status.releaseRead.resolve(); fixture.cleanup(); + readinessTimers.restore(); timerSandbox.restore(); if (pendingCommandSettled) { await pendingCommandSettled; @@ -1139,6 +1148,7 @@ suite('Session Terminal Lifecycle', () => { } as unknown as vscode.Terminal; const timerSandbox = sinon.createSandbox(); const clock = timerSandbox.useFakeTimers(); + const readinessTimers = trackModuleTimers(readinessModules); let settled = false; const waiting = session.waitForTerminalReady(terminal).then((result) => { settled = true; @@ -1153,12 +1163,13 @@ suite('Session Terminal Lifecycle', () => { ); await clock.tickAsync(1); assert.strictEqual(await waiting, false); - assert.strictEqual(clock.countTimers(), 0); + assert.strictEqual(readinessTimers.pending(), 0); } finally { try { await clock.tickAsync(30000); await waiting; } finally { + readinessTimers.restore(); timerSandbox.restore(); } } @@ -1175,6 +1186,7 @@ suite('Session Terminal Lifecycle', () => { const clock = timerSandbox.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'], }); + const readinessTimers = trackModuleTimers(readinessModules); const timerSpy = timerSandbox.spy(globalThis, 'setTimeout'); const close = new vscode.EventEmitter(); sandbox.stub(vscode.window, 'onDidCloseTerminal').callsFake(close.event); @@ -1184,10 +1196,11 @@ suite('Session Terminal Lifecycle', () => { sinon.assert.calledWith(timerSpy, sinon.match.func, 600000); await clock.tickAsync(600000); assert.strictEqual(await waiting, false); - assert.strictEqual(clock.countTimers(), 0); + assert.strictEqual(readinessTimers.pending(), 0); } finally { status.releaseRead.resolve(); close.fire(terminal); + readinessTimers.restore(); timerSandbox.restore(); await waiting; close.dispose(); From acb7c9e87f323a264a614c558d71ff3f3af5f105 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 14 Nov 2026 00:56:54 +0000 Subject: [PATCH 3/7] test: give Interactive language server checks diagnosable budgets Wait up to 45 seconds for languageserver diagnostics and client startup, and report the document text, version, client state and current diagnostics on timeout instead of a generic notebook execution message. Always clear the shared Interactive input after the lint test so a timeout does not cascade into later tests, and give the bulk session setup, which starts four arf sessions serially, its own timeout. --- src/test/suite/interactiveEditor.test.ts | 135 ++++++++++++++--------- 1 file changed, 84 insertions(+), 51 deletions(-) diff --git a/src/test/suite/interactiveEditor.test.ts b/src/test/suite/interactiveEditor.test.ts index 5e1ca0b33..e2eb32744 100644 --- a/src/test/suite/interactiveEditor.test.ts +++ b/src/test/suite/interactiveEditor.test.ts @@ -168,8 +168,9 @@ function treeTooltip(item: vscode.TreeItem): string { const until = async ( predicate: () => boolean, timeoutMessage: string | (() => string) = 'Timed out waiting for notebook execution', + timeoutMs = 20000, ): Promise => { - const deadline = Date.now() + 20000; + const deadline = Date.now() + timeoutMs; while (!predicate()) { if (Date.now() > deadline) { throw new Error( @@ -179,6 +180,27 @@ function treeTooltip(item: vscode.TreeItem): string { await new Promise((resolve) => setTimeout(resolve, 50)); } }; + // languageserver lints after a debounce in a separate R process; on macOS + // runners a first lint occasionally takes longer than 20 seconds. + const untilDiagnostics = ( + document: vscode.TextDocument, + predicate: (diagnostics: readonly vscode.Diagnostic[]) => boolean, + description: string, + ): Promise => + until( + () => predicate(vscode.languages.getDiagnostics(document.uri)), + () => + `Timed out waiting for ${description} on ${document.uri.toString()} ` + + `(version ${document.version}, text ${JSON.stringify(document.getText())}, ` + + `language client running: ${String( + languageService().clients.get(document.uri.toString())?.isRunning(), + )}); current diagnostics: ${JSON.stringify( + vscode.languages + .getDiagnostics(document.uri) + .map((item) => `${item.range.start.line}: ${item.message}`), + )}`, + 45000, + ); function bundleContext(): vscode.ExtensionContext { return ( createRequire(__filename)(path.join(process.cwd(), 'dist/extension')) as { @@ -1202,7 +1224,7 @@ function treeTooltip(item: vscode.TreeItem): string { ]); await vscode.workspace.applyEdit(edit); const document = notebook.cellAt(notebook.cellCount - 1).document; - await until(() => vscode.languages.getDiagnostics(document.uri).length > 0); + await untilDiagnostics(document, (items) => items.length > 0, 'lint diagnostics'); const diagnostics = vscode.languages.getDiagnostics(document.uri); assert.ok( !diagnostics.some((item) => item.message.includes('Failed to run diagnostics')), @@ -1216,7 +1238,7 @@ function treeTooltip(item: vscode.TreeItem): string { 'diagnostic_value <- 1\n', ); await vscode.workspace.applyEdit(fix); - await until(() => vscode.languages.getDiagnostics(document.uri).length === 0); + await untilDiagnostics(document, (items) => items.length === 0, 'diagnostics to clear'); }); test('namespace linters work on virtual cells and diagnostics keep their cell identity', async () => { const notebook = vscode.workspace.notebookDocuments.find( @@ -1238,7 +1260,7 @@ function treeTooltip(item: vscode.TreeItem): string { await vscode.workspace.applyEdit(edit); const bad = notebook.cellAt(index).document, good = notebook.cellAt(index + 1).document; - await until(() => vscode.languages.getDiagnostics(bad.uri).length > 0); + await untilDiagnostics(bad, (items) => items.length > 0, 'lint diagnostics'); const diagnostics = vscode.languages.getDiagnostics(bad.uri); assert.ok( !diagnostics.some((item) => item.message.includes('Failed to run diagnostics')), @@ -1257,7 +1279,7 @@ function treeTooltip(item: vscode.TreeItem): string { 'base::sum(1:3)\nnamespace_value <- 1\n', ); await vscode.workspace.applyEdit(fix); - await until(() => vscode.languages.getDiagnostics(bad.uri).length === 0); + await untilDiagnostics(bad, (items) => items.length === 0, 'diagnostics to clear'); }); test('lints the Interactive input before execution', async () => { @@ -1265,39 +1287,44 @@ function treeTooltip(item: vscode.TreeItem): string { (doc) => doc.uri.scheme === 'vscode-interactive-input' && doc.languageId === 'r', ); assert.ok(document); - const edit = new vscode.WorkspaceEdit(); - edit.replace( - document.uri, - new vscode.Range(0, 0, document.lineCount, 0), - 'input_value<-1\n', - ); - await vscode.workspace.applyEdit(edit); - await until(() => - vscode.languages - .getDiagnostics(document.uri) - .some( - (item) => - item.message.includes('spaces around') || - item.message.includes('Failed to run diagnostics'), - ), - ); - const diagnostics = vscode.languages.getDiagnostics(document.uri); - assert.ok( - !diagnostics.some((item) => item.message.includes('Failed to run diagnostics')), - JSON.stringify(diagnostics), - ); - assert.ok(diagnostics.some((item) => item.message.includes('spaces around'))); - const clear = new vscode.WorkspaceEdit(); - clear.replace( - document.uri, - new vscode.Range(0, 0, document.lineCount, 0), - 'input_value <- 1\n', - ); - await vscode.workspace.applyEdit(clear); - await until(() => vscode.languages.getDiagnostics(document.uri).length === 0); - const reset = new vscode.WorkspaceEdit(); - reset.delete(document.uri, new vscode.Range(0, 0, document.lineCount, 0)); - await vscode.workspace.applyEdit(reset); + // Later tests share this input; never leave the lint fixture behind. + try { + const edit = new vscode.WorkspaceEdit(); + edit.replace( + document.uri, + new vscode.Range(0, 0, document.lineCount, 0), + 'input_value<-1\n', + ); + await vscode.workspace.applyEdit(edit); + await untilDiagnostics( + document, + (items) => + items.some( + (item) => + item.message.includes('spaces around') || + item.message.includes('Failed to run diagnostics'), + ), + 'lint diagnostics', + ); + const diagnostics = vscode.languages.getDiagnostics(document.uri); + assert.ok( + !diagnostics.some((item) => item.message.includes('Failed to run diagnostics')), + JSON.stringify(diagnostics), + ); + assert.ok(diagnostics.some((item) => item.message.includes('spaces around'))); + const clear = new vscode.WorkspaceEdit(); + clear.replace( + document.uri, + new vscode.Range(0, 0, document.lineCount, 0), + 'input_value <- 1\n', + ); + await vscode.workspace.applyEdit(clear); + await untilDiagnostics(document, (items) => items.length === 0, 'diagnostics to clear'); + } finally { + const reset = new vscode.WorkspaceEdit(); + reset.delete(document.uri, new vscode.Range(0, 0, document.lineCount, 0)); + await vscode.workspace.applyEdit(reset); + } }); test('formats typed Interactive input with its language server and preserves the cursor', async () => { const settings = vscode.workspace.getConfiguration('editor', { languageId: 'r' }); @@ -1331,6 +1358,8 @@ function treeTooltip(item: vscode.TreeItem): string { await until( () => languageService().clients.get(opened.inputUri.toString())?.isRunning() === true, + 'Timed out waiting for the Interactive input language client to start', + 45000, ); // Current servers register formatting dynamically after initialize; // the active provider also covers servers with static capabilities. @@ -1341,6 +1370,7 @@ function treeTooltip(item: vscode.TreeItem): string { ?.getFeature(DocumentOnTypeFormattingRequest.method) .getProvider(document) !== undefined, 'languageserver must register on-type formatting for the Interactive input', + 45000, ); try { for (const [text, ch, expected] of [ @@ -1520,24 +1550,24 @@ function treeTooltip(item: vscode.TreeItem): string { // Expression diagnostics also work with lintr versions that filter // file-level whitespace lints for synthetic document paths. await replace('input_value<-2\n'); - await until(() => - vscode.languages - .getDiagnostics(document.uri) - .some((item) => item.message.includes('spaces around')), + await untilDiagnostics( + document, + (items) => items.some((item) => item.message.includes('spaces around')), + 'lint diagnostics', ); await replace(' \n\t\n'); - await until(() => vscode.languages.getDiagnostics(document.uri).length === 0); + await untilDiagnostics(document, (items) => items.length === 0, 'diagnostics to clear'); // Let the server publish its whitespace diagnostics after the immediate clear. await new Promise((resolve) => setTimeout(resolve, 1500)); assert.deepStrictEqual(vscode.languages.getDiagnostics(document.uri), []); await replace('input_value<-1\n'); - await until(() => - vscode.languages - .getDiagnostics(document.uri) - .some((item) => item.message.includes('spaces around')), + await untilDiagnostics( + document, + (items) => items.some((item) => item.message.includes('spaces around')), + 'lint diagnostics', ); await replace(''); - await until(() => vscode.languages.getDiagnostics(document.uri).length === 0); + await untilDiagnostics(document, (items) => items.length === 0, 'diagnostics to clear'); } finally { await replace(previous); } @@ -1669,7 +1699,7 @@ function treeTooltip(item: vscode.TreeItem): string { await vscode.workspace.openTextDocument(file), 'r', ); - await until(() => vscode.languages.getDiagnostics(document.uri).length > 0); + await untilDiagnostics(document, (items) => items.length > 0, 'lint diagnostics'); const diagnostics = vscode.languages.getDiagnostics(document.uri); assert.ok( !diagnostics.some((item) => item.message.includes('Failed to run diagnostics')), @@ -1683,7 +1713,7 @@ function treeTooltip(item: vscode.TreeItem): string { 'file_value <- 1\n', ); await vscode.workspace.applyEdit(fix); - await until(() => vscode.languages.getDiagnostics(document.uri).length === 0); + await untilDiagnostics(document, (items) => items.length === 0, 'diagnostics to clear'); await document.save(); } finally { sourceDirectories.push(directory); @@ -4450,7 +4480,10 @@ par(mfrow=c(1,1))`, await until(() => manifest.status === 'idle'); return manifest; }; - suiteSetup(async () => { + suiteSetup(async function () { + // Four arf sessions start one after another; slow macOS runners can + // need more than the 60 second default before the shared notebook runs. + this.timeout(180000); for (let i = 0; i < 4; i++) { await start(); } From 993caefceef491fa67d1b7f676cde03ef01c04ec Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 14 Nov 2026 00:57:52 +0000 Subject: [PATCH 4/7] ci: run Node and extension-host tests in parallel jobs per OS Each OS ran R integration checks, Node tests and extension-host tests back to back in one job, which made macOS the bottleneck. Add a suite matrix dimension so R integration and Node tests run in one job and extension-host tests in another, after the same R, sess and arf setup. --- .github/workflows/main.yml | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7a7e80f47..f32e57034 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -50,10 +50,14 @@ jobs: test: env: VSCR_REQUIRE_PLOT_TESTS: '1' + # Split each OS into two parallel jobs so the slowest suites do not run + # back to back: R integration checks with Node tests, and extension-host + # tests. Both need the same R, sess, and arf setup. strategy: fail-fast: false matrix: os: [macos-latest, ubuntu-latest, windows-latest] + suite: [node, extension] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -67,10 +71,13 @@ jobs: cache: 'pnpm' - run: pnpm install --frozen-lockfile - name: Verify sess source identity and bootstrap + if: matrix.suite == 'node' run: pnpm run test:sess-source - name: Verify managed tool library + if: matrix.suite == 'node' run: pnpm run test:tool-library - name: Verify managed tool consent + if: matrix.suite == 'node' run: pnpm run test:tool-consent - name: Install R test dependencies uses: r-lib/actions/setup-r-dependencies@f9a764fea8d5c63df6ef9a5c7795bf7deb5d7e05 # v2 @@ -100,15 +107,19 @@ jobs: stopifnot(packageVersion("httpgd") >= "2.0.1") shell: Rscript {0} - name: Verify managed jgd preparation + if: matrix.suite == 'node' run: pnpm run test:prepare-jgd - name: Verify managed rstudioapi preparation + if: matrix.suite == 'node' run: pnpm run test:prepare-rstudioapi - name: Verify installed sess identity + if: matrix.suite == 'node' run: | source("R/sess_source.R") stopifnot(!sess_install_required("dist/resources/sess")) shell: Rscript {0} - name: Run sess tests + if: matrix.suite == 'node' run: tinytest::test_package("sess") shell: Rscript {0} - name: Compile test files @@ -136,27 +147,28 @@ jobs: echo "$(dirname "${ARF_EXECUTABLE}")" >> "$GITHUB_PATH" echo "ARF_PATH=${ARF_EXECUTABLE}" >> "$GITHUB_ENV" - name: Test persistent tmux sessions (Linux) - if: runner.os == 'Linux' + if: matrix.suite == 'node' && runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y tmux VSCR_TEST_TMUX=1 pnpm exec mocha out/test/node/interactiveRuntime.test.js --ui tdd --timeout 60000 --grep "^Interactive real R runtime standalone agent survives its launcher process exiting$" - name: Run Node tests including live plot lifecycle tests + if: matrix.suite == 'node' run: pnpm run test:node - name: Run plot lifecycle extension tests (Linux) - if: runner.os == 'Linux' + if: matrix.suite == 'extension' && runner.os == 'Linux' run: xvfb-run -a pnpm run test:extension --label plot-lifecycle - name: Run plot lifecycle extension tests - if: runner.os != 'Linux' + if: matrix.suite == 'extension' && runner.os != 'Linux' run: pnpm run test:extension --label plot-lifecycle - name: Run extension tests (Linux) - if: runner.os == 'Linux' + if: matrix.suite == 'extension' && runner.os == 'Linux' run: xvfb-run -a pnpm run test:extension --label provider-tests --label editor-command-tests --label interactive-integration - name: Run extension tests - if: runner.os != 'Linux' + if: matrix.suite == 'extension' && runner.os != 'Linux' run: pnpm run test:extension --label provider-tests --label editor-command-tests --label interactive-integration - name: Preserve extension test failure logs - if: failure() + if: failure() && matrix.suite == 'extension' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: extension-test-logs-${{ runner.os }} From cfa749c01efc8c9d63b6788365ee0b8ab88d4c55 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 14 Nov 2026 01:10:29 +0000 Subject: [PATCH 5/7] ci: split test jobs and share setup through local composite actions Replace the suite matrix dimension and per-step conditions with separate test-node and test-extension jobs. Move the shared setup into two local composite actions: setup-test-tools installs R, pnpm and Node.js, and prepare-test-runtime installs R test dependencies, builds and installs sess, compiles the tests and installs arf. sess bootstrap checks still run between the two, before R test dependencies exist. Re-include .github/actions in the path filter, and let Dependabot update actions pinned inside the composite actions. --- .../actions/prepare-test-runtime/action.yml | 63 +++++++++++ .github/actions/setup-test-tools/action.yml | 17 +++ .github/dependabot.yml | 6 +- .github/workflows/main.yml | 106 +++++------------- 4 files changed, 112 insertions(+), 80 deletions(-) create mode 100644 .github/actions/prepare-test-runtime/action.yml create mode 100644 .github/actions/setup-test-tools/action.yml diff --git a/.github/actions/prepare-test-runtime/action.yml b/.github/actions/prepare-test-runtime/action.yml new file mode 100644 index 000000000..098f8b037 --- /dev/null +++ b/.github/actions/prepare-test-runtime/action.yml @@ -0,0 +1,63 @@ +name: Prepare test runtime +description: >- + Install R test dependencies, build the extension and bundled sess, install + sess, compile the tests and put arf on PATH (except on Windows). Run after + setup-test-tools. +runs: + using: composite + steps: + - name: Install R test dependencies + uses: r-lib/actions/setup-r-dependencies@f9a764fea8d5c63df6ef9a5c7795bf7deb5d7e05 # v2 + with: + working-directory: sess + # Keep Suggests for live plot tests. Install lintr/styler explicitly + # for Interactive tests; they are optional languageserver dependencies. + # renv is exercised by the Interactive library-isolation tests. + extra-packages: | + any::remotes + any::pkgbuild + any::languageserver + any::lintr + any::styler + any::renv + - run: pnpm run compile + shell: bash + - name: Install bundled sess with prepared dependencies + run: remotes::install_local('dist/resources/sess', dependencies = FALSE, upgrade = 'never', force = TRUE) + shell: Rscript {0} + - name: Verify live plot lifecycle dependencies + run: | + packages <- c("processx", "later", "jsonlite", "jgd", "httpgd", "svglite") + for (package in packages) { + message("Loading plot test dependency: ", package) + loadNamespace(package) + } + stopifnot(packageVersion("httpgd") >= "2.0.1") + shell: Rscript {0} + - name: Compile test files + run: pnpm run clean && pnpm exec tsc + shell: bash + - name: Install arf binary for Interactive tests + if: runner.os != 'Windows' + shell: bash + env: + RUNNER_OS_NAME: ${{ runner.os }} + run: | + ARF_ARCH="$(uname -m)" + if [ "${ARF_ARCH}" = "arm64" ]; then ARF_ARCH="aarch64"; fi + if [ "${RUNNER_OS_NAME}" = "macOS" ]; then + ARF_TARGET="${ARF_ARCH}-apple-darwin" + else + ARF_TARGET="${ARF_ARCH}-unknown-linux-gnu" + fi + ARF_ARCHIVE="arf-console-${ARF_TARGET}.tar.xz" + ARF_RELEASE="https://github.com/eitsupi/arf/releases/download/v0.5.3" + curl -fsSL "${ARF_RELEASE}/${ARF_ARCHIVE}" -o "${RUNNER_TEMP}/${ARF_ARCHIVE}" + curl -fsSL "${ARF_RELEASE}/sha256.sum" -o "${RUNNER_TEMP}/arf.sha256" + (cd "${RUNNER_TEMP}" && awk -v name="${ARF_ARCHIVE}" '$2 == name || $2 == "*" name' arf.sha256 > arf-selected.sha256 && test -s arf-selected.sha256 && shasum -a 256 -c arf-selected.sha256) + mkdir -p "${RUNNER_TEMP}/arf" + tar -xJf "${RUNNER_TEMP}/${ARF_ARCHIVE}" -C "${RUNNER_TEMP}/arf" + ARF_EXECUTABLE=$(find "${RUNNER_TEMP}/arf" -type f -name arf | head -n 1) + test -n "${ARF_EXECUTABLE}" + echo "$(dirname "${ARF_EXECUTABLE}")" >> "$GITHUB_PATH" + echo "ARF_PATH=${ARF_EXECUTABLE}" >> "$GITHUB_ENV" diff --git a/.github/actions/setup-test-tools/action.yml b/.github/actions/setup-test-tools/action.yml new file mode 100644 index 000000000..b5f872b1a --- /dev/null +++ b/.github/actions/setup-test-tools/action.yml @@ -0,0 +1,17 @@ +name: Set up test tools +description: >- + Install R, pnpm, Node.js and the locked npm dependencies. R packages are not + installed yet, so sess bootstrap checks can run in this base environment. +runs: + using: composite + steps: + - uses: r-lib/actions/setup-r@f9a764fea8d5c63df6ef9a5c7795bf7deb5d7e05 # v2 + with: + use-public-rspm: true + - uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + shell: bash diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 616faebc5..a7a0765be 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,11 @@ version: 2 updates: - package-ecosystem: github-actions - directory: / + # `/` covers only workflows and a root action.yml; list local composite + # actions explicitly so their pinned actions are updated too. + directories: + - / + - /.github/actions/* commit-message: prefix: "ci: " schedule: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f32e57034..a8532693b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -26,6 +26,7 @@ on: - '!.roborev/**' - '!scripts/benchmark-*' - '!.github/**' + - '.github/actions/**' - '.github/workflows/main.yml' - '.github/scripts/extension-version.js' - '.github/scripts/extension-version.test.js' @@ -47,128 +48,75 @@ concurrency: cancel-in-progress: true jobs: - test: + # Node and extension-host tests run as parallel jobs per OS. Both need the + # same R, sess and arf runtime, which the local composite actions set up. + test-node: + name: Node (${{ matrix.os }}) env: VSCR_REQUIRE_PLOT_TESTS: '1' - # Split each OS into two parallel jobs so the slowest suites do not run - # back to back: R integration checks with Node tests, and extension-host - # tests. Both need the same R, sess, and arf setup. strategy: fail-fast: false matrix: os: [macos-latest, ubuntu-latest, windows-latest] - suite: [node, extension] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: r-lib/actions/setup-r@f9a764fea8d5c63df6ef9a5c7795bf7deb5d7e05 # v2 - with: - use-public-rspm: true - - uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 24 - cache: 'pnpm' - - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/setup-test-tools + # Bootstrap checks run before R test dependencies are installed. - name: Verify sess source identity and bootstrap - if: matrix.suite == 'node' run: pnpm run test:sess-source - name: Verify managed tool library - if: matrix.suite == 'node' run: pnpm run test:tool-library - name: Verify managed tool consent - if: matrix.suite == 'node' run: pnpm run test:tool-consent - - name: Install R test dependencies - uses: r-lib/actions/setup-r-dependencies@f9a764fea8d5c63df6ef9a5c7795bf7deb5d7e05 # v2 - with: - working-directory: sess - # Keep Suggests for live plot tests. Install lintr/styler explicitly - # for Interactive tests; they are optional languageserver dependencies. - # renv is exercised by the Interactive library-isolation tests. - extra-packages: | - any::remotes - any::pkgbuild - any::languageserver - any::lintr - any::styler - any::renv - - run: pnpm run compile - - name: Install bundled sess with prepared dependencies - run: remotes::install_local('dist/resources/sess', dependencies = FALSE, upgrade = 'never', force = TRUE) - shell: Rscript {0} - - name: Verify live plot lifecycle dependencies - run: | - packages <- c("processx", "later", "jsonlite", "jgd", "httpgd", "svglite") - for (package in packages) { - message("Loading plot test dependency: ", package) - loadNamespace(package) - } - stopifnot(packageVersion("httpgd") >= "2.0.1") - shell: Rscript {0} + - uses: ./.github/actions/prepare-test-runtime - name: Verify managed jgd preparation - if: matrix.suite == 'node' run: pnpm run test:prepare-jgd - name: Verify managed rstudioapi preparation - if: matrix.suite == 'node' run: pnpm run test:prepare-rstudioapi - name: Verify installed sess identity - if: matrix.suite == 'node' run: | source("R/sess_source.R") stopifnot(!sess_install_required("dist/resources/sess")) shell: Rscript {0} - name: Run sess tests - if: matrix.suite == 'node' run: tinytest::test_package("sess") shell: Rscript {0} - - name: Compile test files - run: pnpm run clean && pnpm exec tsc - - name: Install arf binary for Interactive tests - if: runner.os != 'Windows' - shell: bash - run: | - ARF_ARCH="$(uname -m)" - if [ "${ARF_ARCH}" = "arm64" ]; then ARF_ARCH="aarch64"; fi - if [ "${{ runner.os }}" = "macOS" ]; then - ARF_TARGET="${ARF_ARCH}-apple-darwin" - else - ARF_TARGET="${ARF_ARCH}-unknown-linux-gnu" - fi - ARF_ARCHIVE="arf-console-${ARF_TARGET}.tar.xz" - ARF_RELEASE="https://github.com/eitsupi/arf/releases/download/v0.5.3" - curl -fsSL "${ARF_RELEASE}/${ARF_ARCHIVE}" -o "${RUNNER_TEMP}/${ARF_ARCHIVE}" - curl -fsSL "${ARF_RELEASE}/sha256.sum" -o "${RUNNER_TEMP}/arf.sha256" - (cd "${RUNNER_TEMP}" && awk -v name="${ARF_ARCHIVE}" '$2 == name || $2 == "*" name' arf.sha256 > arf-selected.sha256 && test -s arf-selected.sha256 && shasum -a 256 -c arf-selected.sha256) - mkdir -p "${RUNNER_TEMP}/arf" - tar -xJf "${RUNNER_TEMP}/${ARF_ARCHIVE}" -C "${RUNNER_TEMP}/arf" - ARF_EXECUTABLE=$(find "${RUNNER_TEMP}/arf" -type f -name arf | head -n 1) - test -n "${ARF_EXECUTABLE}" - echo "$(dirname "${ARF_EXECUTABLE}")" >> "$GITHUB_PATH" - echo "ARF_PATH=${ARF_EXECUTABLE}" >> "$GITHUB_ENV" - name: Test persistent tmux sessions (Linux) - if: matrix.suite == 'node' && runner.os == 'Linux' + if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y tmux VSCR_TEST_TMUX=1 pnpm exec mocha out/test/node/interactiveRuntime.test.js --ui tdd --timeout 60000 --grep "^Interactive real R runtime standalone agent survives its launcher process exiting$" - name: Run Node tests including live plot lifecycle tests - if: matrix.suite == 'node' run: pnpm run test:node + test-extension: + name: Extension (${{ matrix.os }}) + env: + VSCR_REQUIRE_PLOT_TESTS: '1' + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/setup-test-tools + - uses: ./.github/actions/prepare-test-runtime - name: Run plot lifecycle extension tests (Linux) - if: matrix.suite == 'extension' && runner.os == 'Linux' + if: runner.os == 'Linux' run: xvfb-run -a pnpm run test:extension --label plot-lifecycle - name: Run plot lifecycle extension tests - if: matrix.suite == 'extension' && runner.os != 'Linux' + if: runner.os != 'Linux' run: pnpm run test:extension --label plot-lifecycle - name: Run extension tests (Linux) - if: matrix.suite == 'extension' && runner.os == 'Linux' + if: runner.os == 'Linux' run: xvfb-run -a pnpm run test:extension --label provider-tests --label editor-command-tests --label interactive-integration - name: Run extension tests - if: matrix.suite == 'extension' && runner.os != 'Linux' + if: runner.os != 'Linux' run: pnpm run test:extension --label provider-tests --label editor-command-tests --label interactive-integration - name: Preserve extension test failure logs - if: failure() && matrix.suite == 'extension' + if: failure() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: extension-test-logs-${{ runner.os }} From 6d1c0a896cfa4437af8d768b58a499383e0766ed Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 14 Nov 2026 01:17:01 +0000 Subject: [PATCH 6/7] ci: write the test OS matrices as block lists --- .github/workflows/main.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a8532693b..d3134f5ba 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -57,7 +57,10 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-latest, ubuntu-latest, windows-latest] + os: + - macos-latest + - ubuntu-latest + - windows-latest runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -97,7 +100,10 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-latest, ubuntu-latest, windows-latest] + os: + - macos-latest + - ubuntu-latest + - windows-latest runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 From d789fe9a2b57b32888bd1cb531a0c2a292243077 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 14 Nov 2026 04:52:00 +0000 Subject: [PATCH 7/7] test: keep Interactive language-service waits within the Mocha test timeout Tests that wait for languageserver startup or diagnostics now raise their timeout to cover every sequential 45-second wait. Each wait is also capped by the running test's deadline minus a cleanup reserve, so a slow wait fails inside its own test and its cleanup cannot run during a later test that shares the Interactive input. --- src/test/suite/interactiveEditor.test.ts | 53 ++++++++++++++++++------ 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/src/test/suite/interactiveEditor.test.ts b/src/test/suite/interactiveEditor.test.ts index e2eb32744..81d5ef715 100644 --- a/src/test/suite/interactiveEditor.test.ts +++ b/src/test/suite/interactiveEditor.test.ts @@ -165,12 +165,31 @@ function treeTooltip(item: vscode.TreeItem): string { fs.rmSync(directory, { recursive: true, force: true }), ); }); + // Mocha does not await a test that exceeds its timeout, so a wait that + // outlives the test would run its cleanup while a later test is using the + // shared notebook and input. Every wait therefore ends early enough for + // the cleanup to finish before Mocha advances. + const cleanupReserveMs = 5000; + let runningTest: { test: Mocha.Runnable; started: number } | undefined; + setup(function () { + runningTest = this.currentTest && { test: this.currentTest, started: Date.now() }; + }); + teardown(() => { + runningTest = undefined; + }); const until = async ( predicate: () => boolean, timeoutMessage: string | (() => string) = 'Timed out waiting for notebook execution', timeoutMs = 20000, ): Promise => { - const deadline = Date.now() + timeoutMs; + // Read the test timeout here because tests may raise it in their body. + const testTimeoutMs = runningTest?.test.timeout(); + const deadline = Math.min( + Date.now() + timeoutMs, + runningTest && testTimeoutMs + ? runningTest.started + testTimeoutMs - cleanupReserveMs + : Infinity, + ); while (!predicate()) { if (Date.now() > deadline) { throw new Error( @@ -180,8 +199,12 @@ function treeTooltip(item: vscode.TreeItem): string { await new Promise((resolve) => setTimeout(resolve, 50)); } }; - // languageserver lints after a debounce in a separate R process; on macOS - // runners a first lint occasionally takes longer than 20 seconds. + // languageserver starts and lints after a debounce in a separate R process; + // on macOS runners a first lint occasionally takes longer than 20 seconds. + // Tests that wait for it raise their timeout to cover every sequential wait. + const languageServerWaitMs = 45000; + const timeoutWithLanguageServerWaits = (waits: number): number => + 60000 + waits * languageServerWaitMs; const untilDiagnostics = ( document: vscode.TextDocument, predicate: (diagnostics: readonly vscode.Diagnostic[]) => boolean, @@ -199,7 +222,7 @@ function treeTooltip(item: vscode.TreeItem): string { .getDiagnostics(document.uri) .map((item) => `${item.range.start.line}: ${item.message}`), )}`, - 45000, + languageServerWaitMs, ); function bundleContext(): vscode.ExtensionContext { return ( @@ -1207,7 +1230,8 @@ function treeTooltip(item: vscode.TreeItem): string { errors.restore(); } }); - test('lints Interactive cells and clears diagnostics after an edit', async () => { + test('lints Interactive cells and clears diagnostics after an edit', async function () { + this.timeout(timeoutWithLanguageServerWaits(2)); const notebook = vscode.workspace.notebookDocuments.find( (doc) => doc.metadata.rSessionId === manifests[0].id, ); @@ -1240,7 +1264,8 @@ function treeTooltip(item: vscode.TreeItem): string { await vscode.workspace.applyEdit(fix); await untilDiagnostics(document, (items) => items.length === 0, 'diagnostics to clear'); }); - test('namespace linters work on virtual cells and diagnostics keep their cell identity', async () => { + test('namespace linters work on virtual cells and diagnostics keep their cell identity', async function () { + this.timeout(timeoutWithLanguageServerWaits(2)); const notebook = vscode.workspace.notebookDocuments.find( (doc) => doc.metadata.rSessionId === manifests[0].id, ); @@ -1282,7 +1307,8 @@ function treeTooltip(item: vscode.TreeItem): string { await untilDiagnostics(bad, (items) => items.length === 0, 'diagnostics to clear'); }); - test('lints the Interactive input before execution', async () => { + test('lints the Interactive input before execution', async function () { + this.timeout(timeoutWithLanguageServerWaits(2)); const document = vscode.workspace.textDocuments.find( (doc) => doc.uri.scheme === 'vscode-interactive-input' && doc.languageId === 'r', ); @@ -1326,7 +1352,8 @@ function treeTooltip(item: vscode.TreeItem): string { await vscode.workspace.applyEdit(reset); } }); - test('formats typed Interactive input with its language server and preserves the cursor', async () => { + test('formats typed Interactive input with its language server and preserves the cursor', async function () { + this.timeout(timeoutWithLanguageServerWaits(2)); const settings = vscode.workspace.getConfiguration('editor', { languageId: 'r' }); const previous = settings.inspect('formatOnType')?.globalLanguageValue; await settings.update('formatOnType', true, vscode.ConfigurationTarget.Global, true); @@ -1359,7 +1386,7 @@ function treeTooltip(item: vscode.TreeItem): string { () => languageService().clients.get(opened.inputUri.toString())?.isRunning() === true, 'Timed out waiting for the Interactive input language client to start', - 45000, + languageServerWaitMs, ); // Current servers register formatting dynamically after initialize; // the active provider also covers servers with static capabilities. @@ -1370,7 +1397,7 @@ function treeTooltip(item: vscode.TreeItem): string { ?.getFeature(DocumentOnTypeFormattingRequest.method) .getProvider(document) !== undefined, 'languageserver must register on-type formatting for the Interactive input', - 45000, + languageServerWaitMs, ); try { for (const [text, ch, expected] of [ @@ -1528,7 +1555,8 @@ function treeTooltip(item: vscode.TreeItem): string { await vscode.workspace.applyEdit(reset); } }); - test('keeps blank Interactive prompts free of diagnostics while still linting nonempty input', async () => { + test('keeps blank Interactive prompts free of diagnostics while still linting nonempty input', async function () { + this.timeout(timeoutWithLanguageServerWaits(4)); await vscode.commands.executeCommand('r.interactive.open', manifests[0]); const notebook = vscode.workspace.notebookDocuments.find( (doc) => doc.metadata.rSessionId === manifests[0].id, @@ -1689,7 +1717,8 @@ function treeTooltip(item: vscode.TreeItem): string { await vscode.commands.executeCommand('r.interactive.open', manifests[0]); } }); - test('keeps source-file diagnostics working with lint caching enabled', async () => { + test('keeps source-file diagnostics working with lint caching enabled', async function () { + this.timeout(timeoutWithLanguageServerWaits(2)); // languageserver deliberately excludes files under the system temporary directory. const directory = fs.mkdtempSync(path.join(process.cwd(), '.r-interactive-source-')); try {