fix: update path for TS test helpers

This commit is contained in:
Michael Smith 2025-04-16 16:59:45 +00:00
parent 4bbeb50dd5
commit 25b7225a5b
32 changed files with 349 additions and 28 deletions

View File

@ -1,2 +1,3 @@
# hub
Publish Coder modules and templates for other developers to use.

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "modules",
"scripts": {
"test": "bun test",
"fmt": "bun x prettier -w **/*.sh .sample/run.sh new.sh **/*.ts **/*.md *.md && terraform fmt **/*.tf .sample/main.tf",
"fmt:ci": "bun x prettier --check **/*.sh .sample/run.sh new.sh **/*.ts **/*.md *.md && terraform fmt -check **/*.tf .sample/main.tf",
"lint": "bun run lint.ts && ./terraform_validate.sh",
"update-version": "./update-version.sh"
},
"devDependencies": {
"@types/bun": "^1.2.9",
"bun-types": "^1.1.23",
"gray-matter": "^4.0.3",
"marked": "^12.0.2",
"prettier": "^3.3.3",
"prettier-plugin-sh": "^0.13.1",
"prettier-plugin-terraform-formatter": "^1.2.1"
},
"peerDependencies": {
"typescript": "^5.5.4"
},
"prettier": {
"plugins": [
"prettier-plugin-sh",
"prettier-plugin-terraform-formatter"
]
}
}

View File

