diff --git a/.icons/slack.svg b/.icons/slack.svg new file mode 100644 index 0000000..fb55f72 --- /dev/null +++ b/.icons/slack.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/.sample/run.sh b/.sample/run.sh index 06ada9b..e0d1afc 100755 --- a/.sample/run.sh +++ b/.sample/run.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env sh +#!/usr/bin/env bash BOLD='\033[0;1m' printf "$${BOLD}Installing MODULE_NAME ...\n\n" diff --git a/code-server/main.test.ts b/code-server/main.test.ts new file mode 100644 index 0000000..daf3ac1 --- /dev/null +++ b/code-server/main.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "bun:test"; +import { runTerraformInit, testRequiredVariables } from "../test"; + +describe("code-server", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + // More tests depend on shebang refactors +}); diff --git a/coder-login/main.tf b/coder-login/main.tf index 2d3ac8b..58d1bf0 100644 --- a/coder-login/main.tf +++ b/coder-login/main.tf @@ -23,7 +23,7 @@ resource "coder_script" "coder-login" { CODER_DEPLOYMENT_URL : data.coder_workspace.me.access_url }) display_name = "Coder Login" - icon = "http://svgur.com/i/y5G.svg" + icon = "/icon/coder.svg" run_on_start = true start_blocks_login = true } diff --git a/dotfiles/main.test.ts b/dotfiles/main.test.ts new file mode 100644 index 0000000..69eda32 --- /dev/null +++ b/dotfiles/main.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("dotfiles", 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.dotfiles_uri.value).toBe(""); + }); +}); diff --git a/dotfiles/main.tf b/dotfiles/main.tf index d3d0de2..c0b0135 100644 --- a/dotfiles/main.tf +++ b/dotfiles/main.tf @@ -36,3 +36,8 @@ resource "coder_script" "personalize" { icon = "/icon/dotfiles.svg" run_on_start = true } + +output "dotfiles_uri" { + description = "Dotfiles URI" + value = data.coder_parameter.dotfiles_uri.value +} \ No newline at end of file diff --git a/filebrowser/run.sh b/filebrowser/run.sh index ffbee0f..b54a051 100644 --- a/filebrowser/run.sh +++ b/filebrowser/run.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env sh +#!/usr/bin/env bash BOLD='\033[0;1m' printf "$${BOLD}Installing filebrowser \n\n" diff --git a/fly-region/main.test.ts b/fly-region/main.test.ts index 86f6bfc..7e72586 100644 --- a/fly-region/main.test.ts +++ b/fly-region/main.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "bun:test"; import { - executeScriptInContainer, runTerraformApply, runTerraformInit, testRequiredVariables, @@ -22,4 +21,12 @@ describe("fly-region", async () => { }); expect(state.outputs.value.value).toBe("atl"); }); + + it("region filter", async () => { + const state = await runTerraformApply(import.meta.dir, { + default: "atl", + regions: '["arn", "ams", "bos"]', + }); + expect(state.outputs.value.value).toBe(""); + }); }); diff --git a/gcp-region/README.md b/gcp-region/README.md index 0ca76e1..ab5daf3 100644 --- a/gcp-region/README.md +++ b/gcp-region/README.md @@ -28,6 +28,8 @@ resource "google_compute_instance" "example" { ### Add only GPU zones in the US West 1 region +Note: setting `gpu_only = true` and using a default region without GPU support, the default will be set to `null`. + ```hcl module "gcp_region" { source = "https://registry.coder.com/modules/gcp-region" diff --git a/gcp-region/main.test.ts b/gcp-region/main.test.ts new file mode 100644 index 0000000..2ec623b --- /dev/null +++ b/gcp-region/main.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("gcp-region", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, {}); + + it("default output", async () => { + const state = await runTerraformApply(import.meta.dir, {}); + expect(state.outputs.value.value).toBe(""); + }); + + it("customized default", async () => { + const state = await runTerraformApply(import.meta.dir, { + regions: '["asia"]', + default: "asia-east1-a", + }); + expect(state.outputs.value.value).toBe("asia-east1-a"); + }); + + it("gpu only invalid default", async () => { + const state = await runTerraformApply(import.meta.dir, { + regions: '["us-west2"]', + default: "us-west2-a", + gpu_only: "true", + }); + expect(state.outputs.value.value).toBe(""); + }); + + it("gpu only valid default", async () => { + const state = await runTerraformApply(import.meta.dir, { + regions: '["us-west2"]', + default: "us-west2-b", + gpu_only: "true", + }); + expect(state.outputs.value.value).toBe("us-west2-b"); + }); +}); diff --git a/gcp-region/main.tf b/gcp-region/main.tf index 4d675c8..e9f549d 100644 --- a/gcp-region/main.tf +++ b/gcp-region/main.tf @@ -714,7 +714,7 @@ data "coder_parameter" "region" { description = var.description icon = "/icon/gcp.png" mutable = var.mutable - default = var.default != null && var.default != "" ? var.default : null + default = var.default != null && var.default != "" && (!var.gpu_only || try(local.zones[var.default].gpu, false)) ? var.default : null dynamic "option" { for_each = { for k, v in local.zones : k => v diff --git a/git-clone/run.sh b/git-clone/run.sh index 1fee8da..2b537f4 100755 --- a/git-clone/run.sh +++ b/git-clone/run.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env sh +#!/usr/bin/env bash REPO_URL="${REPO_URL}" CLONE_PATH="${CLONE_PATH}" @@ -23,16 +23,19 @@ if ! command -v git >/dev/null; then exit 1 fi -# Check if the directory exists... +# Check if the directory for the cloning exists +# and if not, create it if [ ! -d "$CLONE_PATH" ]; then echo "Creating directory $CLONE_PATH..." mkdir -p "$CLONE_PATH" +fi + +# Check if the directory is empty +# and if it is, clone the repo, otherwise skip cloning +if [ -z "$(ls -A "$CLONE_PATH")" ]; then + echo "Cloning $REPO_URL to $CLONE_PATH..." + git clone "$REPO_URL" "$CLONE_PATH" else - echo "$CLONE_PATH already exists, skipping clone!" + echo "$CLONE_PATH already exists and isn't empty, skipping clone!" exit 0 fi - -# Clone the repository... -echo "Cloning $REPO_URL to $CLONE_PATH..." -git clone "$REPO_URL" "$CLONE_PATH" - diff --git a/jetbrains-gateway/main.test.ts b/jetbrains-gateway/main.test.ts index ffd970f..9a0628f 100644 --- a/jetbrains-gateway/main.test.ts +++ b/jetbrains-gateway/main.test.ts @@ -1,7 +1,12 @@ -import { describe } from "bun:test"; -import { runTerraformInit, testRequiredVariables } from "../test"; +import { it, expect, describe } from "bun:test"; +import { + runTerraformInit, + testRequiredVariables, + executeScriptInContainer, + runTerraformApply, +} from "../test"; -describe("jetbrains-gateway`", async () => { +describe("jetbrains-gateway", async () => { await runTerraformInit(import.meta.dir); await testRequiredVariables(import.meta.dir, { @@ -10,4 +15,16 @@ describe("jetbrains-gateway`", async () => { folder: "/baz/", jetbrains_ides: '["IU", "IC", "PY"]', }); + + it("default to first ide", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + agent_name: "bar", + folder: "/baz/", + jetbrains_ides: '["IU", "IC", "PY"]', + }); + expect(state.outputs.jetbrains_ides.value).toBe( + '["IU","232.9921.47","https://download.jetbrains.com/idea/ideaIU-2023.2.2.tar.gz"]', + ); + }); }); diff --git a/jupyterlab/main.test.ts b/jupyterlab/main.test.ts new file mode 100644 index 0000000..2597dc2 --- /dev/null +++ b/jupyterlab/main.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "bun:test"; +import { + executeScriptInContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, + findResourceInstance, + runContainer, + TerraformState, + execContainer, +} from "../test"; + +// executes the coder script after installing pip +const executeScriptInContainerWithPip = async ( + state: TerraformState, + image: string, + shell: string = "sh", +): Promise<{ + exitCode: number; + stdout: string[]; + stderr: string[]; +}> => { + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer(image); + const respPip = await execContainer(id, [shell, "-c", "apk add py3-pip"]); + const resp = await execContainer(id, [shell, "-c", instance.script]); + const stdout = resp.stdout.trim().split("\n"); + const stderr = resp.stderr.trim().split("\n"); + return { + exitCode: resp.exitCode, + stdout, + stderr, + }; +}; + +describe("jupyterlab", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("fails without pip3", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + const output = await executeScriptInContainer(state, "alpine"); + expect(output.exitCode).toBe(1); + expect(output.stdout).toEqual([ + "\u001B[0;1mInstalling jupyterlab!", + "pip3 is not installed", + "Please install pip3 in your Dockerfile/VM image before running this script", + ]); + }); + + // TODO: Add faster test to run with pip3. + // currently times out. + // it("runs with pip3", async () => { + // ... + // const output = await executeScriptInContainerWithPip(state, "alpine"); + // ... + // }); +}); diff --git a/personalize/main.test.ts b/personalize/main.test.ts new file mode 100644 index 0000000..9c8134e --- /dev/null +++ b/personalize/main.test.ts @@ -0,0 +1,33 @@ +import { readableStreamToText, spawn } from "bun"; +import { describe, expect, it } from "bun:test"; +import { + executeScriptInContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, + runContainer, + execContainer, + findResourceInstance, +} from "../test"; + +describe("personalize", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("warns without personalize script", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + const output = await executeScriptInContainer(state, "alpine"); + expect(output.exitCode).toBe(0); + expect(output.stdout).toEqual([ + "✨ \u001b[0;1mYou don't have a personalize script!", + "", + "Run \u001b[36;40;1mtouch ~/personalize && chmod +x ~/personalize\u001b[0m to create one.", + "It will run every time your workspace starts. Use it to install personal packages!", + ]); + }); +}); diff --git a/personalize/run.sh b/personalize/run.sh index 94a9539..18fea76 100755 --- a/personalize/run.sh +++ b/personalize/run.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env sh +#!/usr/bin/env bash BOLD='\033[0;1m' CODE='\033[36;40;1m' diff --git a/slackme/README.md b/slackme/README.md new file mode 100644 index 0000000..017f06a --- /dev/null +++ b/slackme/README.md @@ -0,0 +1,81 @@ +--- +display_name: Slack Me +description: Send a Slack message when a command finishes inside a workspace! +icon: ../.icons/slack.svg +maintainer_github: coder +verified: true +tags: [helper] +--- + +# Slack Me + +Add the `slackme` command to your workspace that DMs you on Slack when your command finishes running. + +```bash +$ slackme npm run long-build +``` + +## Setup + +1. Navigate to [Create a Slack App](https://api.slack.com/apps?new_app=1) and select "From an app manifest". Select a workspace and paste in the following manifest, adjusting the redirect URL to your Coder deployment: + + ```json + { + "display_information": { + "name": "Command Notify", + "description": "Notify developers when commands finish running inside Coder!", + "background_color": "#1b1b1c" + }, + "features": { + "bot_user": { + "display_name": "Command Notify" + } + }, + "oauth_config": { + "redirect_urls": [ + "https:///external-auth/slack/callback" + ], + "scopes": { + "bot": ["chat:write"] + } + } + } + ``` + +2. In the "Basic Information" tab on the left after creating your app, scroll down to the "App Credentials" section. Set the following environment variables in your Coder deployment: + + ```env + CODER_EXTERNAL_AUTH_1_TYPE=slack + CODER_EXTERNAL_AUTH_1_SCOPES="chat:write" + CODER_EXTERNAL_AUTH_1_DISPLAY_NAME="Slack Me" + CODER_EXTERNAL_AUTH_1_CLIENT_ID=" + CODER_EXTERNAL_AUTH_1_CLIENT_SECRET="" + ``` + +3. Restart your Coder deployment. Any Template can now import the Slack Me module, and `slackme` will be available on the `$PATH`: + + ```hcl + module "slackme" { + source = "https://registry.coder.com/modules/slackme" + agent_id = coder_agent.example.id + auth_provider_id = "slack" + } + ``` + +## Examples + +### Custom Slack Message + +- `$COMMAND` is replaced with the command the user executed. +- `$DURATION` is replaced with a human-readable duration the command took to execute. + +```hcl +module "slackme" { + source = "https://registry.coder.com/modules/slackme" + agent_id = coder_agent.example.id + auth_provider_id = "slack" + slack_message = < { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + auth_provider_id: "foo", + }); + + it("writes to path as executable", async () => { + const { instance, id } = await setupContainer(); + await writeCoder(id, "exit 0"); + let exec = await execContainer(id, ["sh", "-c", instance.script]); + expect(exec.exitCode).toBe(0); + exec = await execContainer(id, ["sh", "-c", "which slackme"]); + expect(exec.exitCode).toBe(0); + expect(exec.stdout.trim()).toEqual("/usr/bin/slackme"); + }); + + it("prints usage with no command", async () => { + const { instance, id } = await setupContainer(); + await writeCoder(id, "echo 👋"); + let exec = await execContainer(id, ["sh", "-c", instance.script]); + expect(exec.exitCode).toBe(0); + exec = await execContainer(id, ["sh", "-c", "slackme"]); + expect(exec.stdout.trim()).toStartWith( + "slackme — Send a Slack notification when a command finishes", + ); + }); + + it("displays url when not authenticated", async () => { + const { instance, id } = await setupContainer(); + await writeCoder(id, "echo 'some-url' && exit 1"); + let exec = await execContainer(id, ["sh", "-c", instance.script]); + expect(exec.exitCode).toBe(0); + exec = await execContainer(id, ["sh", "-c", "slackme echo test"]); + expect(exec.stdout.trim()).toEndWith("some-url"); + }); + + it("default output", async () => { + await assertSlackMessage({ + command: "echo test", + durationMS: 2, + output: "👨‍💻 `echo test` completed in 2ms", + }); + }); + + it("formats multiline message", async () => { + await assertSlackMessage({ + command: "echo test", + format: `this command: +\`$COMMAND\` +executed`, + output: `this command: +\`echo test\` +executed`, + }); + }); + + it("formats execution with milliseconds", async () => { + await assertSlackMessage({ + command: "echo test", + format: `$COMMAND took $DURATION`, + durationMS: 150, + output: "echo test took 150ms", + }); + }); + + it("formats execution with seconds", async () => { + await assertSlackMessage({ + command: "echo test", + format: `$COMMAND took $DURATION`, + durationMS: 15000, + output: "echo test took 15.0s", + }); + }); + + it("formats execution with minutes", async () => { + await assertSlackMessage({ + command: "echo test", + format: `$COMMAND took $DURATION`, + durationMS: 120000, + output: "echo test took 2m 0.0s", + }); + }); + + it("formats execution with hours", async () => { + await assertSlackMessage({ + command: "echo test", + format: `$COMMAND took $DURATION`, + durationMS: 60000 * 60, + output: "echo test took 1hr 0m 0.0s", + }); + }); +}); + +const setupContainer = async ( + image = "alpine", + vars: Record = {}, +) => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + auth_provider_id: "foo", + ...vars, + }); + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer(image); + return { id, instance }; +}; + +const writeCoder = async (id: string, script: string) => { + const exec = await execContainer(id, [ + "sh", + "-c", + `echo '${script}' > /usr/bin/coder && chmod +x /usr/bin/coder`, + ]); + expect(exec.exitCode).toBe(0); +}; + +const assertSlackMessage = async (opts: { + command: string; + format?: string; + durationMS?: number; + output: string; +}) => { + let url: URL; + const fakeSlackHost = serve({ + fetch: (req) => { + url = new URL(req.url); + if (url.pathname === "/api/chat.postMessage") + return createJSONResponse({ + ok: true, + }); + return createJSONResponse({}, 404); + }, + port: 0, + }); + const { instance, id } = await setupContainer( + "alpine/curl", + opts.format && { + slack_message: opts.format, + }, + ); + await writeCoder(id, "echo 'token'"); + let exec = await execContainer(id, ["sh", "-c", instance.script]); + expect(exec.exitCode).toBe(0); + exec = await execContainer(id, [ + "sh", + "-c", + `DURATION_MS=${opts.durationMS || 0} SLACK_URL="http://${ + fakeSlackHost.hostname + }:${fakeSlackHost.port}" slackme ${opts.command}`, + ]); + expect(exec.stderr.trim()).toBe(""); + expect(url.pathname).toEqual("/api/chat.postMessage"); + expect(url.searchParams.get("channel")).toEqual("token"); + expect(url.searchParams.get("text")).toEqual(opts.output); +}; diff --git a/slackme/main.tf b/slackme/main.tf new file mode 100644 index 0000000..5fe948e --- /dev/null +++ b/slackme/main.tf @@ -0,0 +1,46 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "auth_provider_id" { + type = string + description = "The ID of an external auth provider." +} + +variable "slack_message" { + type = string + description = "The message to send to Slack." + default = "👨‍💻 `$COMMAND` completed in $DURATION" +} + +resource "coder_script" "install_slackme" { + agent_id = var.agent_id + display_name = "install_slackme" + run_on_start = true + script = < $CODER_DIR/slackme < + +Example: slackme npm run long-build +EOF +} + +pretty_duration() { + local duration_ms=$1 + + # If the duration is less than 1 second, display in milliseconds + if [ $duration_ms -lt 1000 ]; then + echo "$${duration_ms}ms" + return + fi + + # Convert the duration to seconds + local duration_sec=$((duration_ms / 1000)) + local remaining_ms=$((duration_ms % 1000)) + + # If the duration is less than 1 minute, display in seconds (with ms) + if [ $duration_sec -lt 60 ]; then + echo "$${duration_sec}.$${remaining_ms}s" + return + fi + + # Convert the duration to minutes + local duration_min=$((duration_sec / 60)) + local remaining_sec=$((duration_sec % 60)) + + # If the duration is less than 1 hour, display in minutes and seconds + if [ $duration_min -lt 60 ]; then + echo "$${duration_min}m $${remaining_sec}.$${remaining_ms}s" + return + fi + + # Convert the duration to hours + local duration_hr=$((duration_min / 60)) + local remaining_min=$((duration_min % 60)) + + # Display in hours, minutes, and seconds + echo "$${duration_hr}hr $${remaining_min}m $${remaining_sec}.$${remaining_ms}s" +} + +if [ $# -eq 0 ]; then + usage + exit 1 +fi + +BOT_TOKEN=$(coder external-auth access-token $PROVIDER_ID) +if [ $? -ne 0 ]; then + printf "Authenticate with Slack to be notified when a command finishes:\n$BOT_TOKEN\n" + exit 1 +fi + +USER_ID=$(coder external-auth access-token $PROVIDER_ID --extra "authed_user.id") +if [ $? -ne 0 ]; then + printf "Failed to get authenticated user ID:\n$USER_ID\n" + exit 1 +fi + +START=$(date +%s%N) +# Run all arguments as a command +$@ +END=$(date +%s%N) +DURATION_MS=$${DURATION_MS:-$(( (END - START) / 1000000 ))} +PRETTY_DURATION=$(pretty_duration $DURATION_MS) + +set -e +COMMAND=$(echo $@) +SLACK_MESSAGE=$(echo "$SLACK_MESSAGE" | sed "s|\\$COMMAND|$COMMAND|g") +SLACK_MESSAGE=$(echo "$SLACK_MESSAGE" | sed "s|\\$DURATION|$PRETTY_DURATION|g") + +curl --silent -o /dev/null --header "Authorization: Bearer $BOT_TOKEN" \ + -G --data-urlencode "text=$${SLACK_MESSAGE}" \ + "$SLACK_URL/api/chat.postMessage?channel=$USER_ID&pretty=1" diff --git a/test.ts b/test.ts index 6546490..37e0805 100644 --- a/test.ts +++ b/test.ts @@ -13,6 +13,8 @@ export const runContainer = async ( "-d", "--label", "modules-test=true", + "--network", + "host", "--entrypoint", "sh", image, @@ -129,7 +131,7 @@ export const findResourceInstance = ( return resource.instances[0].attributes as any; }; -// assertRequiredVariables creates a test-case +// testRequiredVariables creates a test-case // for each variable provided and ensures that // the apply fails without it. export const testRequiredVariables = ( diff --git a/vscode-desktop/main.test.ts b/vscode-desktop/main.test.ts new file mode 100644 index 0000000..304655d --- /dev/null +++ b/vscode-desktop/main.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test"; +import { + executeScriptInContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("vscode-desktop", 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.vscode_url.value).toBe( + "vscode://coder.coder-remote/open?owner=default&workspace=default&token=$SESSION_TOKEN", + ); + }); +}); diff --git a/vscode-desktop/main.tf b/vscode-desktop/main.tf index 9edae25..715fb14 100644 --- a/vscode-desktop/main.tf +++ b/vscode-desktop/main.tf @@ -16,7 +16,7 @@ variable "agent_id" { variable "folder" { type = string - description = "The folder to opne in VS Code." + description = "The folder to open in VS Code." default = "" } @@ -44,3 +44,8 @@ resource "coder_app" "vscode" { "&token=$SESSION_TOKEN", ]) } + +output "vscode_url" { + value = coder_app.vscode.url + description = "VS Code Desktop URL." +} diff --git a/vscode-web/main.test.ts b/vscode-web/main.test.ts new file mode 100644 index 0000000..57277df --- /dev/null +++ b/vscode-web/main.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "bun:test"; +import { + executeScriptInContainer, + runTerraformApply, + runTerraformInit, +} from "../test"; + +describe("vscode-web", async () => { + await runTerraformInit(import.meta.dir); + + // replaces testRequiredVariables due to license variable + // may add a testRequiredVariablesWithLicense function later + it("missing agent_id", async () => { + try { + await runTerraformApply(import.meta.dir, { + accept_license: "true", + }); + } catch (ex) { + expect(ex.message).toContain('input variable "agent_id" is not set'); + } + }); + + it("invalid license_agreement", async () => { + try { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + } catch (ex) { + expect(ex.message).toContain( + "You must accept the VS Code license agreement by setting accept_license=true", + ); + } + }); + + it("fails without curl", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: "true", + }); + const output = await executeScriptInContainer(state, "alpine"); + expect(output.exitCode).toBe(1); + expect(output.stdout).toEqual([ + "\u001b[0;1mInstalling vscode-cli!", + "Failed to install vscode-cli:", // TODO: manually test error log + ]); + }); + + it("runs with curl", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: "true", + }); + const output = await executeScriptInContainer(state, "alpine/curl"); + expect(output.exitCode).toBe(0); + expect(output.stdout).toEqual([ + "\u001b[0;1mInstalling vscode-cli!", + "🥳 vscode-cli has been installed.", + "", + "👷 Running /tmp/vscode-cli/bin/code serve-web --port 13338 --without-connection-token --accept-server-license-terms in the background...", + "Check logs at /tmp/vscode-web.log!", + ]); + }); +});