Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 210 additions & 5 deletions registry/coder/modules/vscode-desktop-core/main.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { describe, expect, it } from "bun:test";
import { describe, expect, it, beforeAll, afterAll } from "bun:test";
import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "~test";
import { mkdtempSync, rmSync, existsSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";

// hardcoded coder_app name in main.tf
const appName = "vscode-desktop";
Expand Down Expand Up @@ -39,7 +42,6 @@
it("adds folder", async () => {
const state = await runTerraformApply(import.meta.dir, {
folder: "/foo/bar",

...defaultVariables,
});

Expand All @@ -52,7 +54,6 @@
const state = await runTerraformApply(import.meta.dir, {
folder: "/foo/bar",
open_recent: "true",

...defaultVariables,
});
expect(state.outputs.ide_uri.value).toBe(
Expand All @@ -64,7 +65,6 @@
const state = await runTerraformApply(import.meta.dir, {
folder: "/foo/bar",
openRecent: "false",

...defaultVariables,
});
expect(state.outputs.ide_uri.value).toBe(
Expand All @@ -75,7 +75,6 @@
it("adds open_recent", async () => {
const state = await runTerraformApply(import.meta.dir, {
open_recent: "true",

...defaultVariables,
});
expect(state.outputs.ide_uri.value).toBe(
Expand All @@ -98,3 +97,209 @@
expect(coder_app?.instances[0].attributes.order).toBe(22);
});
});

describe("vscode-desktop-core extension script logic", async () => {
await runTerraformInit(import.meta.dir);

let tempDir: string;

beforeAll(() => {
tempDir = mkdtempSync(join(tmpdir(), "vscode-extensions-test-"));
});

afterAll(() => {
if (tempDir && existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});

const supportedIdes = [
{
protocol: "vscode",
name: "VS Code",
expectedUrls: ["marketplace.visualstudio.com"],
marketplace: "Microsoft",
},
{
protocol: "vscode-insiders",
name: "VS Code Insiders",
expectedUrls: ["marketplace.visualstudio.com"],
marketplace: "Microsoft",
},
{
protocol: "vscodium",
name: "VSCodium",
expectedUrls: ["open-vsx.org"],
marketplace: "Open VSX",
},
{
protocol: "cursor",
name: "Cursor",
expectedUrls: ["open-vsx.org"],
marketplace: "Open VSX",
},
{
protocol: "windsurf",
name: "WindSurf",
expectedUrls: ["open-vsx.org"],
marketplace: "Open VSX",
},
{
protocol: "kiro",
name: "Kiro",
expectedUrls: ["open-vsx.org"],
marketplace: "Open VSX",
},
];

// Test extension script generation and IDE-specific marketplace logic
for (const ide of supportedIdes) {
it(`should use correct marketplace for ${ide.name} (${ide.marketplace})`, async () => {
const extensionsDir = join(tempDir, ide.protocol, "extensions");

const variables = {
...defaultVariables,
protocol: ide.protocol,
coder_app_display_name: ide.name,
extensions: '["ms-vscode.hexeditor"]',
extensions_dir: extensionsDir,
};

const state = await runTerraformApply(import.meta.dir, variables);

// Verify the script was created
const extensionScript = state.resources.find(
(res) =>
res.type === "coder_script" && res.name === "extensions-installer",
);

expect(extensionScript).not.toBeNull();

const scriptContent = extensionScript?.instances[0].attributes.script;

// Verify IDE type is correctly set
expect(scriptContent).toContain(`IDE_TYPE="${ide.protocol}"`);

// Verify extensions directory is set correctly
expect(scriptContent).toContain(`EXTENSIONS_DIR="${extensionsDir}"`);

// Verify extension ID is present
expect(scriptContent).toContain("ms-vscode.hexeditor");

// Verify the case statement includes the IDE protocol
expect(scriptContent).toContain(`case "${ide.protocol}" in`);

// Verify that the correct case branch exists for the IDE
if (ide.marketplace === "Microsoft") {
expect(scriptContent).toContain(`"vscode"|"vscode-insiders"`);

Check failure on line 194 in registry/coder/modules/vscode-desktop-core/main.test.ts

View workflow job for this annotation

GitHub Actions / Validate Terraform output

error: expect(received).toContain(expected)

Expected to contain: "\"vscode\"|\"vscode-insiders\"" No extensions to install for ${CODE}vscode-insiders${RESET}\\n\"\nfi\n" at <anonymous> (/home/runner/work/registry/registry/registry/coder/modules/vscode-desktop-core/main.test.ts:194:31)

Check failure on line 194 in registry/coder/modules/vscode-desktop-core/main.test.ts

View workflow job for this annotation

GitHub Actions / Validate Terraform output

error: expect(received).toContain(expected)

Expected to contain: "\"vscode\"|\"vscode-insiders\"" No extensions to install for ${CODE}vscode${RESET}\\n\"\nfi\n" at <anonymous> (/home/runner/work/registry/registry/registry/coder/modules/vscode-desktop-core/main.test.ts:194:31)
} else {
expect(scriptContent).toContain(

Check failure on line 196 in registry/coder/modules/vscode-desktop-core/main.test.ts

View workflow job for this annotation

GitHub Actions / Validate Terraform output

error: expect(received).toContain(expected)

Expected to contain: "\"vscodium\"|\"cursor\"|\"windsurf\"|\"kiro\"" No extensions to install for ${CODE}kiro${RESET}\\n\"\nfi\n" at <anonymous> (/home/runner/work/registry/registry/registry/coder/modules/vscode-desktop-core/main.test.ts:196:31)

Check failure on line 196 in registry/coder/modules/vscode-desktop-core/main.test.ts

View workflow job for this annotation

GitHub Actions / Validate Terraform output

error: expect(received).toContain(expected)

Expected to contain: "\"vscodium\"|\"cursor\"|\"windsurf\"|\"kiro\"" No extensions to install for ${CODE}windsurf${RESET}\\n\"\nfi\n" at <anonymous> (/home/runner/work/registry/registry/registry/coder/modules/vscode-desktop-core/main.test.ts:196:31)

Check failure on line 196 in registry/coder/modules/vscode-desktop-core/main.test.ts

View workflow job for this annotation

GitHub Actions / Validate Terraform output

error: expect(received).toContain(expected)

Expected to contain: "\"vscodium\"|\"cursor\"|\"windsurf\"|\"kiro\"" No extensions to install for ${CODE}cursor${RESET}\\n\"\nfi\n" at <anonymous> (/home/runner/work/registry/registry/registry/coder/modules/vscode-desktop-core/main.test.ts:196:31)

Check failure on line 196 in registry/coder/modules/vscode-desktop-core/main.test.ts

View workflow job for this annotation

GitHub Actions / Validate Terraform output

error: expect(received).toContain(expected)

Expected to contain: "\"vscodium\"|\"cursor\"|\"windsurf\"|\"kiro\"" No extensions to install for ${CODE}vscodium${RESET}\\n\"\nfi\n" at <anonymous> (/home/runner/work/registry/registry/registry/coder/modules/vscode-desktop-core/main.test.ts:196:31)
`"vscodium"|"cursor"|"windsurf"|"kiro"`,
);
}

// Verify the correct marketplace URL is present
for (const expectedUrl of ide.expectedUrls) {
expect(scriptContent).toContain(expectedUrl);
}

// Verify the script uses the correct case branch for this IDE
if (ide.marketplace === "Microsoft") {
expect(scriptContent).toContain(
"# Microsoft IDEs: Use Visual Studio Marketplace",
);
} else {
expect(scriptContent).toContain(
"# Non-Microsoft IDEs: Use Open VSX Registry",
);
}
});
}

// Test extension installation from URLs (airgapped scenario)
it("should generate script for extensions from URLs with proper variable handling", async () => {
const extensionsDir = join(tempDir, "airgapped", "extensions");

const variables = {
...defaultVariables,
extensions_urls:
'["https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-vscode/vsextensions/hexeditor/latest/vspackage"]',
extensions_dir: extensionsDir,
};

const state = await runTerraformApply(import.meta.dir, variables);

const extensionScript = state.resources.find(
(res) =>
res.type === "coder_script" && res.name === "extensions-installer",
);

expect(extensionScript).not.toBeNull();

const scriptContent = extensionScript?.instances[0].attributes.script;

// Verify URLs variable is populated
expect(scriptContent).toContain("EXTENSIONS_URLS=");
expect(scriptContent).toContain("hexeditor");

// Verify extensions variable is empty when using URLs
expect(scriptContent).toContain('EXTENSIONS=""');

// Verify the script calls the URL installation function
expect(scriptContent).toContain("install_extensions_from_urls");
});

// Test script logic for both extension IDs and URLs handling
it("should handle empty extensions gracefully", async () => {
const variables = {
...defaultVariables,
extensions: "[]",
extensions_urls: "[]",
};

const state = await runTerraformApply(import.meta.dir, variables);

// Script should not exist when no extensions are provided
const extensionScript = state.resources.find(
(res) =>
res.type === "coder_script" && res.name === "extensions-installer",
);

expect(extensionScript).toBeUndefined();
});

// Test script template variable substitution
it("should properly substitute template variables in script", async () => {
const customDir = join(tempDir, "custom-template-test");
const testExtensions = ["ms-python.python", "ms-vscode.cpptools"];

const variables = {
...defaultVariables,
protocol: "cursor",
extensions: JSON.stringify(testExtensions),
extensions_dir: customDir,
};

const state = await runTerraformApply(import.meta.dir, variables);
const extensionScript = state.resources.find(
(res) =>
res.type === "coder_script" && res.name === "extensions-installer",
)?.instances[0].attributes.script;

// Verify all template variables are properly substituted
expect(extensionScript).toContain(
`EXTENSIONS="${testExtensions.join(",")}"`,
);
expect(extensionScript).toContain(`EXTENSIONS_URLS=""`);
expect(extensionScript).toContain(`EXTENSIONS_DIR="${customDir}"`);
expect(extensionScript).toContain(`IDE_TYPE="cursor"`);

// Verify Terraform template variables are properly substituted (no double braces)
expect(extensionScript).not.toContain("$${");

// Verify script contains proper bash functions
expect(extensionScript).toContain("generate_extension_url()");
expect(extensionScript).toContain("install_extensions_from_ids");
expect(extensionScript).toContain("install_extensions_from_urls");
});
});
79 changes: 74 additions & 5 deletions registry/coder/modules/vscode-desktop-core/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
version = ">= 2.11"
}
}
}
Expand All @@ -14,6 +14,30 @@ variable "agent_id" {
description = "The ID of a Coder agent."
}

variable "extensions" {
type = list(string)
description = <<-EOF
The list of extensions to install in the IDE.
Example: ["ms-python.python", "ms-vscode.cpptools"]
EOF
default = []
}

variable "extensions_urls" {
type = list(string)
description = <<-EOF
The list of extension URLs to install in the IDE.
Example: ["https://marketplace.visualstudio.com/items?itemName=ms-python.python", "https://marketplace.visualstudio.com/items?itemName=ms-vscode.cpptools"]
EOF
default = []
}

variable "extensions_dir" {
type = string
description = "The directory where extensions will be installed."
default = ""
}

variable "folder" {
type = string
description = "The folder to open in the IDE."
Expand All @@ -29,6 +53,10 @@ variable "open_recent" {
variable "protocol" {
type = string
description = "The URI protocol for the IDE."
validation {
condition = contains(["vscode", "vscode-insiders", "vscodium", "cursor", "windsurf", "kiro"], var.protocol)
error_message = "Protocol must be one of: vscode, vscode-insiders, vscodium, cursor, windsurf, or kiro."
}
}

variable "coder_app_icon" {
Expand Down Expand Up @@ -58,19 +86,60 @@ variable "coder_app_group" {
default = null
}

variable "coder_app_tooltip" {
type = string
description = "An optional tooltip to display on the IDE button."
default = null
}

data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}

locals {
default_extensions_dirs = {
vscode = "~/.vscode-server/extensions"
vscode-insiders = "~/.vscode-server-insiders/extensions"
vscodium = "~/.vscode-server-oss/extensions"
cursor = "~/.cursor-server/extensions"
windsurf = "~/.windsurf-server/extensions"
kiro = "~/.kiro-server/extensions"
}

# Extensions directory
final_extensions_dir = var.extensions_dir != "" ? var.extensions_dir : local.default_extensions_dirs[var.protocol]
}

resource "coder_script" "extensions-installer" {
count = length(var.extensions) > 0 || length(var.extensions_urls) > 0 ? 1 : 0
agent_id = var.agent_id
display_name = "${var.coder_app_display_name} Extensions"
icon = var.coder_app_icon
script = templatefile("${path.module}/run.sh", {
EXTENSIONS = join(",", var.extensions)
EXTENSIONS_URLS = join(",", var.extensions_urls)
EXTENSIONS_DIR = local.final_extensions_dir
IDE_TYPE = var.protocol
})
run_on_start = true

lifecycle {
precondition {
condition = !(length(var.extensions) > 0 && length(var.extensions_urls) > 0)
error_message = "Cannot specify both 'extensions' and 'extensions_urls'. Use 'extensions' for normal operation or 'extensions_urls' for airgapped environments."
}
}
}

resource "coder_app" "vscode-desktop" {
agent_id = var.agent_id
external = true

icon = var.coder_app_icon
slug = var.coder_app_slug
display_name = var.coder_app_display_name

order = var.coder_app_order
group = var.coder_app_group
order = var.coder_app_order
group = var.coder_app_group
tooltip = var.coder_app_tooltip

# While the call to "join" is not strictly necessary, it makes the URL more readable.
url = join("", [
Expand All @@ -89,4 +158,4 @@ resource "coder_app" "vscode-desktop" {
output "ide_uri" {
value = coder_app.vscode-desktop.url
description = "IDE URI."
}
}
Loading
Loading