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!",
+ ]);
+ });
+});