@ -3,7 +3,7 @@ import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "../test";
} from "~test";
describe("aws-region", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -3,7 +3,7 @@ import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "../test";
} from "~test";
describe("azure-region", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -3,7 +3,7 @@ import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "../test";
} from "~test";
describe("code-server", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -1,5 +1,5 @@
import { describe } from "bun:test";
import { runTerraformInit, testRequiredVariables } from "../test";
import { runTerraformInit, testRequiredVariables } from "~test";
describe("coder-login", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -3,7 +3,7 @@ import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "../test";
} from "~test";
describe("cursor", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -3,7 +3,7 @@ import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "../test";
} from "~test";
describe("dotfiles", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -3,7 +3,7 @@ import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "../test";
} from "~test";
describe("fly-region", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -3,7 +3,7 @@ import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "../test";
} from "~test";
describe("gcp-region", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -4,7 +4,7 @@ import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "../test";
} from "~test";
describe("git-clone", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -3,7 +3,7 @@ import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "../test";
} from "~test";
describe("git-config", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -9,7 +9,7 @@ import {
runTerraformInit,
testRequiredVariables,
writeCoder,
} from "../test";
} from "~test";
describe("github-upload-public-key", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -3,7 +3,7 @@ import {
runTerraformInit,
testRequiredVariables,
runTerraformApply,
} from "../test";
} from "~test";
describe("jetbrains-gateway", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -4,7 +4,7 @@ import {
runTerraformInit,
runTerraformApply,
testRequiredVariables,
} from "../test";
} from "~test";
describe("jfrog-oauth", async () => {
type TestVariables = {

View File

@ -6,7 +6,7 @@ import {
runTerraformInit,
runTerraformApply,
testRequiredVariables,
} from "../test";
} from "~test";
describe("jfrog-token", async () => {
type TestVariables = {

View File

@ -8,7 +8,7 @@ import {
runTerraformInit,
testRequiredVariables,
type TerraformState,
} from "../test";
} from "~test";
// executes the coder script after installing pip
const executeScriptInContainerWithPip = async (

View File

@ -3,7 +3,7 @@ import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "../test";
} from "~test";
const allowedDesktopEnvs = ["xfce", "kde", "gnome", "lxde", "lxqt"] as const;
type AllowedDesktopEnv = (typeof allowedDesktopEnvs)[number];

View File

@ -4,7 +4,7 @@ import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "../test";
} from "~test";
describe("personalize", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -9,7 +9,7 @@ import {
runTerraformInit,
testRequiredVariables,
writeCoder,
} from "../test";
} from "~test";
describe("slackme", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -1,5 +1,5 @@
import { describe } from "bun:test";
import { runTerraformInit, testRequiredVariables } from "../test";
import { runTerraformInit, testRequiredVariables } from "~test";
describe("vault-github", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -1,5 +1,5 @@
import { describe } from "bun:test";
import { runTerraformInit, testRequiredVariables } from "../test";
import { runTerraformInit, testRequiredVariables } from "~test";
describe("vault-jwt", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -1,5 +1,5 @@
import { describe } from "bun:test";
import { runTerraformInit, testRequiredVariables } from "../test";
import { runTerraformInit, testRequiredVariables } from "~test";
describe("vault-token", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -4,7 +4,7 @@ import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "../test";
} from "~test";
describe("vscode-desktop", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { runTerraformApply, runTerraformInit } from "../test";
import { runTerraformApply, runTerraformInit } from "~test";
describe("vscode-web", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -4,7 +4,7 @@ import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "../test";
} from "~test";
type TestVariables = Readonly<{
agent_id: string;

View File

@ -20,7 +20,7 @@ module "nodejs" {
}
```
### Install multiple versions
## Install multiple versions
This installs multiple versions of Node.js:

View File

@ -1,5 +1,5 @@
import { describe } from "bun:test";
import { runTerraformInit, testRequiredVariables } from "../test";
import { runTerraformInit, testRequiredVariables } from "~test";
describe("nodejs", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -3,7 +3,7 @@ import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "../test";
} from "~test";
describe("exoscale-instance-type", async () => {
await runTerraformInit(import.meta.dir);

View File

@ -3,7 +3,7 @@ import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "../test";
} from "~test";
describe("exoscale-zone", async () => {
await runTerraformInit(import.meta.dir);

271
test/test.ts Normal file
View File

@ -0,0 +1,271 @@
import { readableStreamToText, spawn } from "bun";
import { expect, it } from "bun:test";
import { readFile, unlink } from "node:fs/promises";
export const runContainer = async (
image: string,
init = "sleep infinity",
): Promise<string> => {
const proc = spawn([
"docker",
"run",
"--rm",
"-d",
"--label",
"modules-test=true",
"--network",
"host",
"--entrypoint",
"sh",
image,
"-c",
init,
]);
const containerID = await readableStreamToText(proc.stdout);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(containerID);
}
return containerID.trim();
};
/**
* Finds the only "coder_script" resource in the given state and runs it in a
* container.
*/
export const executeScriptInContainer = async (
state: TerraformState,
image: string,
shell = "sh",
): Promise<{
exitCode: number;
stdout: string[];
stderr: string[];
}> => {
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer(image);
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,
};
};
export const execContainer = async (
id: string,
cmd: string[],
): Promise<{
exitCode: number;
stderr: string;
stdout: string;
}> => {
const proc = spawn(["docker", "exec", id, ...cmd], {
stderr: "pipe",
stdout: "pipe",
});
const [stderr, stdout] = await Promise.all([
readableStreamToText(proc.stderr),
readableStreamToText(proc.stdout),
]);
const exitCode = await proc.exited;
return {
exitCode,
stderr,
stdout,
};
};
type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
type TerraformStateResource = {
type: string;
name: string;
provider: string;
instances: [
{
attributes: Record<string, JsonValue>;
},
];
};
type TerraformOutput = {
type: string;
value: JsonValue;
};
export interface TerraformState {
outputs: Record<string, TerraformOutput>;
resources: [TerraformStateResource, ...TerraformStateResource[]];
}
type TerraformVariables = Record<string, JsonValue>;
export interface CoderScriptAttributes {
script: string;
agent_id: string;
url: string;
}
export type ResourceInstance<T extends string = string> =
T extends "coder_script" ? CoderScriptAttributes : Record<string, string>;
/**
* finds the first instance of the given resource type in the given state. If
* name is specified, it will only find the instance with the given name.
*/
export const findResourceInstance = <T extends string>(
state: TerraformState,
type: T,
name?: string,
): ResourceInstance<T> => {
const resource = state.resources.find(
(resource) =>
resource.type === type && (name ? resource.name === name : true),
);
if (!resource) {
throw new Error(`Resource ${type} not found`);
}
if (resource.instances.length !== 1) {
throw new Error(
`Resource ${type} has ${resource.instances.length} instances`,
);
}
return resource.instances[0].attributes as ResourceInstance<T>;
};
/**
* Creates a test-case for each variable provided and ensures that the apply
* fails without it.
*/
export const testRequiredVariables = <TVars extends TerraformVariables>(
dir: string,
vars: Readonly<TVars>,
) => {
// Ensures that all required variables are provided.
it("required variables", async () => {
await runTerraformApply(dir, vars);
});
const varNames = Object.keys(vars);
for (const varName of varNames) {
// Ensures that every variable provided is required!
it(`missing variable: ${varName}`, async () => {
const localVars: TerraformVariables = {};
for (const otherVarName of varNames) {
if (otherVarName !== varName) {
localVars[otherVarName] = vars[otherVarName];
}
}
try {
await runTerraformApply(dir, localVars);
} catch (ex) {
if (!(ex instanceof Error)) {
throw new Error("Unknown error generated");
}
expect(ex.message).toContain(
`input variable \"${varName}\" is not set`,
);
return;
}
throw new Error(`${varName} is not a required variable!`);
});
}
};
/**
* Runs terraform apply in the given directory with the given variables. It is
* fine to run in parallel with other instances of this function, as it uses a
* random state file.
*/
export const runTerraformApply = async <TVars extends TerraformVariables>(
dir: string,
vars: Readonly<TVars>,
customEnv?: Record<string, string>,
): Promise<TerraformState> => {
const stateFile = `${dir}/${crypto.randomUUID()}.tfstate`;
const childEnv: Record<string, string | undefined> = {
...process.env,
...(customEnv ?? {}),
};
for (const [key, value] of Object.entries(vars) as [string, JsonValue][]) {
if (value !== null) {
childEnv[`TF_VAR_${key}`] = String(value);
}
}
const proc = spawn(
[
"terraform",
"apply",
"-compact-warnings",
"-input=false",
"-auto-approve",
"-state",
"-no-color",
stateFile,
],
{
cwd: dir,
env: childEnv,
stderr: "pipe",
stdout: "pipe",
},
);
const text = await readableStreamToText(proc.stderr);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(text);
}
const content = await readFile(stateFile, "utf8");
await unlink(stateFile);
return JSON.parse(content);
};
/**
* Runs terraform init in the given directory.
*/
export const runTerraformInit = async (dir: string) => {
const proc = spawn(["terraform", "init"], {
cwd: dir,
});
const text = await readableStreamToText(proc.stdout);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(text);
}
};
export const createJSONResponse = (obj: object, statusCode = 200): Response => {
return new Response(JSON.stringify(obj), {
headers: {
"Content-Type": "application/json",
},
status: statusCode,
});
};
export 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);
};

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
// If we were just compiling for the tests, we could safely target ESNext at
// all times, but just because we've been starting to add more runtime logic
// files to some of the modules, erring on the side of caution by having a
// older compilation target
"target": "ES2024",
"module": "esnext",
"strict": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"types": ["bun-types"],
"paths": {
// Not the biggest fan of relative paths in TypeScript projects, but it
// does make things easier for non-Coder contributors to get tests
// imported and set up
"~test": ["./test/test.ts"]
}
}
}