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)) {
// 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);
}
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;
}
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 (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 (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 (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