Skip to content
36 changes: 35 additions & 1 deletion extensions/vscode/src/connect_content_fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? (() => {});
Expand Down Expand Up @@ -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}`),
);
Expand Down Expand Up @@ -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}`));
Expand All @@ -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);
});
});
});
50 changes: 43 additions & 7 deletions extensions/vscode/src/connect_content_fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Disposable,
EventEmitter,
FileChangeEvent,
FileChangeType,
FileStat,
FileSystemError,
FileSystemProvider,
Expand Down Expand Up @@ -109,12 +110,36 @@ export class ConnectContentFileSystemProvider implements FileSystemProvider {

async stat(uri: Uri): Promise<FileStat> {
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)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't understand why we only returned right away if we were looking at a root content Uri. I did a bit of digging and VS Code stats the root URI during workspace folder validation. Since other sub-paths are accessed after the explorer is rendered we only need the special handling here.

// Kick off the bundle fetch in the background so readDirectory() is faster
void this.ensureBundleForUri(uri);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does void do here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

claude is indicating that this is to avoid linting errors because this is an asynchronous promise that we are explicitly not awaiting on.

https://typescript-eslint.io/rules/no-floating-promises/

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting we don't use plugin:@typescript-eslint/recommended-type-checked but probably still a valid call.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It discards the return value of this.ensureBundleForUri so doesn't use the Promise at all.

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);
}
Expand Down Expand Up @@ -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;
}
Comment on lines +185 to -167

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is right. This means we would throw an error immediately while trying to add a credential doesn't it?

You can see the bad behavior if you try to open connect content on a server that you don't have credentials for yet.

It leaves you in a bad state where you have an error for no valid credentials, and an empty temporary workspace, which isn't great.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed this to

    logger.warn(
      `No credentials for ${normalizedServer}. Opening credential flow.`,
    );
    // Await the credential dialog so the user can authenticate on the first
    // try. This runs inside the background bundle fetch, so it won't block
    // stat() or readDirectory() — those return immediately for root URIs.
    await commands.executeCommand(
      Commands.HomeView.AddCredential,
      normalizedServer,
    );
    await state.refreshCredentials();
    if (hasCredentialForServer(normalizedServer, state)) {
      return normalizedServer;
    }
    throw new Error(`No valid credentials available for ${normalizedServer}`);

and that worked much better. I didn't get the "no valid credentials" error, but it take a bit of time for the files to load in. I don't think that is a blocker, but something that could be improved in the future. I thought it wasn't working for a second.

throw new Error(`No valid credentials available for ${normalizedServer}`);
}

Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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;
Expand Down
180 changes: 84 additions & 96 deletions test/e2e/tests/open-connect-content.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

describe("Open Connect Content", () => {
before(() => {
cy.initializeConnect();

Check warning on line 5 in test/e2e/tests/open-connect-content.cy.js

View workflow job for this annotation

GitHub Actions / e2e / e2e (release)

Unexpected pending mocha test

Check warning on line 5 in test/e2e/tests/open-connect-content.cy.js

View workflow job for this annotation

GitHub Actions / e2e / e2e (preview)

Unexpected pending mocha test

Check warning on line 5 in test/e2e/tests/open-connect-content.cy.js

View workflow job for this annotation

GitHub Actions / e2e / e2e (2025.03.0)

Unexpected pending mocha test

Check warning on line 5 in test/e2e/tests/open-connect-content.cy.js

View workflow job for this annotation

GitHub Actions / e2e / e2e (2023.03.0)

Unexpected pending mocha test
});

afterEach(() => {
Expand All @@ -18,119 +18,107 @@
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");
});
});
});
});
Loading