From f1ee0834b9bdcd2b0f83f13125ae8d61b75fcf51 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:18:03 -0400 Subject: [PATCH 1/8] Fix flaky open-connect-content e2e test Replace URL change detection with direct explorer polling to handle both workspace switch code paths (updateWorkspaceFolders and openFolder). Consolidate 5 retryWithBackoff blocks into a single waitUntil loop with try-catch for page reload safety. Co-Authored-By: Claude Opus 4.6 --- test/e2e/tests/open-connect-content.cy.js | 161 +++++++++------------- 1 file changed, 63 insertions(+), 98 deletions(-) diff --git a/test/e2e/tests/open-connect-content.cy.js b/test/e2e/tests/open-connect-content.cy.js index e834911a0..d05e56622 100644 --- a/test/e2e/tests/open-connect-content.cy.js +++ b/test/e2e/tests/open-connect-content.cy.js @@ -18,119 +18,84 @@ describe("Open Connect Content", () => { cy.debugIframes(); cy.expectInitialPublisherState(); + // Phase 2: Create and deploy static content cy.createPCSDeployment("static", "index.html", "static", () => { return; }).deployCurrentlySelected(); + // Phase 3: Read the content record to get server_url and content GUID cy.getPublisherTomlFilePaths("static").then((filePaths) => { cy.loadTomlFile(filePaths.contentRecord.path).then((contentRecord) => { - // Capture the current URL before running the command - cy.url().then((originalUrl) => { - cy.runCommandPaletteCommand("Posit Publisher: Open Connect Content"); - cy.quickInputType("Connect server URL", contentRecord.server_url); - cy.quickInputType("Connect content GUID", contentRecord.id); + // Stash values for use after the workspace switch + const serverUrl = contentRecord.server_url; + const contentGuid = contentRecord.id; - // Wait for the quick input to close, indicating the command has been submitted - cy.get(".quick-input-widget").should("not.be.visible"); + // Phase 4: Run "Open Connect Content" via command palette + cy.runCommandPaletteCommand( + "Posit Publisher: Open Connect Content", + ); + cy.quickInputType("Connect server URL", serverUrl); + cy.quickInputType("Connect content GUID", contentGuid); - // Wait for the workspace to actually start reloading by detecting URL change. - // This prevents the race condition where retries run against the old workspace - // before VS Code has started loading the new one. - cy.url({ timeout: 30000 }).should("not.eq", originalUrl); + // Wait for the quick input to close, confirming submission + cy.get(".quick-input-widget").should("not.be.visible"); - // Wait for VS Code to fully reload after the workspace switch. - // The workbench needs time to initialize with the new workspace. - cy.get(".monaco-workbench", { timeout: 60000 }).should("be.visible"); + // Phase 5: Wait for workspace switch to complete. + // The extension has two code paths: updateWorkspaceFolders (no reload) + // and openFolder (full page reload). We handle both by waiting for the + // workbench to be present, then polling the explorer for the content GUID. + cy.get(".monaco-workbench", { timeout: 120_000 }).should("be.visible"); - // Additional wait for the explorer to be ready - cy.get(".explorer-viewlet", { timeout: 30000 }).should("exist"); + cy.waitUntil( + () => { + try { + const $body = Cypress.$("body"); + if ($body.length === 0) return false; - // Wait for the workspace to reload with the Connect content. - // The GUID appears in the explorer as a root folder after the workspace switch. - // Use { timeout: 0 } in the inner cy.contains() so retryWithBackoff controls - // the retry timing rather than cy.contains() blocking on its own timeout. - cy.retryWithBackoff( - () => - cy.get("body", { timeout: 0 }).then(($body) => { - const explorer = $body.find(".explorer-viewlet"); - if (explorer.length === 0) { - return Cypress.$(); // Explorer not yet rendered, return empty to retry - } - const row = explorer.find( - `.monaco-list-row[aria-level='1']:contains("${contentRecord.id}")`, - ); - return row.length > 0 ? cy.wrap(row) : Cypress.$(); - }), - 20, - 1500, - ).should("exist"); - }); - }); - }); + // Ensure the Explorer sidebar is visible; click its icon if not + if ($body.find(".explorer-viewlet:visible").length === 0) { + const explorerBtn = + $body + .find( + '[id="workbench.parts.activitybar"] .action-item[role="button"][aria-label="Explorer"]', + ) + .get(0) || + $body.find("a.codicon-explorer-view-icon").get(0); + if (explorerBtn) explorerBtn.click(); + return false; + } - cy.retryWithBackoff( - () => - cy.get("body", { timeout: 0 }).then(($body) => { - if ($body.find(".explorer-viewlet:visible").length === 0) { - const explorerButton = - $body - .find( - '[id="workbench.parts.activitybar"] .action-item[role="button"][aria-label="Explorer"]', - ) - .get(0) || $body.find("a.codicon-explorer-view-icon").get(0); - if (explorerButton) { - explorerButton.click(); + // Look for the content GUID as a root folder in the explorer + const guidRow = $body.find( + `.explorer-viewlet .monaco-list-row[aria-level='1']:contains("${contentGuid}")`, + ); + return guidRow.length > 0; + } catch { + // DOM may be briefly invalid during a full page reload + return false; } - return Cypress.$(); // Return empty to retry after clicking - } - const explorer = $body.find(".explorer-viewlet:visible"); - return explorer.length > 0 ? cy.wrap(explorer) : Cypress.$(); - }), - 12, - 1000, - ).should("be.visible"); + }, + { + timeout: 60_000, + interval: 1_500, + errorMsg: `Content GUID "${contentGuid}" did not appear in the explorer within 60 seconds`, + }, + ); - cy.retryWithBackoff( - () => - cy.get("body", { timeout: 0 }).then(($body) => { - const items = $body.find(".explorer-viewlet .explorer-item"); - return items.length > 0 ? cy.wrap(items) : Cypress.$(); - }), - 10, - 1000, - ).should("exist"); + // Phase 6: Expand the GUID folder and verify expected files + cy.get(".explorer-viewlet") + .find('.monaco-list-row[aria-level="1"]') + .first() + .then(($row) => { + if ($row.attr("aria-expanded") === "false") { + cy.wrap($row).click(); + } + }); - cy.get(".explorer-viewlet") - .find('.monaco-list-row[aria-level="1"]') - .first() - .then(($row) => { - if ($row.attr("aria-expanded") === "false") { - cy.wrap($row).click(); - } + cy.get(".explorer-viewlet", { timeout: 30_000 }) + .should("contain", "manifest.json") + .and("contain", "index.html"); }); - - cy.retryWithBackoff( - () => - cy.get("body", { timeout: 0 }).then(($body) => { - const match = $body.find( - '.explorer-viewlet .explorer-item a > span:contains("manifest.json")', - ); - return match.length > 0 ? cy.wrap(match) : Cypress.$(); - }), - 10, - 1000, - ).should("be.visible"); - - cy.retryWithBackoff( - () => - cy.get("body", { timeout: 0 }).then(($body) => { - const match = $body.find( - '.explorer-viewlet .explorer-item a > span:contains("index.html")', - ); - return match.length > 0 ? cy.wrap(match) : Cypress.$(); - }), - 10, - 1000, - ).should("be.visible"); + }); }); }); From 83e7e632b31fe2bd3dc68b022ca57804468325f5 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:44:20 -0400 Subject: [PATCH 2/8] Fix Prettier formatting Co-Authored-By: Claude Opus 4.6 --- test/e2e/tests/open-connect-content.cy.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/e2e/tests/open-connect-content.cy.js b/test/e2e/tests/open-connect-content.cy.js index d05e56622..7e7af5ddc 100644 --- a/test/e2e/tests/open-connect-content.cy.js +++ b/test/e2e/tests/open-connect-content.cy.js @@ -31,9 +31,7 @@ describe("Open Connect Content", () => { const contentGuid = contentRecord.id; // Phase 4: Run "Open Connect Content" via command palette - cy.runCommandPaletteCommand( - "Posit Publisher: Open Connect Content", - ); + cy.runCommandPaletteCommand("Posit Publisher: Open Connect Content"); cy.quickInputType("Connect server URL", serverUrl); cy.quickInputType("Connect content GUID", contentGuid); From 8bb1d9a7d34a30daa9fad2ea01a9815ba063ccf4 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:16:42 -0400 Subject: [PATCH 3/8] fix: prevent filesystem provider from blocking on error dialogs The connect-content filesystem provider's stat() call was blocked by awaited UI dialogs (showErrorMessage, credential flow), preventing the explorer from rendering the workspace folder during e2e tests. - Move cache population before error notification in fetchAndCacheBundle - Fire-and-forget showErrorMessage and credential dialog instead of awaiting - Increase e2e waitUntil timeout to 120s and dismiss notification toasts - Add unit tests verifying stat/readDirectory resolve on fetch failure Co-Authored-By: Claude Opus 4.6 --- .../vscode/src/connect_content_fs.test.ts | 22 +++++++++++++++++++ extensions/vscode/src/connect_content_fs.ts | 17 ++++++++------ test/e2e/tests/open-connect-content.cy.js | 16 +++++++++++--- 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/extensions/vscode/src/connect_content_fs.test.ts b/extensions/vscode/src/connect_content_fs.test.ts index 417fb9b00..4ad9c406a 100644 --- a/extensions/vscode/src/connect_content_fs.test.ts +++ b/extensions/vscode/src/connect_content_fs.test.ts @@ -317,4 +317,26 @@ describe("ConnectContentFileSystemProvider", () => { expect(mockOpenConnectContent).toHaveBeenCalledTimes(1); }); }); + + describe("error handling", () => { + test("stat resolves with empty directory when bundle fetch fails", async () => { + mockOpenConnectContent.mockRejectedValue(new Error("network error")); + + const stat = await provider.stat( + makeUri(testAuthority, `/${testContentGuid}`), + ); + + expect(stat.type).toBe(DIRECTORY); + }); + + test("readDirectory returns empty list when bundle fetch fails", async () => { + mockOpenConnectContent.mockRejectedValue(new Error("network error")); + + const entries = await provider.readDirectory( + makeUri(testAuthority, `/${testContentGuid}`), + ); + + expect(entries).toHaveLength(0); + }); + }); }); diff --git a/extensions/vscode/src/connect_content_fs.ts b/extensions/vscode/src/connect_content_fs.ts index d501a0875..3267c7691 100644 --- a/extensions/vscode/src/connect_content_fs.ts +++ b/extensions/vscode/src/connect_content_fs.ts @@ -157,14 +157,13 @@ export class ConnectContentFileSystemProvider implements FileSystemProvider { logger.warn( `No credentials for ${normalizedServer}. Opening credential flow.`, ); - await commands.executeCommand( + // Launch the credential dialog without awaiting it. Blocking here would + // stall the filesystem provider's stat() call, preventing the explorer + // from rendering anything until the user completes the dialog. + void commands.executeCommand( Commands.HomeView.AddCredential, normalizedServer, ); - await state.refreshCredentials(); - if (hasCredentialForServer(normalizedServer, state)) { - return normalizedServer; - } throw new Error(`No valid credentials available for ${normalizedServer}`); } @@ -229,10 +228,14 @@ export class ConnectContentFileSystemProvider implements FileSystemProvider { logger.error( `Unable to fetch bundle ${contentGuid} for ${normalizedServerUrl}: ${message}`, ); - await window.showErrorMessage( + // Populate the cache with an empty directory BEFORE showing the error + // dialog. This unblocks the filesystem provider's stat() call so the + // explorer can render the (empty) folder immediately instead of hanging + // until the user dismisses the notification. + contentRoots.set(rootKey, createDirectoryEntry()); + void window.showErrorMessage( `Unable to open Connect content ${contentGuid}: ${message}`, ); - contentRoots.set(rootKey, createDirectoryEntry()); return; } } diff --git a/test/e2e/tests/open-connect-content.cy.js b/test/e2e/tests/open-connect-content.cy.js index 7e7af5ddc..a5c919c2e 100644 --- a/test/e2e/tests/open-connect-content.cy.js +++ b/test/e2e/tests/open-connect-content.cy.js @@ -50,6 +50,16 @@ describe("Open Connect Content", () => { const $body = Cypress.$("body"); if ($body.length === 0) return false; + // Dismiss any VS Code notification dialogs that might block + // rendering (e.g. error messages from failed bundle fetches). + $body + .find( + '.notifications-toasts .codicon-notifications-clear-all, .notification-toast .action-label[aria-label="Close"]', + ) + .each(function () { + this.click(); + }); + // Ensure the Explorer sidebar is visible; click its icon if not if ($body.find(".explorer-viewlet:visible").length === 0) { const explorerBtn = @@ -74,9 +84,9 @@ describe("Open Connect Content", () => { } }, { - timeout: 60_000, - interval: 1_500, - errorMsg: `Content GUID "${contentGuid}" did not appear in the explorer within 60 seconds`, + timeout: 120_000, + interval: 2_000, + errorMsg: `Content GUID "${contentGuid}" did not appear in the explorer within 120 seconds`, }, ); From 21ef9a96b343a8026d257474c70b65f2469f40ed Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:58:11 -0400 Subject: [PATCH 4/8] chore: add diagnostic logging to open-connect-content e2e test Screenshots show the workspace switch never happens on preview and 2023.03.0 - the URL stays at /?folder=/home/coder/workspace. Adding cy.log diagnostics to capture: content record values, URL after command, explorer state, notification messages, and quick input visibility during the waitUntil polling loop. Co-Authored-By: Claude Opus 4.6 --- test/e2e/tests/open-connect-content.cy.js | 39 +++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/e2e/tests/open-connect-content.cy.js b/test/e2e/tests/open-connect-content.cy.js index a5c919c2e..80b3093d4 100644 --- a/test/e2e/tests/open-connect-content.cy.js +++ b/test/e2e/tests/open-connect-content.cy.js @@ -30,6 +30,12 @@ describe("Open Connect Content", () => { const serverUrl = contentRecord.server_url; const contentGuid = contentRecord.id; + cy.log(`**Content record server_url:** ${serverUrl}`); + cy.log(`**Content record id (GUID):** ${contentGuid}`); + cy.log( + `**Full content record:** ${JSON.stringify(contentRecord, null, 2)}`, + ); + // Phase 4: Run "Open Connect Content" via command palette cy.runCommandPaletteCommand("Posit Publisher: Open Connect Content"); cy.quickInputType("Connect server URL", serverUrl); @@ -38,18 +44,51 @@ describe("Open Connect Content", () => { // Wait for the quick input to close, confirming submission cy.get(".quick-input-widget").should("not.be.visible"); + // Log URL after command completes to verify workspace switch + cy.window().then((win) => { + cy.log(`**URL after command:** ${win.location.href}`); + }); + // Phase 5: Wait for workspace switch to complete. // The extension has two code paths: updateWorkspaceFolders (no reload) // and openFolder (full page reload). We handle both by waiting for the // workbench to be present, then polling the explorer for the content GUID. cy.get(".monaco-workbench", { timeout: 120_000 }).should("be.visible"); + let pollCount = 0; cy.waitUntil( () => { try { + pollCount++; const $body = Cypress.$("body"); if ($body.length === 0) return false; + // Log diagnostics every 10 polls (~20 seconds) + if (pollCount % 10 === 1) { + const url = window.location.href; + const notifications = $body + .find(".notification-toast .notification-list-item-message") + .map(function () { + return Cypress.$(this).text(); + }) + .get(); + const explorerVisible = + $body.find(".explorer-viewlet:visible").length > 0; + const explorerRows = $body + .find(".explorer-viewlet .monaco-list-row[aria-level='1']") + .map(function () { + return Cypress.$(this).text().substring(0, 80); + }) + .get(); + const quickInputVisible = + $body.find(".quick-input-widget:visible").length > 0; + + Cypress.log({ + name: "poll-debug", + message: `Poll #${pollCount} | URL: ${url} | Explorer visible: ${explorerVisible} | Quick input visible: ${quickInputVisible} | Root rows: [${explorerRows.join(", ")}] | Notifications: [${notifications.join("; ")}]`, + }); + } + // Dismiss any VS Code notification dialogs that might block // rendering (e.g. error messages from failed bundle fetches). $body From 06e462980748ee3790035d9ba341f2aba37f60b9 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:55:27 -0400 Subject: [PATCH 5/8] fix: return stat() immediately for root connect-content URIs The workspace switch via updateWorkspaceFolders was silently failing because code-server validates new workspace folders by calling stat() on the root URI. The stat() call blocked on the bundle fetch (which depends on Go backend readiness and Connect API), causing code-server to reject the workspace change entirely. Now stat() returns an immediate directory entry for root content URIs (/{guid}) without waiting for the bundle. The bundle fetch is kicked off in the background and the actual content loads lazily when readDirectory() is called by the explorer. Co-Authored-By: Claude Opus 4.6 --- .../vscode/src/connect_content_fs.test.ts | 6 ++- extensions/vscode/src/connect_content_fs.ts | 18 +++++++++ test/e2e/tests/open-connect-content.cy.js | 39 ------------------- 3 files changed, 23 insertions(+), 40 deletions(-) diff --git a/extensions/vscode/src/connect_content_fs.test.ts b/extensions/vscode/src/connect_content_fs.test.ts index 4ad9c406a..024b25d0a 100644 --- a/extensions/vscode/src/connect_content_fs.test.ts +++ b/extensions/vscode/src/connect_content_fs.test.ts @@ -307,7 +307,11 @@ describe("ConnectContentFileSystemProvider", () => { ]); mockOpenConnectContent.mockResolvedValue({ data: bundle.buffer }); - await provider.stat(makeUri(testAuthority, `/${testContentGuid}`)); + // readDirectory triggers the full fetch, unlike stat which returns + // immediately for root URIs. + await provider.readDirectory( + makeUri(testAuthority, `/${testContentGuid}`), + ); expect(mockOpenConnectContent).toHaveBeenCalledTimes(1); await provider.stat(makeUri(testAuthority, `/${testContentGuid}`)); diff --git a/extensions/vscode/src/connect_content_fs.ts b/extensions/vscode/src/connect_content_fs.ts index 3267c7691..d3a2901f3 100644 --- a/extensions/vscode/src/connect_content_fs.ts +++ b/extensions/vscode/src/connect_content_fs.ts @@ -109,6 +109,16 @@ export class ConnectContentFileSystemProvider implements FileSystemProvider { async stat(uri: Uri): Promise { logger.info(`connect-content stat ${uri.toString()}`); + // For root connect-content URIs (e.g. /{guid}), return a directory stat + // immediately so that updateWorkspaceFolders / openFolder can validate the + // workspace folder without waiting for the (potentially slow) bundle fetch. + // The actual bundle is fetched lazily when readDirectory() is called. + if (isRootContentUri(uri)) { + // Kick off the bundle fetch in the background so readDirectory() is faster + void this.ensureBundleForUri(uri); + const cached = contentRoots.get(uri.toString()); + return statFromEntry(cached ?? createDirectoryEntry()); + } const entry = await this.resolveEntry(uri); return statFromEntry(entry); } @@ -321,6 +331,14 @@ function decodeAuthorityAsServerUrl(authority: string): string | null { return `https://${authority}`; } +// Check whether a URI points to the root of a content GUID +// (i.e. path is "/{guid}" with no sub-path segments). +function isRootContentUri(uri: Uri): boolean { + const trimmed = uri.path.replace(/^\/+/, "").replace(/\/+$/, ""); + // Root URIs have exactly one segment (the GUID) with no slashes + return trimmed.length > 0 && !trimmed.includes("/"); +} + function parseConnectContentUri(uri: Uri) { if (uri.scheme !== CONNECT_CONTENT_SCHEME) { return null; diff --git a/test/e2e/tests/open-connect-content.cy.js b/test/e2e/tests/open-connect-content.cy.js index 80b3093d4..a5c919c2e 100644 --- a/test/e2e/tests/open-connect-content.cy.js +++ b/test/e2e/tests/open-connect-content.cy.js @@ -30,12 +30,6 @@ describe("Open Connect Content", () => { const serverUrl = contentRecord.server_url; const contentGuid = contentRecord.id; - cy.log(`**Content record server_url:** ${serverUrl}`); - cy.log(`**Content record id (GUID):** ${contentGuid}`); - cy.log( - `**Full content record:** ${JSON.stringify(contentRecord, null, 2)}`, - ); - // Phase 4: Run "Open Connect Content" via command palette cy.runCommandPaletteCommand("Posit Publisher: Open Connect Content"); cy.quickInputType("Connect server URL", serverUrl); @@ -44,51 +38,18 @@ describe("Open Connect Content", () => { // Wait for the quick input to close, confirming submission cy.get(".quick-input-widget").should("not.be.visible"); - // Log URL after command completes to verify workspace switch - cy.window().then((win) => { - cy.log(`**URL after command:** ${win.location.href}`); - }); - // Phase 5: Wait for workspace switch to complete. // The extension has two code paths: updateWorkspaceFolders (no reload) // and openFolder (full page reload). We handle both by waiting for the // workbench to be present, then polling the explorer for the content GUID. cy.get(".monaco-workbench", { timeout: 120_000 }).should("be.visible"); - let pollCount = 0; cy.waitUntil( () => { try { - pollCount++; const $body = Cypress.$("body"); if ($body.length === 0) return false; - // Log diagnostics every 10 polls (~20 seconds) - if (pollCount % 10 === 1) { - const url = window.location.href; - const notifications = $body - .find(".notification-toast .notification-list-item-message") - .map(function () { - return Cypress.$(this).text(); - }) - .get(); - const explorerVisible = - $body.find(".explorer-viewlet:visible").length > 0; - const explorerRows = $body - .find(".explorer-viewlet .monaco-list-row[aria-level='1']") - .map(function () { - return Cypress.$(this).text().substring(0, 80); - }) - .get(); - const quickInputVisible = - $body.find(".quick-input-widget:visible").length > 0; - - Cypress.log({ - name: "poll-debug", - message: `Poll #${pollCount} | URL: ${url} | Explorer visible: ${explorerVisible} | Quick input visible: ${quickInputVisible} | Root rows: [${explorerRows.join(", ")}] | Notifications: [${notifications.join("; ")}]`, - }); - } - // Dismiss any VS Code notification dialogs that might block // rendering (e.g. error messages from failed bundle fetches). $body From 3492f32157ddf63a221a5c307f3169e70f536c1a Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:45:27 -0400 Subject: [PATCH 6/8] fix: make readDirectory non-blocking for root connect-content URIs On Connect 2023.03.0, code-server was dropping the connect-content workspace folder because readDirectory() blocked on the bundle fetch. When the bundle takes too long (slow Connect API, Go backend startup), code-server rejects the workspace change. Now readDirectory() returns an empty list immediately for root URIs if the bundle hasn't been cached yet, and fires a FileChangeType.Changed event when the fetch completes to trigger VS Code to refresh the tree. Co-Authored-By: Claude Opus 4.6 --- extensions/vscode/src/connect_content_fs.test.ts | 16 ++++++++++++---- extensions/vscode/src/connect_content_fs.ts | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/extensions/vscode/src/connect_content_fs.test.ts b/extensions/vscode/src/connect_content_fs.test.ts index 024b25d0a..94b297f7e 100644 --- a/extensions/vscode/src/connect_content_fs.test.ts +++ b/extensions/vscode/src/connect_content_fs.test.ts @@ -19,7 +19,9 @@ vi.mock("src/logging", () => ({ vi.mock("vscode", () => ({ EventEmitter: class { event = vi.fn(); + fire = vi.fn(); }, + FileChangeType: { Changed: 1, Created: 2, Deleted: 3 }, Disposable: class { constructor(fn?: () => void) { this.dispose = fn ?? (() => {}); @@ -187,6 +189,12 @@ describe("ConnectContentFileSystemProvider", () => { ]); mockOpenConnectContent.mockResolvedValue({ data: bundle.buffer }); + // Populate the cache via a sub-path read (root readDirectory is + // non-blocking and returns [] when the cache is empty). + await provider.readFile( + makeUri(testAuthority, `/${testContentGuid}/manifest.json`), + ); + const entries = await provider.readDirectory( makeUri(testAuthority, `/${testContentGuid}`), ); @@ -307,10 +315,10 @@ describe("ConnectContentFileSystemProvider", () => { ]); mockOpenConnectContent.mockResolvedValue({ data: bundle.buffer }); - // readDirectory triggers the full fetch, unlike stat which returns - // immediately for root URIs. - await provider.readDirectory( - makeUri(testAuthority, `/${testContentGuid}`), + // readFile on a sub-path triggers the full synchronous fetch + // (unlike stat/readDirectory on root URIs which return immediately). + await provider.readFile( + makeUri(testAuthority, `/${testContentGuid}/file.txt`), ); expect(mockOpenConnectContent).toHaveBeenCalledTimes(1); diff --git a/extensions/vscode/src/connect_content_fs.ts b/extensions/vscode/src/connect_content_fs.ts index d3a2901f3..af1ab1996 100644 --- a/extensions/vscode/src/connect_content_fs.ts +++ b/extensions/vscode/src/connect_content_fs.ts @@ -4,6 +4,7 @@ import { Disposable, EventEmitter, FileChangeEvent, + FileChangeType, FileStat, FileSystemError, FileSystemProvider, @@ -125,6 +126,20 @@ export class ConnectContentFileSystemProvider implements FileSystemProvider { async readDirectory(uri: Uri): Promise<[string, FileType][]> { logger.info(`connect-content readDirectory ${uri.toString()}`); + // For root URIs, return whatever is cached immediately. If the bundle + // hasn't been fetched yet, return an empty list and notify VS Code to + // refresh the tree once the fetch completes. + if (isRootContentUri(uri)) { + const cached = contentRoots.get(uri.toString()); + if (cached) { + return listDirectory(cached); + } + // Bundle not ready yet — start fetching and notify when done + this.ensureBundleForUri(uri).then(() => { + this.fileChangeEmitter.fire([{ type: FileChangeType.Changed, uri }]); + }); + return []; + } const entry = await this.resolveEntry(uri); return listDirectory(entry); } From e339a80f0aa47a38b0dfad98154c9efd657c852d Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:39:46 -0400 Subject: [PATCH 7/8] fix: remove aria-level restriction from explorer GUID selector In multi-root workspace mode (triggered by updateWorkspaceFolders in code-server), the workspace name appears at aria-level 1 and the connect-content folder drops to aria-level 2. The previous selector only checked level 1, missing the GUID folder entirely. Now the test searches for the GUID anywhere in the explorer tree regardless of nesting level. Co-Authored-By: Claude Opus 4.6 --- test/e2e/tests/open-connect-content.cy.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/e2e/tests/open-connect-content.cy.js b/test/e2e/tests/open-connect-content.cy.js index a5c919c2e..96ecbf1f0 100644 --- a/test/e2e/tests/open-connect-content.cy.js +++ b/test/e2e/tests/open-connect-content.cy.js @@ -73,9 +73,11 @@ describe("Open Connect Content", () => { return false; } - // Look for the content GUID as a root folder in the explorer + // Look for the content GUID anywhere in the explorer tree. + // In multi-root workspace mode (code-server), the folder may + // appear at aria-level 2 instead of 1. const guidRow = $body.find( - `.explorer-viewlet .monaco-list-row[aria-level='1']:contains("${contentGuid}")`, + `.explorer-viewlet .monaco-list-row:contains("${contentGuid}")`, ); return guidRow.length > 0; } catch { @@ -92,7 +94,7 @@ describe("Open Connect Content", () => { // Phase 6: Expand the GUID folder and verify expected files cy.get(".explorer-viewlet") - .find('.monaco-list-row[aria-level="1"]') + .find(`.monaco-list-row:contains("${contentGuid}")`) .first() .then(($row) => { if ($row.attr("aria-expanded") === "false") { From dd0d350df4c0d83e05e29f97b032a2034136db9a Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:35:02 -0400 Subject: [PATCH 8/8] chore: add explorer DOM diagnostics to waitUntil error message Include the actual explorer row contents (text + aria-level) in the failure error message so we can see exactly what the DOM contains when the GUID selector doesn't match. Co-Authored-By: Claude Opus 4.6 --- test/e2e/tests/open-connect-content.cy.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/test/e2e/tests/open-connect-content.cy.js b/test/e2e/tests/open-connect-content.cy.js index 96ecbf1f0..8365fc6ee 100644 --- a/test/e2e/tests/open-connect-content.cy.js +++ b/test/e2e/tests/open-connect-content.cy.js @@ -44,6 +44,8 @@ describe("Open Connect Content", () => { // workbench to be present, then polling the explorer for the content GUID. cy.get(".monaco-workbench", { timeout: 120_000 }).should("be.visible"); + // Track the last diagnostic snapshot for the error message + let lastDiag = ""; cy.waitUntil( () => { try { @@ -73,6 +75,16 @@ describe("Open Connect Content", () => { return false; } + // Collect diagnostic info for debugging + const allRows = $body + .find(".explorer-viewlet .monaco-list-row") + .map(function () { + const $el = Cypress.$(this); + return `[level=${$el.attr("aria-level")}] "${$el.text().substring(0, 60)}"`; + }) + .get(); + lastDiag = `rows(${allRows.length}): ${allRows.slice(0, 10).join(" | ")}`; + // Look for the content GUID anywhere in the explorer tree. // In multi-root workspace mode (code-server), the folder may // appear at aria-level 2 instead of 1. @@ -88,7 +100,8 @@ describe("Open Connect Content", () => { { timeout: 120_000, interval: 2_000, - errorMsg: `Content GUID "${contentGuid}" did not appear in the explorer within 120 seconds`, + errorMsg: () => + `Content GUID "${contentGuid}" did not appear in the explorer within 120 seconds. Explorer state: ${lastDiag}`, }, );