diff --git a/CHANGELOG.md b/CHANGELOG.md index 67a5e70d79..f894917fe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ from published versions since it shows up in the VS Code extension changelog tab and is confusing to users. Add it back between releases if needed. --> +## Unreleased + +### Fixed + +- Reopen a workspace on the SSH host it was last opened on. v1.16.1 moved + existing workspaces onto a host named after your editor, and an editor + remembers a window by its full address, host included, so the workspace + looked new: Cursor's chats, your window layout, and anything else kept per + workspace appeared to be gone. Nothing was deleted. The host now follows the + workspace wherever you open it from: the Coder panel, a dashboard or + devcontainer link, or **File > Open Recent**, and whether it opens as a + folder or as a multi-root workspace. Only a workspace you have never opened + gets your editor's host. +- Mark the shared `coder-vscode` host as `(legacy)` in **File > Open Recent**, + so a workspace listed on both hosts is no longer two identical lines. The + folder picker lists each folder once. +- Look for a workspace's remote logs under the host it opens on when collecting + a support bundle for a workspace you are not connected to. The lookup always + used a host named after your editor, so a `remote.SSH.serverInstallPath` set + for the shared host was missed and the bundle fell back to the default. +- Serve the shared `coder-vscode` host from one generated SSH config file + instead of one per editor, so a connection over it always uses the CLI and + credentials of the editor that started it. **Coder: Open Generated SSH + Configuration File** can open that file too. + ## [v1.16.1](https://github.com/coder/vscode-coder/releases/tag/v1.16.1) 2026-08-24 ### Added diff --git a/src/commands.ts b/src/commands.ts index 4bab9e9fa2..8f9268b6d5 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -51,8 +51,13 @@ import { } from "./supportBundle/remoteServerDataPath"; import { runExportTelemetryCommand } from "./telemetry/export/command"; import { + LegacyEditorId, + currentEditorId, + hostEditorId, isRemoteAuthorityCompatible, parseRemoteAuthority, + sshHostOf, + toLegacyAuthority, toRemoteAuthority, } from "./util/authority"; import { openInBrowser, toSafeHost } from "./util/uri"; @@ -87,6 +92,12 @@ import type { PongMessage, } from "./workspace/duplicateWorkspaceIpc"; +/** One entry from the private `_workbench.getRecentlyOpened` command. */ +interface RecentlyOpened { + folderUri?: vscode.Uri; + workspace?: { configPath?: vscode.Uri }; +} + const NO_SSH_CONFIG_MESSAGE = "No SSH config has been generated yet. It is written when you connect to a workspace."; @@ -572,14 +583,15 @@ export class Commands { ); } - /** Open this editor's generated SSH config, picking a deployment when several exist. */ + /** Open the generated SSH config, picking a file when several exist. */ public async openSshConfig(): Promise { - const hostname = await this.pickSshHostname(); - if (!hostname) { + const configPath = + this.connectedSshConfigPath() ?? (await this.pickSshConfigPath()); + if (!configPath) { return; } try { - await openFile(this.pathResolver.getSshConfigPath(hostname)); + await openFile(configPath); } catch { vscode.window.showInformationMessage(NO_SSH_CONFIG_MESSAGE); return; @@ -590,36 +602,51 @@ export class Commands { ); } - /** A connected window resolves to its own deployment; otherwise ask. */ - private async pickSshHostname(): Promise { + /** The file serving this window's connection, if it has one. */ + private connectedSshConfigPath(): string | undefined { try { // remoteAuthority is a proposed API; our own vscode module may not read it. - const remoteAuthority = vscodeProposed.env.remoteAuthority; - if (remoteAuthority) { - const parts = parseRemoteAuthority(remoteAuthority); - if (parts) { - return parts.safeHostname; - } - } + const authority = vscodeProposed.env.remoteAuthority; + const parts = authority ? parseRemoteAuthority(authority) : null; + return parts + ? this.pathResolver.getSshConfigPath( + parts.safeHostname, + hostEditorId(parts.sshHost), + ) + : undefined; } catch { - // Malformed Coder authority or unavailable API; fall through to the picker. + // Malformed Coder authority or unavailable API; fall back to the picker. + return undefined; } - const hostnames = ( - await readdirOrEmpty(this.pathResolver.getSshConfigDir()) - ) - .map((file) => this.pathResolver.parseSshConfigFile(file)) - .filter((name) => name !== undefined); - if (hostnames.length === 0) { + } + + /** Ask which config to open, of this editor's prefixes and the legacy one. */ + private async pickSshConfigPath(): Promise { + const files = await readdirOrEmpty(this.pathResolver.getSshConfigDir()); + const items = [...new Set([currentEditorId(), LegacyEditorId])].flatMap( + (editorId) => + files + .map((file) => this.pathResolver.parseSshConfigFile(file, editorId)) + .filter((safeHostname) => safeHostname !== undefined) + .map((safeHostname) => ({ + label: safeHostname, + // Which hosts it serves; two files can share a deployment. + description: `coder-${editorId}.${safeHostname}--*`, + path: this.pathResolver.getSshConfigPath(safeHostname, editorId), + })), + ); + if (items.length === 0) { vscode.window.showInformationMessage(NO_SSH_CONFIG_MESSAGE); return undefined; } - if (hostnames.length === 1) { - return hostnames[0]; + if (items.length === 1) { + return items[0].path; } - return vscode.window.showQuickPick(hostnames, { + const picked = await vscode.window.showQuickPick(items, { title: "Open generated SSH configuration", placeHolder: "Select a deployment", }); + return picked?.path; } /** @@ -1107,6 +1134,43 @@ export class Commands { ); } + /** Recently opened folders, and every entry including workspace files. */ + private async recentlyOpened(): Promise<{ + folders: vscode.Uri[]; + entries: vscode.Uri[]; + }> { + let recents: RecentlyOpened[] = []; + try { + // Private command; without it there is just no history to reuse. + const output: { workspaces?: RecentlyOpened[] } = + await vscode.commands.executeCommand("_workbench.getRecentlyOpened"); + recents = output?.workspaces ?? []; + } catch (error) { + this.logger.warn("Failed to read recently opened folders", error); + } + return { + folders: recents.flatMap((recent) => recent.folderUri ?? []), + entries: recents.flatMap( + (recent) => recent.folderUri ?? recent.workspace?.configPath ?? [], + ), + }; + } + + /** + * The host this workspace was last opened on, or this editor's own. Only the + * host carries over, never a recent entry's authority itself: that can name + * another devcontainer of the same workspace. + */ + private reusableAuthority(recents: vscode.Uri[], target: string): string { + const legacyAuthority = toLegacyAuthority(target); + const legacyHost = sshHostOf(legacyAuthority); + const currentHost = sshHostOf(target); + const lastUsed = recents + .map((uri) => sshHostOf(uri.authority)) + .find((host) => host === currentHost || host === legacyHost); + return lastUsed === legacyHost ? legacyAuthority : target; + } + private async runOpenDevContainer( workspaceOwner: string, workspaceName: string, @@ -1144,20 +1208,25 @@ export class Commands { ).toString("hex"); const type = localWorkspaceFolder ? "dev-container" : "attached-container"; - const devContainerAuthority = `${type}+${devContainer}@${remoteAuthority}`; + const target = `${type}+${devContainer}@${remoteAuthority}`; let newWindow = true; if (!vscode.workspace.workspaceFolders?.length) { newWindow = false; } + const { entries } = await this.recentlyOpened(); + const authority = this.reusableAuthority(entries, target); + + this.logger.info("Opening devcontainer", { remoteAuthority: authority }); + // Only set the memento when opening a new folder await this.mementoManager.setStartupMode("start"); await vscode.commands.executeCommand( "vscode.openFolder", vscode.Uri.from({ scheme: "vscode-remote", - authority: devContainerAuthority, + authority, path: devContainerFolder, }), newWindow, @@ -1252,7 +1321,7 @@ export class Commands { agentName, client: this.extensionClient, workspaceId: createWorkspaceIdentifier(item.workspace), - remoteAuthority: this.toWorkspaceAuthority( + remoteAuthority: await this.toWorkspaceAuthority( this.extensionClient, item.workspace, agentName, @@ -1279,7 +1348,7 @@ export class Commands { status: "selected", client: this.extensionClient, workspaceId: createWorkspaceIdentifier(pick.workspace), - remoteAuthority: this.toWorkspaceAuthority( + remoteAuthority: await this.toWorkspaceAuthority( this.extensionClient, pick.workspace, ), @@ -1292,20 +1361,24 @@ export class Commands { * Reconstruct the authority Remote-SSH would use, defaulting to the * first agent like the CLI does. */ - private toWorkspaceAuthority( + private async toWorkspaceAuthority( client: CoderApi, workspace: Workspace, agentName?: string, - ): string | undefined { + ): Promise { const baseUrl = client.getAxiosInstance().defaults.baseURL; if (!baseUrl) { return undefined; } - return toRemoteAuthority( - baseUrl, - workspace.owner_name, - workspace.name, - agentName ?? extractAgents(workspace.latest_build.resources)[0]?.name, + const { entries } = await this.recentlyOpened(); + return this.reusableAuthority( + entries, + toRemoteAuthority( + baseUrl, + workspace.owner_name, + workspace.name, + agentName ?? extractAgents(workspace.latest_build.resources)[0]?.name, + ), ); } @@ -1510,7 +1583,7 @@ export class Commands { ...options, }; let { folderPath } = options; - const remoteAuthority = toRemoteAuthority( + let remoteAuthority = toRemoteAuthority( baseUrl, workspace.owner_name, workspace.name, @@ -1526,25 +1599,26 @@ export class Commands { folderPath = agent.expanded_directory; } + const { folders, entries } = await this.recentlyOpened(); // If the agent had no folder or we have been asked to open the most recent, // we can try to open a recently opened folder/workspace. if (!folderPath || openRecent) { - const output: { - workspaces: Array<{ folderUri: vscode.Uri; remoteAuthority: string }>; - } = await vscode.commands.executeCommand("_workbench.getRecentlyOpened"); - const opened = output.workspaces.filter((opened) => - isRemoteAuthorityCompatible( - opened.folderUri?.authority, - remoteAuthority, + // One entry per folder: the same path can be in the list once per host. + const paths = [ + ...new Set( + folders + .filter((uri) => + isRemoteAuthorityCompatible(uri.authority, remoteAuthority), + ) + .map((uri) => uri.path), ), - ); + ]; // openRecent will always use the most recent. Otherwise, if there are // multiple we ask the user which to use. - if (opened.length === 1 || (opened.length > 1 && openRecent)) { - folderPath = opened[0].folderUri.path; - } else if (opened.length > 1) { - const items = opened.map((f) => f.folderUri.path); - folderPath = await vscode.window.showQuickPick(items, { + if (paths.length === 1 || (paths.length > 1 && openRecent)) { + folderPath = paths[0]; + } else if (paths.length > 1) { + folderPath = await vscode.window.showQuickPick(paths, { title: "Select a recently opened folder", }); if (!folderPath) { @@ -1553,6 +1627,7 @@ export class Commands { } } } + remoteAuthority = this.reusableAuthority(entries, remoteAuthority); // Only set the memento when opening a new folder/window await this.mementoManager.setStartupMode("start"); diff --git a/src/core/pathResolver.ts b/src/core/pathResolver.ts index ddd862f41a..aa47edb461 100644 --- a/src/core/pathResolver.ts +++ b/src/core/pathResolver.ts @@ -71,17 +71,27 @@ export class PathResolver { return path.join(platformDataDir(), "coder.coder-remote", "ssh"); } - /** This editor's generated SSH config for one deployment. */ - public getSshConfigPath(safeHostname: string): string { + /** + * Generated SSH config for one deployment, named after the editor in the + * host prefix rather than the one writing it: two files declaring the same + * host pattern would leave glob order to decide which one ssh reads. + */ + public getSshConfigPath( + safeHostname: string, + editorId: string = currentEditorId(), + ): string { return path.join( this.getSshConfigDir(), - `${currentEditorId()}--${safeHostname}${SSH_CONFIG_EXT}`, + `${editorId}--${safeHostname}${SSH_CONFIG_EXT}`, ); } - /** The deployment hostname if this editor generated the file, else undefined. */ - public parseSshConfigFile(fileName: string): string | undefined { - const prefix = `${currentEditorId()}--`; + /** The deployment hostname if `editorId` named the file, else undefined. */ + public parseSshConfigFile( + fileName: string, + editorId: string = currentEditorId(), + ): string | undefined { + const prefix = `${editorId}--`; return fileName.startsWith(prefix) && fileName.endsWith(SSH_CONFIG_EXT) ? fileName.slice(prefix.length, -SSH_CONFIG_EXT.length) : undefined; diff --git a/src/remote/remote.ts b/src/remote/remote.ts index ffcdcfab6e..612da18f57 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -41,8 +41,9 @@ import { escapeCommandArg, expandPath } from "../util"; import { type AuthorityParts, classifySshHost, + hostEditorId, parseRemoteAuthority, - retargetRemoteAuthority, + sshHostOf, } from "../util/authority"; import { createStatusBarItem } from "../util/statusBar"; import { vscodeProposed } from "../vscodeProposed"; @@ -107,6 +108,29 @@ interface RemoteSetupContext { disposables: vscode.Disposable[]; } +/** + * What Open Recent shows after the path. VS Code splits the label on the + * separator, so "/" would display "/home/kyle [Coder: kyle/workspace]" as + * "workspace] /home/kyle [Coder: kyle"; "∕" looks the same in the UI font. + */ +export function workspaceLabelSuffix( + remoteAuthority: string, + owner: string, + workspace: string, + agent?: string, +): string { + let suffix = `Coder: ${owner}∕${workspace}`; + if (agent) { + suffix += `∕${agent}`; + } + // Mark the shared host, so a workspace with an entry on each is not two + // identical lines. Only a fork sees it; in VS Code it is the only host. + const sshHost = sshHostOf(remoteAuthority); + return sshHost && classifySshHost(sshHost) === "legacy" + ? `${suffix} (legacy)` + : suffix; +} + export class Remote { private readonly logger: Logger; private readonly pathResolver: PathResolver; @@ -160,15 +184,6 @@ export class Remote { return; } - // parseRemoteAuthority returned null for foreign hosts, so this is - // either the current editor's authority or a migratable legacy one. - if (classifySshHost(parts.sshHost) === "legacy") { - if (await this.migrateLegacyAuthority(remoteAuthority, startupMode)) { - return; - } - // Not reopened: keep going so the legacy host still connects. - } - this.logger.info("Setting up remote connection", { remoteAuthority, hostname: parts.safeHostname, @@ -726,70 +741,6 @@ export class Remote { return undefined; } - /** - * Reopen the window on this editor's own authority. Returns false when the - * workspace cannot be reopened losslessly, so the caller connects over the - * legacy host instead. - */ - private async migrateLegacyAuthority( - remoteAuthority: string, - startupMode: StartupMode, - ): Promise { - const migratedAuthority = retargetRemoteAuthority(remoteAuthority); - const workspaceFile = vscode.workspace.workspaceFile; - const workspaceFolders = vscode.workspace.workspaceFolders ?? []; - const savedWorkspaceFile = - workspaceFile?.scheme === "untitled" ? undefined : workspaceFile; - if (!savedWorkspaceFile && workspaceFolders.length > 1) { - this.logger.warn( - "Cannot migrate an unsaved multi-root workspace; connecting over the legacy host", - remoteAuthority, - ); - // Fire-and-forget: the connection proceeds either way. - void vscode.window - .showWarningMessage( - "This workspace still opens over the old coder-vscode SSH host. " + - "To switch it to this editor's own host, save the workspace, then reload the window.", - "Learn More", - ) - .then(async (choice) => { - if (choice === "Learn More") { - await vscode.env.openExternal( - vscode.Uri.parse( - "https://code.visualstudio.com/docs/editing/workspaces/multi-root-workspaces", - ), - ); - } - }); - return false; - } - - await this.serviceContainer - .getMementoManager() - .setStartupMode(startupMode === "none" ? "start" : startupMode); - this.logger.info("Migrating legacy remote authority", { - from: remoteAuthority, - to: migratedAuthority, - }); - - const currentUri = savedWorkspaceFile ?? workspaceFolders[0]?.uri; - if (currentUri) { - await vscode.commands.executeCommand( - "vscode.openFolder", - currentUri.with({ - authority: retargetRemoteAuthority(currentUri.authority), - }), - false, - ); - return true; - } - await vscode.commands.executeCommand("vscode.newWindow", { - remoteAuthority: migratedAuthority, - reuseWindow: true, - }); - return true; - } - private async resolveRemoteBinary(workspaceClient: Api): Promise { if ( this.extensionContext.extensionMode === vscode.ExtensionMode.Production @@ -968,14 +919,14 @@ export class Remote { featureSet: FeatureSet, cliAuth: CliAuth, ): Promise { - // Taken from the authority, so an unmigrated legacy host keeps working. - const { hostPrefix, safeHostname } = parts; - // One file per (editor, deployment); the user's config gains one shared include. + // Taken from the authority, so a legacy host keeps working. + const { hostPrefix, safeHostname, sshHost } = parts; + // One file per (host prefix, deployment); the user's config gains one shared include. const sshConfig = new SshConfig(this.getMainSshConfigPath(), this.logger); await sshConfig.load(); // Never loaded: update() regenerates it without reading the old content. const coderConfig = new SshConfig( - this.pathResolver.getSshConfigPath(safeHostname), + this.pathResolver.getSshConfigPath(safeHostname, hostEditorId(sshHost)), this.logger, ); @@ -1156,16 +1107,6 @@ export class Remote { workspace: string, agent?: string, ): vscode.Disposable { - // VS Code splits based on the separator when displaying the label - // in a recently opened dialog. If the workspace suffix contains /, - // then it'll visually display weird: - // "/home/kyle [Coder: kyle/workspace]" displays as "workspace] /home/kyle [Coder: kyle" - // For this reason, we use a different / that visually appears the - // same on non-monospace fonts "∕". - let suffix = `Coder: ${owner}∕${workspace}`; - if (agent) { - suffix += `∕${agent}`; - } // VS Code caches resource label formatters in it's global storage SQLite database // under the key "memento/cachedResourceLabelFormatters2". return vscodeProposed.workspace.registerResourceLabelFormatter({ @@ -1177,7 +1118,12 @@ export class Remote { label: "${path}", separator: "/", tildify: true, - workspaceSuffix: suffix, + workspaceSuffix: workspaceLabelSuffix( + remoteAuthority, + owner, + workspace, + agent, + ), }, }); } diff --git a/src/util/authority.ts b/src/util/authority.ts index a2ba9c3b9c..1120460ed1 100644 --- a/src/util/authority.ts +++ b/src/util/authority.ts @@ -2,7 +2,9 @@ import * as vscode from "vscode"; import { toSafeHost } from "./uri"; -export const LegacyAuthorityPrefix = "coder-vscode"; +/** The editor every host was named after before per-editor prefixes existed. */ +export const LegacyEditorId = "vscode"; +const LegacyAuthorityPrefix = `coder-${LegacyEditorId}`; export interface AuthorityParts { agent: string | undefined; @@ -68,10 +70,19 @@ export function classifySshHost(sshHost: string): AuthorityClassification { return "foreign"; } -function authorityPrefix(classification: AuthorityClassification): string { - return classification === "legacy" - ? LegacyAuthorityPrefix - : currentAuthorityPrefix(); +function editorIdFor(classification: AuthorityClassification): string { + return classification === "legacy" ? LegacyEditorId : currentEditorId(); +} + +/** Editor named in a host's prefix; legacy hosts stay VS Code's in any editor. */ +export function hostEditorId(sshHost: string): string { + return editorIdFor(classifySshHost(sshHost)); +} + +/** The SSH host an authority connects over, nested or not. */ +export function sshHostOf(authority: string): string | undefined { + const sshHostStart = getSshHostStart(authority); + return sshHostStart === undefined ? undefined : authority.slice(sshHostStart); } /** @@ -100,7 +111,7 @@ export function parseRemoteAuthority(authority: string): AuthorityParts | null { } // The classification guarantees the host starts with ".". - const prefix = `${authorityPrefix(classification)}.`; + const prefix = `coder-${editorIdFor(classification)}.`; const parts = sshHost.slice(prefix.length).split("--"); if (parts.length < 3) { throw new Error(invalidAuthorityMessage); @@ -149,17 +160,20 @@ export function toRemoteAuthority( return remoteAuthority; } -export function retargetRemoteAuthority(authority: string): string { +/** + * The same authority on the shared legacy host, keeping any wrapper. Returns it + * unchanged when the host is not this editor's, the legacy one included. + */ +export function toLegacyAuthority(authority: string): string { const sshHostStart = getSshHostStart(authority); if (sshHostStart === undefined) { return authority; } - + const currentPrefix = currentAuthorityPrefix(); const sshHost = authority.slice(sshHostStart); - if (classifySshHost(sshHost) !== "legacy") { - return authority; - } - return `${authority.slice(0, sshHostStart)}${currentAuthorityPrefix()}${sshHost.slice(LegacyAuthorityPrefix.length)}`; + return sshHost.startsWith(`${currentPrefix}.`) + ? `${authority.slice(0, sshHostStart)}${LegacyAuthorityPrefix}${sshHost.slice(currentPrefix.length)}` + : authority; } export function isRemoteAuthorityCompatible( @@ -171,6 +185,6 @@ export function isRemoteAuthorityCompatible( } return ( authority === targetAuthority || - retargetRemoteAuthority(authority) === targetAuthority + authority === toLegacyAuthority(targetAuthority) ); } diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 914cc45f4c..708440cbb0 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -6,9 +6,10 @@ import axios, { type InternalAxiosRequestConfig, } from "axios"; import * as fs from "node:fs/promises"; -import { vi } from "vitest"; +import { onTestFinished, vi } from "vitest"; import * as vscode from "vscode"; +import { Commands } from "@/commands"; import { SessionStore, type SessionData } from "@/deployment/sessionStore"; import { @@ -17,7 +18,7 @@ import { } from "@repo/mocks"; import { createTestTelemetryService } from "./telemetry"; -import { window as vscodeWindow } from "./vscode.runtime"; +import { env as vscodeEnv, window as vscodeWindow } from "./vscode.runtime"; import type { Experiment, @@ -39,6 +40,7 @@ import type { ContextManager } from "@/core/contextManager"; import type { MementoManager } from "@/core/mementoManager"; import type { PathResolver } from "@/core/pathResolver"; import type { SecretsManager } from "@/core/secretsManager"; +import type { DeploymentManager } from "@/deployment/deploymentManager"; import type { Deployment } from "@/deployment/types"; import type { Logger } from "@/logging/logger"; import type { LoginCoordinator } from "@/login/loginCoordinator"; @@ -65,6 +67,18 @@ interface LoginCoordinatorLike { ensureLoggedInWithDialog: LoginCoordinator["ensureLoggedInWithDialog"]; } +/** + * Run the rest of the test as the given editor, which is the identity + * `vscode.env.uriScheme` reports and the extension names its SSH hosts after. + * The previous editor is restored when the test ends. + */ +export function useEditor(uriScheme: string): void { + const previous = vscodeEnv.__setUriScheme(uriScheme); + onTestFinished(() => { + vscodeEnv.__setUriScheme(previous); + }); +} + export function makeNetworkInfo( overrides: Partial = {}, ): NetworkInfo { @@ -639,6 +653,68 @@ export function createMockServiceContainer( } as ServiceContainer; } +/** Build `Commands`; services left unnamed stand in as empty objects. */ +export function createTestCommands( + options: { + services?: Record; + baseUrl?: string; + client?: Partial; + } = {}, +): Commands { + const services: Record = { + getTelemetryService: createTestTelemetryService(), + getLogger: createMockLogger(), + getMementoManager: { setStartupMode: vi.fn() }, + getDuplicateWorkspaceIpc: { + sendPing: vi.fn().mockResolvedValue(undefined), + }, + ...options.services, + }; + return new Commands( + new Proxy({} as ServiceContainer, { + get: (_, name: string) => () => services[name] ?? {}, + }), + { + getAxiosInstance: () => ({ defaults: { baseURL: options.baseUrl } }), + ...options.client, + } as unknown as CoderApi, + {} as DeploymentManager, + ); +} + +/** Recently opened entries on one path; a multi-root file has no folder URI. */ +export function mockRecentlyOpened( + authorities: string[], + path: string, + kind: "folder" | "workspaceFile" = "folder", +): void { + const workspaces = authorities.map((authority) => { + const uri = vscode.Uri.from({ scheme: "vscode-remote", authority, path }); + return kind === "folder" + ? { folderUri: uri } + : { workspace: { id: "workspace-1", configPath: uri } }; + }); + vi.mocked(vscode.commands.executeCommand).mockImplementation( + (command: string) => + Promise.resolve( + command === "_workbench.getRecentlyOpened" ? { workspaces } : undefined, + ), + ); +} + +/** The authority a window was handed: a folder by URI, an empty window by option. */ +export function openedAuthority(): string | undefined { + const [, handoff] = + vi + .mocked(vscode.commands.executeCommand) + .mock.calls.find(([command]) => + ["vscode.openFolder", "vscode.newWindow"].includes(command), + ) ?? []; + return handoff instanceof vscode.Uri + ? handoff.authority + : (handoff as { remoteAuthority?: string } | undefined)?.remoteAuthority; +} + /** Update the mocked active color theme and fire onDidChangeActiveColorTheme. */ export function setActiveColorTheme(kind: vscode.ColorThemeKind): void { vscodeWindow.__setActiveColorThemeKind(kind); diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index 252ae68af8..d63b2efcf7 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -237,6 +237,8 @@ export const workspace = { onDidChangeWorkspaceFolders.fire(e), }; +let uriScheme = "vscode"; + export const env = { appName: "Visual Studio Code", appRoot: "/app", @@ -245,8 +247,17 @@ export const env = { sessionId: "test-session-id", remoteName: undefined as string | undefined, shell: "/bin/bash", - uriScheme: "vscode", + get uriScheme(): string { + return uriScheme; + }, openExternal: vi.fn(), + + // test-only trigger, returning the scheme it replaced: + __setUriScheme: (scheme: string): string => { + const previous = uriScheme; + uriScheme = scheme; + return previous; + }, }; export const extensions = { diff --git a/test/unit/commands.openDevContainer.test.ts b/test/unit/commands.openDevContainer.test.ts new file mode 100644 index 0000000000..e2a30a7ab2 --- /dev/null +++ b/test/unit/commands.openDevContainer.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + createTestCommands, + MockConfigurationProvider, + mockRecentlyOpened, + openedAuthority, + useEditor, +} from "../mocks/testHelpers"; + +const FOLDER = "/workspaces/project"; +const CONTAINER = "my-container"; + +/** The hex payload the extension derives from the arguments below. */ +const PAYLOAD = Buffer.from( + JSON.stringify({ + containerName: CONTAINER, + hostPath: undefined, + configFile: undefined, + localDocker: false, + }), + "utf-8", +).toString("hex"); + +const authorityFor = (editorId: string, payload = PAYLOAD) => + `attached-container+${payload}@ssh-remote+coder-${editorId}.dev.coder.com--foo--bar.devcontainer`; + +const CURSOR = authorityFor("cursor"); +const LEGACY = authorityFor("vscode"); + +/** + * Open the devcontainer as the URI handler does, with the given authorities + * standing in for recently opened folders. + */ +async function openDevContainer( + recents: string[], +): Promise { + new MockConfigurationProvider(); + mockRecentlyOpened(recents, FOLDER); + const commands = createTestCommands({ baseUrl: "https://dev.coder.com" }); + await commands.openDevContainer( + "foo", + "bar", + "devcontainer", + CONTAINER, + FOLDER, + ); + return openedAuthority(); +} + +describe("openDevContainer", () => { + beforeEach(() => { + vi.clearAllMocks(); + useEditor("cursor"); + }); + + // How the two hosts are chosen between is covered in openWorkspace; these + // cases are about carrying the container payload across that choice. + it.each([ + { label: "its own host with no history", recents: [], expected: CURSOR }, + { label: "the legacy host it used", recents: [LEGACY], expected: LEGACY }, + { + label: "the legacy host another devcontainer in the workspace used", + recents: [authorityFor("vscode", "abcdef")], + expected: LEGACY, + }, + ])("reopens the devcontainer on $label", async ({ recents, expected }) => { + expect(await openDevContainer(recents)).toBe(expected); + }); +}); diff --git a/test/unit/commands.openSshConfig.test.ts b/test/unit/commands.openSshConfig.test.ts new file mode 100644 index 0000000000..f029e0bc5e --- /dev/null +++ b/test/unit/commands.openSshConfig.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { PathResolver } from "@/core/pathResolver"; + +import { + createTestCommands, + MockConfigurationProvider, + useEditor, +} from "../mocks/testHelpers"; + +const pathResolver = new PathResolver("/data", "/logs"); +const configPath = (editorId: string) => + pathResolver.getSshConfigPath("dev.coder.com", editorId); +const CURSOR_FILE = "cursor--dev.coder.com.conf"; +const LEGACY_FILE = "vscode--dev.coder.com.conf"; + +vi.mock("node:fs/promises", async (importOriginal) => ({ + ...(await importOriginal()), + readdir: vi.fn(), +})); + +/** Open the generated config with the given files on disk, and report the path. */ +async function openSshConfig( + files: string[], + remoteAuthority?: string, +): Promise { + new MockConfigurationProvider(); + const { readdir } = await import("node:fs/promises"); + vi.mocked(readdir).mockResolvedValue(files as never); + vi.mocked(vscode.env).remoteAuthority = remoteAuthority; + + const opened: string[] = []; + vi.mocked(vscode.window.showTextDocument).mockImplementation( + (uri: unknown) => { + opened.push((uri as vscode.Uri).fsPath); + return Promise.resolve({} as vscode.TextEditor); + }, + ); + const commands = createTestCommands({ + services: { getPathResolver: pathResolver }, + }); + await commands.openSshConfig(); + return opened[0]; +} + +describe("openSshConfig", () => { + beforeEach(() => { + vi.clearAllMocks(); + useEditor("cursor"); + }); + + it("opens the file serving the connected window, legacy host included", async () => { + expect( + await openSshConfig( + [CURSOR_FILE, LEGACY_FILE], + "ssh-remote+coder-vscode.dev.coder.com--foo--bar.main", + ), + ).toBe(configPath("vscode")); + }); + + it("opens the only file without asking", async () => { + expect(await openSshConfig([LEGACY_FILE])).toBe(configPath("vscode")); + expect(vscode.window.showQuickPick).not.toHaveBeenCalled(); + }); + + it("offers both prefixes serving one deployment", async () => { + vi.mocked(vscode.window.showQuickPick).mockImplementation( + (items: unknown) => + Promise.resolve( + (items as Array<{ description: string }>).find( + (item) => item.description === "coder-vscode.dev.coder.com--*", + ), + ) as never, + ); + expect(await openSshConfig([CURSOR_FILE, LEGACY_FILE])).toBe( + configPath("vscode"), + ); + const [items] = vi.mocked(vscode.window.showQuickPick).mock.calls[0]; + expect(items).toHaveLength(2); + }); + + it("ignores files for prefixes this editor never connects over", async () => { + expect(await openSshConfig(["devin--dev.coder.com.conf"])).toBeUndefined(); + expect(vscode.window.showInformationMessage).toHaveBeenCalled(); + }); +}); diff --git a/test/unit/commands.openWorkspace.test.ts b/test/unit/commands.openWorkspace.test.ts new file mode 100644 index 0000000000..2f7093d61f --- /dev/null +++ b/test/unit/commands.openWorkspace.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { + agent as createAgent, + resource as createResource, + workspace as createWorkspace, +} from "@repo/mocks"; + +import { + createTestCommands, + MockConfigurationProvider, + mockRecentlyOpened, + openedAuthority, + useEditor, +} from "../mocks/testHelpers"; + +import type { WorkspaceAgent } from "coder/site/src/api/typesGenerated"; + +vi.mock("@/workspace/workspacesProvider", () => ({ + AgentTreeItem: class { + constructor( + public agent: unknown, + public workspace: unknown, + ) {} + }, + WorkspaceTreeItem: class {}, +})); + +const AGENT = { name: "main" } as WorkspaceAgent; +const FOLDER = "/home/foo/project"; +const BASE_URL = "https://dev.coder.com"; +const CURSOR = "ssh-remote+coder-cursor.dev.coder.com--foo--bar.main"; +const LEGACY = "ssh-remote+coder-vscode.dev.coder.com--foo--bar.main"; +const DEVIN = "ssh-remote+coder-devin.dev.coder.com--foo--bar.main"; + +/** + * Open the workspace from the sidebar, with the given authorities standing in + * for recently opened folders, and report the authority the window was handed. + */ +async function openFromSidebar( + recents: string[], + path = FOLDER, + kind?: "folder" | "workspaceFile", +): Promise { + new MockConfigurationProvider(); + mockRecentlyOpened(recents, path, kind); + const commands = createTestCommands({ baseUrl: BASE_URL }); + const { AgentTreeItem } = await import("@/workspace/workspacesProvider"); + await commands.openFromSidebar( + new AgentTreeItem( + AGENT, + createWorkspace({ owner_name: "foo", name: "bar" }), + ), + ); + return openedAuthority(); +} + +/** Open as a link does, without openRecent, from an agent built as given. */ +async function openFromLink( + recents: string[], + agent: Partial = {}, +): Promise { + new MockConfigurationProvider(); + mockRecentlyOpened(recents, FOLDER); + const commands = createTestCommands({ + baseUrl: BASE_URL, + client: { + getWorkspaceByOwnerAndName: vi.fn().mockResolvedValue( + createWorkspace({ + owner_name: "foo", + name: "bar", + latest_build: { + resources: [createResource({ agents: [createAgent(agent)] })], + }, + }), + ), + }, + }); + await commands.open({ + workspaceOwner: "foo", + workspaceName: "bar", + agentName: "main", + source: "uri", + }); + return openedAuthority(); +} + +describe("openWorkspace", () => { + beforeEach(() => { + vi.clearAllMocks(); + useEditor("cursor"); + }); + + interface RecentCase { + label: string; + recents: string[]; + expected: string; + } + it.each([ + { label: "the legacy host it used", recents: [LEGACY], expected: LEGACY }, + { label: "its own host", recents: [CURSOR], expected: CURSOR }, + { label: "its own host with no history", recents: [], expected: CURSOR }, + { + label: "its own host, ignoring another editor's", + recents: [DEVIN], + expected: CURSOR, + }, + { + label: "the host it used last, of the two it has used", + recents: [CURSOR, LEGACY], + expected: CURSOR, + }, + { + label: "the legacy host it used last", + recents: [LEGACY, CURSOR], + expected: LEGACY, + }, + ])("reopens the workspace on $label", async ({ recents, expected }) => { + expect(await openFromSidebar(recents)).toBe(expected); + }); + + it("reopens a multi-root workspace on the host its file used", async () => { + expect( + await openFromSidebar( + [LEGACY], + "/home/foo/project.code-workspace", + "workspaceFile", + ), + ).toBe(LEGACY); + }); + + it("reuses the host of a directory the agent supplies", async () => { + expect(await openFromLink([LEGACY], { expanded_directory: FOLDER })).toBe( + LEGACY, + ); + }); + + it("does not ask between one folder recorded on both hosts", async () => { + expect(await openFromLink([CURSOR, LEGACY])).toBe(CURSOR); + expect(vscode.window.showQuickPick).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/core/pathResolver.test.ts b/test/unit/core/pathResolver.test.ts index a6117dc564..4361ccdbab 100644 --- a/test/unit/core/pathResolver.test.ts +++ b/test/unit/core/pathResolver.test.ts @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PathResolver } from "@/core/pathResolver"; -import { MockConfigurationProvider } from "../../mocks/testHelpers"; +import { MockConfigurationProvider, useEditor } from "../../mocks/testHelpers"; import { expectPathsEqual } from "../../utils/platform"; describe("PathResolver", () => { @@ -127,10 +127,21 @@ describe("PathResolver", () => { ); }); - it("parses the hostname only from this editor's generated files", () => { + it("names a legacy host's file after VS Code, whichever editor asks", () => { + useEditor("cursor"); + expectPathsEqual( + pathResolver.getSshConfigPath("dev.coder.com", "vscode"), + path.join(pathResolver.getSshConfigDir(), "vscode--dev.coder.com.conf"), + ); + }); + + it("parses the hostname only from the named editor's files", () => { expect( pathResolver.parseSshConfigFile("vscode--dev.coder.com.conf"), ).toBe("dev.coder.com"); + expect( + pathResolver.parseSshConfigFile("cursor--dev.coder.com.conf", "cursor"), + ).toBe("dev.coder.com"); expect( pathResolver.parseSshConfigFile("cursor--dev.coder.com.conf"), ).toBeUndefined(); diff --git a/test/unit/remote/remote.test.ts b/test/unit/remote/remote.test.ts index 04d58288c5..50ec7c46c5 100644 --- a/test/unit/remote/remote.test.ts +++ b/test/unit/remote/remote.test.ts @@ -5,7 +5,7 @@ import * as vscode from "vscode"; import { MementoManager } from "@/core/mementoManager"; import { PathResolver } from "@/core/pathResolver"; import { SecretsManager } from "@/core/secretsManager"; -import { Remote } from "@/remote/remote"; +import { Remote, workspaceLabelSuffix } from "@/remote/remote"; import { createTestTelemetryService } from "../../mocks/telemetry"; import { @@ -16,6 +16,7 @@ import { LogCollector, MockConfigurationProvider, MockUserInteraction, + useEditor, } from "../../mocks/testHelpers"; import type { Commands } from "@/commands"; @@ -26,7 +27,6 @@ const mockWorkspace = vscode.workspace as typeof vscode.workspace & { workspaceFile: vscode.Uri | undefined; workspaceFolders: vscode.WorkspaceFolder[]; }; -const mockEnv = vscode.env as typeof vscode.env & { uriScheme: string }; vi.mock("node:fs/promises", async () => (await import("memfs")).fs.promises); @@ -93,7 +93,6 @@ describe("Remote", () => { vol.reset(); mockWorkspace.workspaceFile = undefined; mockWorkspace.workspaceFolders = []; - mockEnv.uriScheme = "vscode"; }); type UriOptions = Partial< @@ -118,126 +117,46 @@ describe("Remote", () => { return mockWorkspace.workspaceFolders; }; - it("migrates a legacy folder with its full URI", async () => { - mockEnv.uriScheme = "cursor"; - const { remote, mementoManager } = createRemote(); - setWorkspace([ - createUri("/workspace", { - query: "window=active", - fragment: "selection", - }), - ]); - - await expect( - remote.setup(REMOTE_AUTHORITY, "none", REMOTE_SSH_EXTENSION_ID), - ).resolves.toBeUndefined(); - - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - "vscode.openFolder", - createUri("/workspace", { - authority: CURSOR_REMOTE_AUTHORITY, - query: "window=active", - fragment: "selection", - }), - false, - ); - expect(await mementoManager.getAndClearStartupMode()).toBe("start"); - }); - - it("migrates a saved multi-root workspace file", async () => { - mockEnv.uriScheme = "cursor"; - const { remote, mementoManager } = createRemote(); - setWorkspace( - [createUri("/first-folder"), createUri("/second-folder")], - createUri("/project.code-workspace", { - query: "window=active", - fragment: "selection", - }), - ); - - await expect( - remote.setup(REMOTE_AUTHORITY, "none", REMOTE_SSH_EXTENSION_ID), - ).resolves.toBeUndefined(); - - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - "vscode.openFolder", - createUri("/project.code-workspace", { - authority: CURSOR_REMOTE_AUTHORITY, - query: "window=active", - fragment: "selection", - }), - false, - ); - expect(await mementoManager.getAndClearStartupMode()).toBe("start"); - }); - - interface EmptyWindowMigrationCase { - startupMode: "none" | "start" | "update"; - expectedStartupMode: "start" | "update"; + interface LegacyWindowCase { + label: string; + open: () => void; } - it.each([ - { startupMode: "none", expectedStartupMode: "start" }, - { startupMode: "start", expectedStartupMode: "start" }, - { startupMode: "update", expectedStartupMode: "update" }, - ])( - "migrates an empty window and preserves $startupMode startup mode", - async ({ startupMode, expectedStartupMode }) => { - mockEnv.uriScheme = "cursor"; - const { remote, mementoManager } = createRemote(); - - await expect( - remote.setup(REMOTE_AUTHORITY, startupMode, REMOTE_SSH_EXTENSION_ID), - ).resolves.toBeUndefined(); - - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - "vscode.newWindow", - { - remoteAuthority: CURSOR_REMOTE_AUTHORITY, - reuseWindow: true, - }, - ); - expect(await mementoManager.getAndClearStartupMode()).toBe( - expectedStartupMode, - ); + it.each([ + { label: "a folder", open: () => setWorkspace([createUri("/workspace")]) }, + { + label: "a saved multi-root workspace", + open: () => + setWorkspace( + [createUri("/first-folder"), createUri("/second-folder")], + createUri("/project.code-workspace"), + ), }, - ); - - it.each([ - { choice: undefined, docsUrls: [] }, { - choice: "Learn More", - docsUrls: [expect.stringContaining("multi-root-workspaces")], + label: "an untitled multi-root workspace", + open: () => + setWorkspace( + [createUri("/first-folder"), createUri("/second-folder")], + createUri("/Untitled-1.code-workspace", { + scheme: "untitled", + authority: "", + }), + ), }, + { label: "an empty window", open: () => setWorkspace() }, ])( - "sets up an untitled multi-root workspace on the old host (choice: $choice)", - async ({ choice, docsUrls }) => { - mockEnv.uriScheme = "cursor"; - const { - remote, - ensureLoggedInWithDialog, - mementoManager, - userInteraction, - } = createRemote(); - setWorkspace( - [createUri("/first-folder"), createUri("/second-folder")], - createUri("/Untitled-1.code-workspace", { - scheme: "untitled", - authority: "", - }), - ); - userInteraction.setResponse(/coder-vscode SSH host/, choice); + "connects $label over the legacy host without reopening it", + async ({ open }) => { + useEditor("cursor"); + const { remote, ensureLoggedInWithDialog, userInteraction } = + createRemote(); + open(); await expect( - remote.setup(REMOTE_AUTHORITY, "update", REMOTE_SSH_EXTENSION_ID), + remote.setup(REMOTE_AUTHORITY, "none", REMOTE_SSH_EXTENSION_ID), ).resolves.toBeUndefined(); - const warning = userInteraction - .getMessageCalls() - .find((call) => call.level === "warning"); - expect(warning?.message).toContain("coder-vscode SSH host"); - expect(warning?.items).toEqual(["Learn More"]); - expect(userInteraction.getExternalUrls()).toEqual(docsUrls); - // Setup continues over the legacy host instead of reopening the window. + // Reopening would change the authority, and with it the identity the + // editor keeps window state under. expect(ensureLoggedInWithDialog).toHaveBeenCalledOnce(); expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith( "vscode.openFolder", @@ -248,12 +167,14 @@ describe("Remote", () => { "vscode.newWindow", expect.anything(), ); - expect(await mementoManager.getAndClearStartupMode()).toBe("none"); + expect( + userInteraction.getMessageCalls().find((c) => c.level === "warning"), + ).toBeUndefined(); }, ); it("continues setup for the current authority without reopening", async () => { - mockEnv.uriScheme = "cursor"; + useEditor("cursor"); const { remote, ensureLoggedInWithDialog } = createRemote(); await expect( @@ -273,7 +194,7 @@ describe("Remote", () => { }); it("ignores a foreign authority", async () => { - mockEnv.uriScheme = "cursor"; + useEditor("cursor"); const { remote, ensureLoggedInWithDialog, mementoManager } = createRemote(); await expect( @@ -306,3 +227,46 @@ describe("Remote", () => { }); }); }); + +describe("workspaceLabelSuffix", () => { + it.each([ + { + label: "this editor's", + editor: "cursor", + host: "coder-cursor", + agent: "main", + expected: "Coder: foo∕bar∕main", + }, + { + label: "the shared", + editor: "cursor", + host: "coder-vscode", + agent: "main", + expected: "Coder: foo∕bar∕main (legacy)", + }, + { + label: "VS Code's own", + editor: "vscode", + host: "coder-vscode", + agent: "main", + expected: "Coder: foo∕bar∕main", + }, + { + label: "this editor's, agentless", + editor: "cursor", + host: "coder-cursor", + agent: undefined, + expected: "Coder: foo∕bar", + }, + ])("labels $label host in $editor", ({ editor, host, agent, expected }) => { + useEditor(editor); + expect( + workspaceLabelSuffix( + `ssh-remote+${host}.dev.coder.com--foo--bar.main`, + "foo", + "bar", + agent, + ), + ).toBe(expected); + }); +}); diff --git a/test/unit/util/authority.test.ts b/test/unit/util/authority.test.ts index e54ec248e8..555b3e3048 100644 --- a/test/unit/util/authority.test.ts +++ b/test/unit/util/authority.test.ts @@ -1,17 +1,18 @@ -import { afterEach, describe, expect, it } from "vitest"; -import * as vscode from "vscode"; +import { describe, expect, it } from "vitest"; import { type AuthorityClassification, type AuthorityParts, classifySshHost, + hostEditorId, isRemoteAuthorityCompatible, parseRemoteAuthority, - retargetRemoteAuthority, + toLegacyAuthority, toRemoteAuthority, } from "@/util/authority"; -const env = vscode.env as typeof vscode.env & { uriScheme: string }; +import { useEditor } from "../../mocks/testHelpers"; + const CURSOR_AUTHORITY = "ssh-remote+coder-cursor.dev.coder.com--foo--bar.main"; const LEGACY_AUTHORITY = "ssh-remote+coder-vscode.dev.coder.com--foo--bar.main"; const DEVIN_AUTHORITY = "ssh-remote+coder-devin.dev.coder.com--foo--bar.main"; @@ -25,10 +26,6 @@ const parts = (prefix: string): AuthorityParts => ({ workspace: "bar", }); -afterEach(() => { - env.uriScheme = "vscode"; -}); - describe("parseRemoteAuthority", () => { interface ClassificationCase { editor: string; @@ -52,7 +49,7 @@ describe("parseRemoteAuthority", () => { ])( "classifies $prefix as $expected in $editor", ({ editor, prefix, expected }) => { - env.uriScheme = editor; + useEditor(editor); expect(classifySshHost(parts(prefix).sshHost)).toBe(expected); }, ); @@ -70,7 +67,7 @@ describe("parseRemoteAuthority", () => { { editor: "vscode", sshHost: "coder-vscode.dev.coder.com--foo--.main" }, { editor: "vscode", sshHost: "coder-vscode.dev.coder.com--foo--bar." }, ])("rejects malformed current or legacy authority", ({ editor, sshHost }) => { - env.uriScheme = editor; + useEditor(editor); expect(() => parseRemoteAuthority(`ssh-remote+${sshHost}`)).toThrow( "Invalid Coder SSH authority", ); @@ -79,7 +76,7 @@ describe("parseRemoteAuthority", () => { it("ignores unrelated and malformed foreign authorities", () => { expect(parseRemoteAuthority("github.com")).toBeNull(); expect(parseRemoteAuthority("ssh-remote+coder-vscode")).toBeNull(); - env.uriScheme = "cursor"; + useEditor("cursor"); expect( parseRemoteAuthority("ssh-remote+coder-devin.dev.coder.com--foo"), ).toBeNull(); @@ -88,7 +85,7 @@ describe("parseRemoteAuthority", () => { it.each(["vscode", "cursor"])( "ignores deployment-unaware historical hosts in %s", (editor) => { - env.uriScheme = editor; + useEditor(editor); // Old versions created hosts matching the preserved `Host coder-vscode--*` block. expect( parseRemoteAuthority("ssh-remote+coder-vscode--user--workspace.main"), @@ -141,7 +138,7 @@ describe("parseRemoteAuthority", () => { authority: `attached-container+def@dev-container+abc@${CURSOR_AUTHORITY}`, }, ])("parses $label wrapper", ({ authority }) => { - env.uriScheme = "cursor"; + useEditor("cursor"); expect(parseRemoteAuthority(authority)).toStrictEqual( parts("coder-cursor"), ); @@ -150,7 +147,7 @@ describe("parseRemoteAuthority", () => { describe("authority construction", () => { it("preserves the editor URI scheme and integrates with toSafeHost", () => { - env.uriScheme = "cursor--dev"; + useEditor("cursor--dev"); expect( toRemoteAuthority("https://ほげ", "alice", "workspace", "main"), ).toBe("ssh-remote+coder-cursor--dev.xn--18j4d--alice--workspace.main"); @@ -163,7 +160,7 @@ describe("authority construction", () => { }); it("formats the current host prefix", () => { - env.uriScheme = "vscode-insiders"; + useEditor("vscode-insiders"); expect( parseRemoteAuthority( "ssh-remote+coder-vscode-insiders.dev.coder.com--foo--bar", @@ -172,42 +169,14 @@ describe("authority construction", () => { }); it("rejects an empty editor URI scheme at prefix construction", () => { - env.uriScheme = ""; + useEditor(""); expect(() => toRemoteAuthority("https://dev.coder.com", "foo", "bar", undefined), ).toThrow("must not be empty"); }); }); -describe("authority migration", () => { - it("leaves unrelated authorities unchanged", () => { - expect(retargetRemoteAuthority("github.com")).toBe("github.com"); - }); - - interface RetargetCase { - label: string; - authority: string; - expected: string; - } - it.each([ - { - label: "plain", - authority: LEGACY_AUTHORITY, - expected: CURSOR_AUTHORITY, - }, - { - label: "multiply nested", - authority: `attached-container+def@dev-container+abc@${LEGACY_AUTHORITY}`, - expected: `attached-container+def@dev-container+abc@${CURSOR_AUTHORITY}`, - }, - ])( - "preserves the $label wrapper while retargeting", - ({ authority, expected }) => { - env.uriScheme = "cursor"; - expect(retargetRemoteAuthority(authority)).toBe(expected); - }, - ); - +describe("legacy authority compatibility", () => { interface CompatibilityCase { label: string; authority: string | undefined; @@ -229,9 +198,62 @@ describe("authority migration", () => { expected: false, }, ])("requires exact compatibility for $label", ({ authority, expected }) => { - env.uriScheme = "cursor"; + useEditor("cursor"); expect(isRemoteAuthorityCompatible(authority, CURSOR_AUTHORITY)).toBe( expected, ); }); + + it.each([ + { label: "plain", authority: CURSOR_AUTHORITY, expected: LEGACY_AUTHORITY }, + { + label: "multiply nested", + authority: `attached-container+def@dev-container+abc@${CURSOR_AUTHORITY}`, + expected: `attached-container+def@dev-container+abc@${LEGACY_AUTHORITY}`, + }, + { + label: "already legacy", + authority: LEGACY_AUTHORITY, + expected: LEGACY_AUTHORITY, + }, + { label: "foreign", authority: DEVIN_AUTHORITY, expected: DEVIN_AUTHORITY }, + { label: "non-remote", authority: "github.com", expected: "github.com" }, + ])( + "moves the $label authority to the legacy host", + ({ authority, expected }) => { + useEditor("cursor"); + expect(toLegacyAuthority(authority)).toBe(expected); + }, + ); +}); + +describe("hostEditorId", () => { + interface HostEditorCase { + editor: string; + sshHost: string; + expected: string; + } + it.each([ + { + editor: "cursor", + sshHost: "coder-cursor.dev--foo--bar", + expected: "cursor", + }, + { + editor: "cursor", + sshHost: "coder-vscode.dev--foo--bar", + expected: "vscode", + }, + { + editor: "vscode", + sshHost: "coder-vscode.dev--foo--bar", + expected: "vscode", + }, + ])( + "$editor serves $sshHost from $expected's file", + ({ editor, sshHost, expected }) => { + useEditor(editor); + expect(hostEditorId(sshHost)).toBe(expected); + }, + ); });