diff --git a/extensions/vscode/src/connect_content_fs.test.ts b/extensions/vscode/src/connect_content_fs.test.ts index 417fb9b00..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,7 +315,11 @@ describe("ConnectContentFileSystemProvider", () => { ]); mockOpenConnectContent.mockResolvedValue({ data: bundle.buffer }); - await provider.stat(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); await provider.stat(makeUri(testAuthority, `/${testContentGuid}`)); @@ -317,4 +329,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..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, @@ -109,12 +110,36 @@ 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); } 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); } @@ -157,14 +182,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 +253,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; } } @@ -318,6 +346,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 e834911a0..8365fc6ee 100644 --- a/test/e2e/tests/open-connect-content.cy.js +++ b/test/e2e/tests/open-connect-content.cy.js @@ -18,119 +18,107 @@ 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"); + // Track the last diagnostic snapshot for the error message + let lastDiag = ""; + 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"); - }); - }); - }); - - cy.retryWithBackoff( - () => - cy.get("body", { timeout: 0 }).then(($body) => { - if ($body.find(".explorer-viewlet:visible").length === 0) { - const explorerButton = + // Dismiss any VS Code notification dialogs that might block + // rendering (e.g. error messages from failed bundle fetches). $body .find( - '[id="workbench.parts.activitybar"] .action-item[role="button"][aria-label="Explorer"]', + '.notifications-toasts .codicon-notifications-clear-all, .notification-toast .action-label[aria-label="Close"]', ) - .get(0) || $body.find("a.codicon-explorer-view-icon").get(0); - if (explorerButton) { - explorerButton.click(); - } - 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"); + .each(function () { + this.click(); + }); - 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"); + // 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.get(".explorer-viewlet") - .find('.monaco-list-row[aria-level="1"]') - .first() - .then(($row) => { - if ($row.attr("aria-expanded") === "false") { - cy.wrap($row).click(); - } - }); + // 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(" | ")}`; - 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"); + // 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:contains("${contentGuid}")`, + ); + return guidRow.length > 0; + } catch { + // DOM may be briefly invalid during a full page reload + return false; + } + }, + { + timeout: 120_000, + interval: 2_000, + errorMsg: () => + `Content GUID "${contentGuid}" did not appear in the explorer within 120 seconds. Explorer state: ${lastDiag}`, + }, + ); + + // Phase 6: Expand the GUID folder and verify expected files + cy.get(".explorer-viewlet") + .find(`.monaco-list-row:contains("${contentGuid}")`) + .first() + .then(($row) => { + if ($row.attr("aria-expanded") === "false") { + cy.wrap($row).click(); + } + }); - 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"); + cy.get(".explorer-viewlet", { timeout: 30_000 }) + .should("contain", "manifest.json") + .and("contain", "index.html"); + }); + }); }); });