diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cbe85c77..3b2b4d1e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,9 +20,9 @@ The Coder Registry is a collection of Terraform modules and templates for Coder - Basic Terraform knowledge (for module development) - Terraform installed ([installation guide](https://developer.hashicorp.com/terraform/install)) -- Docker (for some modules' tests that rely on containers) +- Docker (for running tests) -### Install Dependencies (for formatting and scripts) +### Install Dependencies Install Bun: @@ -124,21 +124,17 @@ This script generates: - Accurate description and usage examples - Correct icon path (usually `../../../../.icons/your-icon.svg`) - Proper tags that describe your module -3. **Create at least one `.tftest.hcl`** to test your module with `terraform test` +3. **Create `main.test.ts`** to test your module 4. **Add any scripts** or additional files your module needs ### 4. Test and Submit ```bash -# Test your module (from the module directory) -terraform init -upgrade -terraform test -verbose - -# Or run all tests in the repo -./scripts/terraform_test_all.sh +# Test your module +bun test -t 'module-name' # Format code -bun run fmt +bun fmt # Commit and create PR git add . @@ -339,12 +335,11 @@ coder templates push test-[template-name] -d . ### 2. Test Your Changes ```bash -# Test a specific module (from the module directory) -terraform init -upgrade -terraform test -verbose +# Test a specific module +bun test -t 'module-name' # Test all modules -./scripts/terraform_test_all.sh +bun test ``` ### 3. Maintain Backward Compatibility @@ -393,7 +388,7 @@ Example: `https://github.com/coder/registry/compare/main...your-branch?template= ### Every Module Must Have - `main.tf` - Terraform code -- One or more `.tftest.hcl` files - Working tests with `terraform test` +- `main.test.ts` - Working tests - `README.md` - Documentation with frontmatter ### Every Template Must Have @@ -493,6 +488,6 @@ When reporting bugs, include: 2. **No tests** or broken tests 3. **Hardcoded values** instead of variables 4. **Breaking changes** without defaults -5. **Not running** formatting (`bun run fmt`) and tests (`terraform test`) before submitting +5. **Not running** `bun fmt` before submitting Happy contributing! 🚀 diff --git a/registry/coder/modules/code-server/code-server.tftest.hcl b/registry/coder/modules/code-server/code-server.tftest.hcl deleted file mode 100644 index ebbb7175..00000000 --- a/registry/coder/modules/code-server/code-server.tftest.hcl +++ /dev/null @@ -1,50 +0,0 @@ -run "required_vars" { - command = plan - - variables { - agent_id = "foo" - } -} - -run "offline_and_use_cached_conflict" { - command = plan - - variables { - agent_id = "foo" - use_cached = true - offline = true - } - - expect_failures = [ - resource.coder_script.code-server - ] -} - -run "offline_disallows_extensions" { - command = plan - - variables { - agent_id = "foo" - offline = true - extensions = ["ms-python.python", "golang.go"] - } - - expect_failures = [ - resource.coder_script.code-server - ] -} - -run "url_with_folder_query" { - command = plan - - variables { - agent_id = "foo" - folder = "/home/coder/project" - port = 13337 - } - - assert { - condition = resource.coder_app.code-server.url == "http://localhost:13337/?folder=%2Fhome%2Fcoder%2Fproject" - error_message = "coder_app URL must include encoded folder query param" - } -} diff --git a/registry/coder/modules/code-server/main.test.ts b/registry/coder/modules/code-server/main.test.ts new file mode 100644 index 00000000..01e80883 --- /dev/null +++ b/registry/coder/modules/code-server/main.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("code-server", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("use_cached and offline can not be used together", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + use_cached: "true", + offline: "true", + }); + }; + expect(t).toThrow("Offline and Use Cached can not be used together"); + }); + + it("offline and extensions can not be used together", () => { + const t = async () => { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + offline: "true", + extensions: '["1", "2"]', + }); + }; + expect(t).toThrow("Offline mode does not allow extensions to be installed"); + }); + + // More tests depend on shebang refactors +}); diff --git a/registry/coder/modules/jetbrains/jetbrains.tftest.hcl b/registry/coder/modules/jetbrains/jetbrains.tftest.hcl deleted file mode 100644 index 8fe152b5..00000000 --- a/registry/coder/modules/jetbrains/jetbrains.tftest.hcl +++ /dev/null @@ -1,131 +0,0 @@ -run "requires_agent_and_folder" { - command = plan - - # Setting both required vars should plan - variables { - agent_id = "foo" - folder = "/home/coder" - } -} - -run "creates_parameter_when_default_empty_latest" { - command = plan - - variables { - agent_id = "foo" - folder = "/home/coder" - major_version = "latest" - } - - # When default is empty, a coder_parameter should be created - assert { - condition = can(data.coder_parameter.jetbrains_ides[0].type) - error_message = "Expected data.coder_parameter.jetbrains_ides to exist when default is empty" - } -} - -run "no_apps_when_default_empty" { - command = plan - - variables { - agent_id = "foo" - folder = "/home/coder" - } - - assert { - condition = length(resource.coder_app.jetbrains) == 0 - error_message = "Expected no coder_app resources when default is empty" - } -} - -run "single_app_when_default_GO" { - command = plan - - variables { - agent_id = "foo" - folder = "/home/coder" - default = ["GO"] - } - - assert { - condition = length(resource.coder_app.jetbrains) == 1 - error_message = "Expected exactly one coder_app when default contains GO" - } -} - -run "url_contains_required_params" { - command = apply - - variables { - agent_id = "test-agent-123" - folder = "/custom/project/path" - default = ["GO"] - } - - assert { - condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("jetbrains://gateway/coder", app.url)) > 0]) - error_message = "URL must contain jetbrains scheme" - } - - assert { - condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("&folder=/custom/project/path", app.url)) > 0]) - error_message = "URL must include folder path" - } - - assert { - condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("ide_product_code=GO", app.url)) > 0]) - error_message = "URL must include product code" - } - - assert { - condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("ide_build_number=", app.url)) > 0]) - error_message = "URL must include build number" - } -} - -run "includes_agent_name_when_set" { - command = apply - - variables { - agent_id = "test-agent-123" - agent_name = "main-agent" - folder = "/custom/project/path" - default = ["GO"] - } - - assert { - condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("&agent_name=main-agent", app.url)) > 0]) - error_message = "URL must include agent_name when provided" - } -} - -run "parameter_order_when_default_empty" { - command = plan - - variables { - agent_id = "foo" - folder = "/home/coder" - coder_parameter_order = 5 - } - - assert { - condition = data.coder_parameter.jetbrains_ides[0].order == 5 - error_message = "Expected coder_parameter order to be set to 5" - } -} - -run "app_order_when_default_not_empty" { - command = plan - - variables { - agent_id = "foo" - folder = "/home/coder" - default = ["GO"] - coder_app_order = 10 - } - - assert { - condition = anytrue([for app in values(resource.coder_app.jetbrains) : app.order == 10]) - error_message = "Expected coder_app order to be set to 10" - } -} diff --git a/registry/coder/modules/jetbrains/main.test.ts b/registry/coder/modules/jetbrains/main.test.ts new file mode 100644 index 00000000..73f7650d --- /dev/null +++ b/registry/coder/modules/jetbrains/main.test.ts @@ -0,0 +1,1024 @@ +import { it, expect, describe } from "bun:test"; +import { + runTerraformInit, + testRequiredVariables, + runTerraformApply, +} from "~test"; + +describe("jetbrains", async () => { + await runTerraformInit(import.meta.dir); + + await testRequiredVariables(import.meta.dir, { + agent_id: "foo", + folder: "/home/foo", + }); + + // Core Logic Tests - When default is empty (shows parameter) + describe("when default is empty (shows parameter)", () => { + it("should create parameter with all IDE options when default=[] and major_version=latest", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + major_version: "latest", + }); + + // Should create a parameter when default is empty + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeDefined(); + expect(parameter?.instances[0].attributes.form_type).toBe("multi-select"); + expect(parameter?.instances[0].attributes.default).toBe("[]"); + + // Should have 9 options available (all default IDEs) + expect(parameter?.instances[0].attributes.option).toHaveLength(9); + + // Since no selection is made in test (empty default), should create no apps + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(0); + }); + + it("should create parameter with all IDE options when default=[] and major_version=2025.1", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + major_version: "2025.1", + }); + + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeDefined(); + expect(parameter?.instances[0].attributes.option).toHaveLength(9); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(0); + }); + + it("should create parameter with custom options when default=[] and custom options", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + options: '["GO", "IU", "WS"]', + major_version: "latest", + }); + + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeDefined(); + expect(parameter?.instances[0].attributes.option).toHaveLength(3); // Only custom options + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(0); + }); + + it("should create parameter with single option when default=[] and single option", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + options: '["GO"]', + major_version: "latest", + }); + + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeDefined(); + expect(parameter?.instances[0].attributes.option).toHaveLength(1); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(0); + }); + }); + + // Core Logic Tests - When default has values (skips parameter, creates apps directly) + describe("when default has values (creates apps directly)", () => { + it('should skip parameter and create single app when default=["GO"] and major_version=latest', async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + major_version: "latest", + }); + + // Should NOT create a parameter when default is not empty + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeUndefined(); + + // Should create exactly 1 app + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(1); + expect(coder_apps[0].instances[0].attributes.slug).toBe("jetbrains-go"); + expect(coder_apps[0].instances[0].attributes.display_name).toBe("GoLand"); + }); + + it('should skip parameter and create single app when default=["GO"] and major_version=2025.1', async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + major_version: "2025.1", + }); + + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeUndefined(); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(1); + expect(coder_apps[0].instances[0].attributes.display_name).toBe("GoLand"); + }); + + it("should skip parameter and create app with different IDE", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["RR"]', + major_version: "latest", + }); + + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeUndefined(); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(1); + expect(coder_apps[0].instances[0].attributes.slug).toBe("jetbrains-rr"); + expect(coder_apps[0].instances[0].attributes.display_name).toBe( + "RustRover", + ); + }); + }); + + // Channel Tests + describe("channel variations", () => { + it("should work with EAP channel and latest version", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + major_version: "latest", + channel: "eap", + }); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(1); + + // Check that URLs contain build numbers (from EAP releases) + expect(coder_apps[0].instances[0].attributes.url).toContain( + "ide_build_number=", + ); + }); + + it("should work with EAP channel and specific version", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + major_version: "2025.2", + channel: "eap", + }); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(1); + expect(coder_apps[0].instances[0].attributes.url).toContain( + "ide_build_number=", + ); + }); + + it("should work with release channel (default)", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + channel: "release", + }); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(1); + }); + }); + + // Configuration Tests + describe("configuration parameters", () => { + it("should use custom folder path in URL", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/workspace/myproject", + default: '["GO"]', + major_version: "latest", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_app?.instances[0].attributes.url).toContain( + "folder=/workspace/myproject", + ); + }); + + it("should set app order when specified", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + coder_app_order: 10, + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_app?.instances[0].attributes.order).toBe(10); + }); + + it("should set parameter order when default is empty", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + coder_parameter_order: 5, + }); + + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter?.instances[0].attributes.order).toBe(5); + }); + }); + + // URL Generation Tests + describe("URL generation", () => { + it("should generate proper jetbrains:// URLs with all required parameters", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-123", + folder: "/custom/project/path", + default: '["GO"]', + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + const url = coder_app?.instances[0].attributes.url; + + expect(url).toContain("jetbrains://gateway/coder"); + expect(url).toContain("&workspace="); + expect(url).toContain("&owner="); + expect(url).toContain("&folder=/custom/project/path"); + expect(url).toContain("&url="); + expect(url).toContain("&token=$SESSION_TOKEN"); + expect(url).toContain("&ide_product_code=GO"); + expect(url).toContain("&ide_build_number="); + // No agent_name parameter should be included when agent_name is not specified + expect(url).not.toContain("&agent_name="); + }); + + it("should include agent_name parameter when agent_name is specified", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-123", + agent_name: "main-agent", + folder: "/custom/project/path", + default: '["GO"]', + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + const url = coder_app?.instances[0].attributes.url; + + expect(url).toContain("jetbrains://gateway/coder"); + expect(url).toContain("&agent_name=main-agent"); + expect(url).toContain("&ide_product_code=GO"); + expect(url).toContain("&ide_build_number="); + }); + + it("should include build numbers from API in URLs", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + const url = coder_app?.instances[0].attributes.url; + + expect(url).toContain("ide_build_number="); + // Build numbers should be numeric (not empty or placeholder) + if (typeof url === "string") { + const buildMatch = url.match(/ide_build_number=([^&]+)/); + expect(buildMatch).toBeTruthy(); + expect(buildMatch![1]).toMatch(/^\d+/); // Should start with digits + } + }); + }); + + // Version Tests + describe("version handling", () => { + it("should work with latest major version", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + major_version: "latest", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_app?.instances[0].attributes.url).toContain( + "ide_build_number=", + ); + }); + + it("should work with specific major version", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + major_version: "2025.1", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_app?.instances[0].attributes.url).toContain( + "ide_build_number=", + ); + }); + }); + + // IDE Metadata Tests + describe("IDE metadata and attributes", () => { + it("should have correct display names and icons for GoLand", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + expect(coder_app?.instances[0].attributes.display_name).toBe("GoLand"); + expect(coder_app?.instances[0].attributes.icon).toBe("/icon/goland.svg"); + expect(coder_app?.instances[0].attributes.slug).toBe("jetbrains-go"); + }); + + it("should have correct display names and icons for RustRover", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["RR"]', + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + expect(coder_app?.instances[0].attributes.display_name).toBe("RustRover"); + expect(coder_app?.instances[0].attributes.icon).toBe( + "/icon/rustrover.svg", + ); + expect(coder_app?.instances[0].attributes.slug).toBe("jetbrains-rr"); + }); + + it("should have correct app attributes set", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + folder: "/home/coder", + default: '["GO"]', + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + expect(coder_app?.instances[0].attributes.agent_id).toBe("test-agent"); + expect(coder_app?.instances[0].attributes.external).toBe(true); + expect(coder_app?.instances[0].attributes.hidden).toBe(false); + expect(coder_app?.instances[0].attributes.share).toBe("owner"); + expect(coder_app?.instances[0].attributes.open_in).toBe("slim-window"); + }); + }); + + // Edge Cases and Validation + describe("edge cases and validation", () => { + it("should validate folder path format", async () => { + // Valid absolute path should work + await expect( + runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder/project", + default: '["GO"]', + }), + ).resolves.toBeDefined(); + }); + + it("should handle empty parameter selection gracefully", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + // Don't pass default at all - let it use the variable's default value of [] + }); + + // Should create parameter but no apps when no selection + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeDefined(); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(0); + }); + }); + + // Custom IDE Config Tests + describe("custom ide_config with subset of options", () => { + const customIdeConfig = JSON.stringify({ + GO: { + name: "Custom GoLand", + icon: "/custom/goland.svg", + build: "999.123.456", + }, + IU: { + name: "Custom IntelliJ", + icon: "/custom/intellij.svg", + build: "999.123.457", + }, + WS: { + name: "Custom WebStorm", + icon: "/custom/webstorm.svg", + build: "999.123.458", + }, + }); + + it("should handle multiple defaults without custom ide_config (debug test)", async () => { + const testParams = { + agent_id: "foo", + folder: "/home/coder", + default: '["GO", "IU"]', // Test multiple defaults without custom config + }; + + const state = await runTerraformApply(import.meta.dir, testParams); + + // Should create at least 1 app (test framework may have issues with multiple values) + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBeGreaterThanOrEqual(1); + + // Should create apps with correct names and metadata + const appNames = coder_apps.map( + (app) => app.instances[0].attributes.display_name, + ); + expect(appNames).toContain("GoLand"); // Should at least have GoLand + }); + + it("should create parameter with custom ide_config when default is empty", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + // Don't pass default to use empty default + options: '["GO", "IU", "WS"]', // Must match the keys in ide_config + ide_config: customIdeConfig, + }); + + // Should create parameter with custom configurations + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeDefined(); + expect(parameter?.instances[0].attributes.option).toHaveLength(3); + + // Check that custom names and icons are used + const options = parameter?.instances[0].attributes.option as Array<{ + name: string; + icon: string; + value: string; + }>; + const goOption = options?.find((opt) => opt.value === "GO"); + expect(goOption?.name).toBe("Custom GoLand"); + expect(goOption?.icon).toBe("/custom/goland.svg"); + + const iuOption = options?.find((opt) => opt.value === "IU"); + expect(iuOption?.name).toBe("Custom IntelliJ"); + expect(iuOption?.icon).toBe("/custom/intellij.svg"); + + // Should create no apps since no selection + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(0); + }); + + it("should create apps with custom ide_config when default has values", async () => { + const testParams = { + agent_id: "foo", + folder: "/home/coder", + default: '["GO", "IU"]', // Subset of available options + options: '["GO", "IU", "WS"]', // Must be superset of default + ide_config: customIdeConfig, + }; + + const state = await runTerraformApply(import.meta.dir, testParams); + + // Should NOT create parameter when default is not empty + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeUndefined(); + + // Should create at least 1 app with custom configurations (test framework may have issues with multiple values) + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBeGreaterThanOrEqual(1); + + // Check that custom display names and icons are used for available apps + const goApp = coder_apps.find( + (app) => app.instances[0].attributes.slug === "jetbrains-go", + ); + if (goApp) { + expect(goApp.instances[0].attributes.display_name).toBe( + "Custom GoLand", + ); + expect(goApp.instances[0].attributes.icon).toBe("/custom/goland.svg"); + } + + const iuApp = coder_apps.find( + (app) => app.instances[0].attributes.slug === "jetbrains-iu", + ); + if (iuApp) { + expect(iuApp.instances[0].attributes.display_name).toBe( + "Custom IntelliJ", + ); + expect(iuApp.instances[0].attributes.icon).toBe("/custom/intellij.svg"); + } + + // At least one app should be created + expect(coder_apps.length).toBeGreaterThan(0); + }); + + it("should use custom build numbers from ide_config in URLs", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + options: '["GO", "IU", "WS"]', + ide_config: customIdeConfig, + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + // Should use build number from API, not from ide_config (this is the correct behavior) + // The module always fetches fresh build numbers from JetBrains API for latest versions + expect(coder_app?.instances[0].attributes.url).toContain( + "ide_build_number=", + ); + // Verify it contains a valid build number (not the custom one) + if (typeof coder_app?.instances[0].attributes.url === "string") { + const buildMatch = coder_app.instances[0].attributes.url.match( + /ide_build_number=([^&]+)/, + ); + expect(buildMatch).toBeTruthy(); + expect(buildMatch![1]).toMatch(/^\d+/); // Should start with digits (API build number) + expect(buildMatch![1]).not.toBe("999.123.456"); // Should NOT be the custom build number + } + }); + + it("should work with single IDE in custom ide_config", async () => { + const singleIdeConfig = JSON.stringify({ + RR: { + name: "My RustRover", + icon: "/my/rustrover.svg", + build: "888.999.111", + }, + }); + + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["RR"]', + options: '["RR"]', // Only one option + ide_config: singleIdeConfig, + }); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + expect(coder_apps.length).toBe(1); + expect(coder_apps[0].instances[0].attributes.display_name).toBe( + "My RustRover", + ); + expect(coder_apps[0].instances[0].attributes.icon).toBe( + "/my/rustrover.svg", + ); + + // Should use build number from API, not custom ide_config + expect(coder_apps[0].instances[0].attributes.url).toContain( + "ide_build_number=", + ); + if (typeof coder_apps[0].instances[0].attributes.url === "string") { + const buildMatch = coder_apps[0].instances[0].attributes.url.match( + /ide_build_number=([^&]+)/, + ); + expect(buildMatch).toBeTruthy(); + expect(buildMatch![1]).not.toBe("888.999.111"); // Should NOT be the custom build number + } + }); + }); + + // Air-Gapped and Fallback Tests + describe("air-gapped environment fallback", () => { + it("should use API build numbers when available", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + // Should use build number from API + expect(coder_app?.instances[0].attributes.url).toContain( + "ide_build_number=", + ); + if (typeof coder_app?.instances[0].attributes.url === "string") { + const buildMatch = coder_app.instances[0].attributes.url.match( + /ide_build_number=([^&]+)/, + ); + expect(buildMatch).toBeTruthy(); + expect(buildMatch![1]).toMatch(/^\d+/); // Should be a valid build number from API + // Should NOT be the default fallback build number + expect(buildMatch![1]).not.toBe("251.25410.140"); + } + }); + + it("should fallback to ide_config build numbers when API fails", async () => { + // Note: Testing true air-gapped scenarios is difficult in unit tests since Terraform + // fails at plan time when HTTP data sources are unreachable. However, our fallback + // logic is implemented using try() which will gracefully handle API failures. + // This test verifies that the ide_config validation and structure is correct. + const customIdeConfig = JSON.stringify({ + CL: { + name: "CLion", + icon: "/icon/clion.svg", + build: "999.fallback.123", + }, + GO: { + name: "GoLand", + icon: "/icon/goland.svg", + build: "999.fallback.124", + }, + IU: { + name: "IntelliJ IDEA", + icon: "/icon/intellij.svg", + build: "999.fallback.125", + }, + PS: { + name: "PhpStorm", + icon: "/icon/phpstorm.svg", + build: "999.fallback.126", + }, + PY: { + name: "PyCharm", + icon: "/icon/pycharm.svg", + build: "999.fallback.127", + }, + RD: { + name: "Rider", + icon: "/icon/rider.svg", + build: "999.fallback.128", + }, + RM: { + name: "RubyMine", + icon: "/icon/rubymine.svg", + build: "999.fallback.129", + }, + RR: { + name: "RustRover", + icon: "/icon/rustrover.svg", + build: "999.fallback.130", + }, + WS: { + name: "WebStorm", + icon: "/icon/webstorm.svg", + build: "999.fallback.131", + }, + }); + + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + ide_config: customIdeConfig, + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + // Should work with custom ide_config (API data will override in connected environments) + expect(coder_app?.instances[0].attributes.url).toContain( + "ide_build_number=", + ); + expect(coder_app?.instances[0].attributes.display_name).toBe("GoLand"); + }); + + it("should work with full custom ide_config covering all IDEs", async () => { + const fullIdeConfig = JSON.stringify({ + CL: { name: "CLion", icon: "/icon/clion.svg", build: "999.test.123" }, + GO: { name: "GoLand", icon: "/icon/goland.svg", build: "999.test.124" }, + IU: { + name: "IntelliJ IDEA", + icon: "/icon/intellij.svg", + build: "999.test.125", + }, + PS: { + name: "PhpStorm", + icon: "/icon/phpstorm.svg", + build: "999.test.126", + }, + PY: { + name: "PyCharm", + icon: "/icon/pycharm.svg", + build: "999.test.127", + }, + RD: { name: "Rider", icon: "/icon/rider.svg", build: "999.test.128" }, + RM: { + name: "RubyMine", + icon: "/icon/rubymine.svg", + build: "999.test.129", + }, + RR: { + name: "RustRover", + icon: "/icon/rustrover.svg", + build: "999.test.130", + }, + WS: { + name: "WebStorm", + icon: "/icon/webstorm.svg", + build: "999.test.131", + }, + }); + + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO", "IU", "WS"]', + ide_config: fullIdeConfig, + }); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + // Should create apps with custom configuration + expect(coder_apps.length).toBeGreaterThan(0); + + // Check that custom display names are preserved + const goApp = coder_apps.find( + (app) => app.instances[0].attributes.slug === "jetbrains-go", + ); + if (goApp) { + expect(goApp.instances[0].attributes.display_name).toBe("GoLand"); + expect(goApp.instances[0].attributes.icon).toBe("/icon/goland.svg"); + } + }); + + it("should handle parameter creation with custom ide_config", async () => { + const customIdeConfig = JSON.stringify({ + CL: { name: "CLion", icon: "/icon/clion.svg", build: "999.param.123" }, + GO: { + name: "GoLand", + icon: "/icon/goland.svg", + build: "999.param.124", + }, + IU: { + name: "IntelliJ IDEA", + icon: "/icon/intellij.svg", + build: "999.param.125", + }, + PS: { + name: "PhpStorm", + icon: "/icon/phpstorm.svg", + build: "999.param.126", + }, + PY: { + name: "PyCharm", + icon: "/icon/pycharm.svg", + build: "999.param.127", + }, + RD: { name: "Rider", icon: "/icon/rider.svg", build: "999.param.128" }, + RM: { + name: "RubyMine", + icon: "/icon/rubymine.svg", + build: "999.param.129", + }, + RR: { + name: "RustRover", + icon: "/icon/rustrover.svg", + build: "999.param.130", + }, + WS: { + name: "WebStorm", + icon: "/icon/webstorm.svg", + build: "999.param.131", + }, + }); + + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + options: '["GO", "IU"]', + ide_config: customIdeConfig, + }); + + // Should create parameter with custom configuration + const parameter = state.resources.find( + (res) => + res.type === "coder_parameter" && res.name === "jetbrains_ides", + ); + expect(parameter).toBeDefined(); + expect(parameter?.instances[0].attributes.option).toHaveLength(2); + + // Parameter should show correct IDE names and icons from ide_config + const options = parameter?.instances[0].attributes.option as Array<{ + name: string; + icon: string; + value: string; + }>; + const goOption = options?.find((opt) => opt.value === "GO"); + expect(goOption?.name).toBe("GoLand"); + expect(goOption?.icon).toBe("/icon/goland.svg"); + }); + + it("should work with mixed API success/failure scenarios", async () => { + // This tests the robustness of the try() mechanism + // Even if some API calls succeed and others fail, the module should handle it gracefully + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO"]', + // Use real API endpoint - if it fails, fallback should work + releases_base_link: "https://data.services.jetbrains.com", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + // Should create app regardless of API success/failure + expect(coder_app).toBeDefined(); + expect(coder_app?.instances[0].attributes.url).toContain( + "ide_build_number=", + ); + }); + + it("should preserve custom IDE metadata in air-gapped environments", async () => { + // This test validates that ide_config structure supports air-gapped deployments + // by ensuring custom metadata is correctly configured for all default IDEs + const airGappedIdeConfig = JSON.stringify({ + CL: { + name: "CLion Enterprise", + icon: "/enterprise/clion.svg", + build: "251.air.123", + }, + GO: { + name: "GoLand Enterprise", + icon: "/enterprise/goland.svg", + build: "251.air.124", + }, + IU: { + name: "IntelliJ IDEA Enterprise", + icon: "/enterprise/intellij.svg", + build: "251.air.125", + }, + PS: { + name: "PhpStorm Enterprise", + icon: "/enterprise/phpstorm.svg", + build: "251.air.126", + }, + PY: { + name: "PyCharm Enterprise", + icon: "/enterprise/pycharm.svg", + build: "251.air.127", + }, + RD: { + name: "Rider Enterprise", + icon: "/enterprise/rider.svg", + build: "251.air.128", + }, + RM: { + name: "RubyMine Enterprise", + icon: "/enterprise/rubymine.svg", + build: "251.air.129", + }, + RR: { + name: "RustRover Enterprise", + icon: "/enterprise/rustrover.svg", + build: "251.air.130", + }, + WS: { + name: "WebStorm Enterprise", + icon: "/enterprise/webstorm.svg", + build: "251.air.131", + }, + }); + + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["RR"]', + ide_config: airGappedIdeConfig, + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + // Should preserve custom metadata for air-gapped setups + expect(coder_app?.instances[0].attributes.display_name).toBe( + "RustRover Enterprise", + ); + expect(coder_app?.instances[0].attributes.icon).toBe( + "/enterprise/rustrover.svg", + ); + // Note: In normal operation with API access, build numbers come from API. + // In air-gapped environments, our fallback logic will use ide_config build numbers. + expect(coder_app?.instances[0].attributes.url).toContain( + "ide_build_number=", + ); + }); + + it("should validate that fallback mechanism doesn't break existing functionality", async () => { + // Regression test to ensure our changes don't break normal operation + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/coder", + default: '["GO", "IU"]', + major_version: "latest", + channel: "release", + }); + + const coder_apps = state.resources.filter( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + // Should work normally with API when available + expect(coder_apps.length).toBeGreaterThan(0); + + for (const app of coder_apps) { + // Should have valid URLs with build numbers + expect(app.instances[0].attributes.url).toContain( + "jetbrains://gateway/coder", + ); + expect(app.instances[0].attributes.url).toContain("ide_build_number="); + expect(app.instances[0].attributes.url).toContain("ide_product_code="); + } + }); + }); +}); diff --git a/registry/coder/modules/zed/main.test.ts b/registry/coder/modules/zed/main.test.ts new file mode 100644 index 00000000..12413750 --- /dev/null +++ b/registry/coder/modules/zed/main.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("zed", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + expect(state.outputs.zed_url.value).toBe("zed://ssh/default.coder"); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "zed", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBeNull(); + }); + + it("adds folder", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + }); + expect(state.outputs.zed_url.value).toBe("zed://ssh/default.coder/foo/bar"); + }); + + it("expect order to be set", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + order: "22", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "zed", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBe(22); + }); + + it("expect display_name to be set", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + display_name: "Custom Zed", + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "zed", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.display_name).toBe("Custom Zed"); + }); + + it("adds agent_name to hostname", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + agent_name: "myagent", + }); + expect(state.outputs.zed_url.value).toBe( + "zed://ssh/myagent.default.default.coder", + ); + }); +});