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 7a7e80f47..d3134f5ba 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,58 +48,31 @@ 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' 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 - - 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 run: pnpm run test:sess-source - name: Verify managed tool library run: pnpm run test:tool-library - name: Verify managed tool consent 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 run: pnpm run test:prepare-jgd - name: Verify managed rstudioapi preparation @@ -111,30 +85,6 @@ jobs: - name: Run sess tests 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: runner.os == 'Linux' run: | @@ -143,6 +93,22 @@ jobs: 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 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: runner.os == 'Linux' run: xvfb-run -a pnpm run test:extension --label plot-lifecycle 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")) } 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/interactiveEditor.test.ts b/src/test/suite/interactiveEditor.test.ts index 5e1ca0b33..81d5ef715 100644 --- a/src/test/suite/interactiveEditor.test.ts +++ b/src/test/suite/interactiveEditor.test.ts @@ -165,11 +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() + 20000; + // 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( @@ -179,6 +199,31 @@ function treeTooltip(item: vscode.TreeItem): string { await new Promise((resolve) => setTimeout(resolve, 50)); } }; + // 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, + 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}`), + )}`, + languageServerWaitMs, + ); function bundleContext(): vscode.ExtensionContext { return ( createRequire(__filename)(path.join(process.cwd(), 'dist/extension')) as { @@ -1185,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, ); @@ -1202,7 +1248,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,9 +1262,10 @@ 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 () => { + 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, ); @@ -1238,7 +1285,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,49 +1304,56 @@ 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 () => { + 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', ); 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 () => { + 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); @@ -1331,6 +1385,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', + languageServerWaitMs, ); // Current servers register formatting dynamically after initialize; // the active provider also covers servers with static capabilities. @@ -1341,6 +1397,7 @@ function treeTooltip(item: vscode.TreeItem): string { ?.getFeature(DocumentOnTypeFormattingRequest.method) .getProvider(document) !== undefined, 'languageserver must register on-type formatting for the Interactive input', + languageServerWaitMs, ); try { for (const [text, ch, expected] of [ @@ -1498,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, @@ -1520,24 +1578,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); } @@ -1659,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 { @@ -1669,7 +1728,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 +1742,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 +4509,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(); } 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();