import { expect, Page } from "@playwright/test"; import { ChildProcess, exec, spawn } from "child_process"; import { randomUUID } from "crypto"; import path from "path"; import express from "express"; import { TarWriter } from "utils/tar"; import { Agent, App, AppSharingLevel, Response, ParseComplete, PlanComplete, ApplyComplete, Resource, RichParameter, } from "./provisionerGenerated"; import { prometheusPort, agentPProfPort } from "./constants"; import { port } from "./playwright.config"; import * as ssh from "ssh2"; import { Duplex } from "stream"; import { WorkspaceBuildParameter, UpdateTemplateMeta, } from "api/typesGenerated"; import axios from "axios"; import capitalize from "lodash/capitalize"; // createWorkspace creates a workspace for a template. // It does not wait for it to be running, but it does navigate to the page. export const createWorkspace = async ( page: Page, templateName: string, richParameters: RichParameter[] = [], buildParameters: WorkspaceBuildParameter[] = [], ): Promise => { await page.goto("/templates/" + templateName + "/workspace", { waitUntil: "domcontentloaded", }); await expect(page).toHaveURL("/templates/" + templateName + "/workspace"); const name = randomName(); await page.getByLabel("name").fill(name); await fillParameters(page, richParameters, buildParameters); await page.getByTestId("form-submit").click(); await expect(page).toHaveURL("/@admin/" + name); await page.waitForSelector("*[data-testid='build-status'] >> text=Running", { state: "visible", }); return name; }; export const verifyParameters = async ( page: Page, workspaceName: string, richParameters: RichParameter[], expectedBuildParameters: WorkspaceBuildParameter[], ) => { await page.goto("/@admin/" + workspaceName + "/settings/parameters", { waitUntil: "domcontentloaded", }); await expect(page).toHaveURL( "/@admin/" + workspaceName + "/settings/parameters", ); for (const buildParameter of expectedBuildParameters) { const richParameter = richParameters.find( (richParam) => richParam.name === buildParameter.name, ); if (!richParameter) { throw new Error( "build parameter is expected to be present in rich parameter schema", ); } const parameterLabel = await page.waitForSelector( "[data-testid='parameter-field-" + richParameter.name + "']", { state: "visible" }, ); const muiDisabled = richParameter.mutable ? "" : ".Mui-disabled"; if (richParameter.type === "bool") { const parameterField = await parameterLabel.waitForSelector( "[data-testid='parameter-field-bool'] .MuiRadio-root.Mui-checked" + muiDisabled + " input", ); const value = await parameterField.inputValue(); expect(value).toEqual(buildParameter.value); } else if (richParameter.options.length > 0) { const parameterField = await parameterLabel.waitForSelector( "[data-testid='parameter-field-options'] .MuiRadio-root.Mui-checked" + muiDisabled + " input", ); const value = await parameterField.inputValue(); expect(value).toEqual(buildParameter.value); } else if (richParameter.type === "list(string)") { throw new Error("not implemented yet"); // FIXME } else { // text or number const parameterField = await parameterLabel.waitForSelector( "[data-testid='parameter-field-text'] input" + muiDisabled, ); const value = await parameterField.inputValue(); expect(value).toEqual(buildParameter.value); } } }; // createTemplate navigates to the /templates/new page and uploads a template // with the resources provided in the responses argument. export const createTemplate = async ( page: Page, responses?: EchoProvisionerResponses, ): Promise => { // Required to have templates submit their provisioner type as echo! await page.addInitScript({ content: "window.playwright = true", }); await page.goto("/templates/new", { waitUntil: "domcontentloaded" }); await expect(page).toHaveURL("/templates/new"); await page.getByTestId("file-upload").setInputFiles({ buffer: await createTemplateVersionTar(responses), mimeType: "application/x-tar", name: "template.tar", }); const name = randomName(); await page.getByLabel("Name *").fill(name); await page.getByTestId("form-submit").click(); await expect(page).toHaveURL(`/templates/${name}/files`, { timeout: 30000, }); return name; }; // sshIntoWorkspace spawns a Coder SSH process and a client connected to it. export const sshIntoWorkspace = async ( page: Page, workspace: string, binaryPath = "go", binaryArgs: string[] = [], ): Promise => { if (binaryPath === "go") { binaryArgs = ["run", coderMainPath()]; } const sessionToken = await findSessionToken(page); return new Promise((resolve, reject) => { const cp = spawn(binaryPath, [...binaryArgs, "ssh", "--stdio", workspace], { env: { ...process.env, CODER_SESSION_TOKEN: sessionToken, CODER_URL: "http://localhost:3000", }, }); cp.on("error", (err) => reject(err)); const proxyStream = new Duplex({ read: (size) => { return cp.stdout.read(Math.min(size, cp.stdout.readableLength)); }, write: cp.stdin.write.bind(cp.stdin), }); // eslint-disable-next-line no-console -- Helpful for debugging cp.stderr.on("data", (data) => console.log(data.toString())); cp.stdout.on("readable", (...args) => { proxyStream.emit("readable", ...args); if (cp.stdout.readableLength > 0) { proxyStream.emit("data", cp.stdout.read()); } }); const client = new ssh.Client(); client.connect({ sock: proxyStream, username: "coder", }); client.on("error", (err) => reject(err)); client.on("ready", () => { resolve(client); }); }); }; export const stopWorkspace = async (page: Page, workspaceName: string) => { await page.goto("/@admin/" + workspaceName, { waitUntil: "domcontentloaded", }); await expect(page).toHaveURL("/@admin/" + workspaceName); await page.getByTestId("workspace-stop-button").click(); await page.waitForSelector("*[data-testid='build-status'] >> text=Stopped", { state: "visible", }); }; export const buildWorkspaceWithParameters = async ( page: Page, workspaceName: string, richParameters: RichParameter[] = [], buildParameters: WorkspaceBuildParameter[] = [], confirm: boolean = false, ) => { await page.goto("/@admin/" + workspaceName, { waitUntil: "domcontentloaded", }); await expect(page).toHaveURL("/@admin/" + workspaceName); await page.getByTestId("build-parameters-button").click(); await fillParameters(page, richParameters, buildParameters); await page.getByTestId("build-parameters-submit").click(); if (confirm) { await page.getByTestId("confirm-button").click(); } await page.waitForSelector("*[data-testid='build-status'] >> text=Running", { state: "visible", }); }; // startAgent runs the coder agent with the provided token. // It awaits the agent to be ready before returning. export const startAgent = async ( page: Page, token: string, ): Promise => { return startAgentWithCommand(page, token, "go", "run", coderMainPath()); }; // downloadCoderVersion downloads the version provided into a temporary dir and // caches it so subsequent calls are fast. export const downloadCoderVersion = async ( version: string, ): Promise => { if (version.startsWith("v")) { version = version.slice(1); } const binaryName = "coder-e2e-" + version; const tempDir = "/tmp/coder-e2e-cache"; // The install script adds `./bin` automatically to the path :shrug: const binaryPath = path.join(tempDir, "bin", binaryName); const exists = await new Promise((resolve) => { const cp = spawn(binaryPath, ["version"]); cp.on("close", (code) => { resolve(code === 0); }); cp.on("error", () => resolve(false)); }); if (exists) { return binaryPath; } // Run our official install script to install the binary await new Promise((resolve, reject) => { const cp = spawn( path.join(__dirname, "../../install.sh"), [ "--version", version, "--method", "standalone", "--prefix", tempDir, "--binary-name", binaryName, ], { env: { ...process.env, XDG_CACHE_HOME: "/tmp/coder-e2e-cache", TRACE: "1", // tells install.sh to `set -x`, helpful if something goes wrong }, }, ); // eslint-disable-next-line no-console -- Needed for debugging cp.stderr.on("data", (data) => console.error(data.toString())); // eslint-disable-next-line no-console -- Needed for debugging cp.stdout.on("data", (data) => console.log(data.toString())); cp.on("close", (code) => { if (code === 0) { resolve(); } else { reject(new Error("install.sh failed with code " + code)); } }); }); return binaryPath; }; export const startAgentWithCommand = async ( page: Page, token: string, command: string, ...args: string[] ): Promise => { const cp = spawn(command, [...args, "agent", "--no-reap"], { env: { ...process.env, CODER_AGENT_URL: "http://localhost:" + port, CODER_AGENT_TOKEN: token, CODER_AGENT_PPROF_ADDRESS: "127.0.0.1:" + agentPProfPort, CODER_AGENT_PROMETHEUS_ADDRESS: "127.0.0.1:" + prometheusPort, }, }); cp.stdout.on("data", (data: Buffer) => { // eslint-disable-next-line no-console -- Log agent activity console.log( `[agent] [stdout] [onData] ${data.toString().replace(/\n$/g, "")}`, ); }); cp.stderr.on("data", (data: Buffer) => { // eslint-disable-next-line no-console -- Log agent activity console.log( `[agent] [stderr] [onData] ${data.toString().replace(/\n$/g, "")}`, ); }); await page.getByTestId("agent-status-ready").waitFor({ state: "visible" }); return cp; }; export const stopAgent = async (cp: ChildProcess, goRun: boolean = true) => { // When the web server is started with `go run`, it spawns a child process with coder server. // `pkill -P` terminates child processes belonging the same group as `go run`. // The command `kill` is used to terminate a web server started as a standalone binary. exec(goRun ? `pkill -P ${cp.pid}` : `kill ${cp.pid}`, (error) => { if (error) { throw new Error(`exec error: ${JSON.stringify(error)}`); } }); await waitUntilUrlIsNotResponding("http://localhost:" + prometheusPort); }; const waitUntilUrlIsNotResponding = async (url: string) => { const maxRetries = 30; const retryIntervalMs = 1000; let retries = 0; while (retries < maxRetries) { try { await axios.get(url); } catch (error) { return; } retries++; await new Promise((resolve) => setTimeout(resolve, retryIntervalMs)); } throw new Error( `URL ${url} is still responding after ${maxRetries * retryIntervalMs}ms`, ); }; const coderMainPath = (): string => { return path.join( __dirname, "..", "..", "enterprise", "cmd", "coder", "main.go", ); }; // Allows users to more easily define properties they want for agents and resources! type RecursivePartial = { [P in keyof T]?: T[P] extends (infer U)[] ? RecursivePartial[] : T[P] extends object | undefined ? RecursivePartial : T[P]; }; interface EchoProvisionerResponses { // parse is for observing any Terraform variables parse?: RecursivePartial[]; // plan occurs when the template is imported plan?: RecursivePartial[]; // apply occurs when the workspace is built apply?: RecursivePartial[]; } // createTemplateVersionTar consumes a series of echo provisioner protobufs and // converts it into an uploadable tar file. const createTemplateVersionTar = async ( responses?: EchoProvisionerResponses, ): Promise => { if (!responses) { responses = {}; } if (!responses.parse) { responses.parse = [ { parse: {}, }, ]; } if (!responses.apply) { responses.apply = [ { apply: {}, }, ]; } if (!responses.plan) { responses.plan = responses.apply.map((response) => { if (response.log) { return response; } return { plan: { error: response.apply?.error ?? "", resources: response.apply?.resources ?? [], parameters: response.apply?.parameters ?? [], externalAuthProviders: response.apply?.externalAuthProviders ?? [], }, }; }); } const tar = new TarWriter(); responses.parse.forEach((response, index) => { response.parse = { templateVariables: [], error: "", readme: new Uint8Array(), ...response.parse, } as ParseComplete; tar.addFile( `${index}.parse.protobuf`, Response.encode(response as Response).finish(), ); }); const fillResource = (resource: RecursivePartial) => { if (resource.agents) { resource.agents = resource.agents?.map( (agent: RecursivePartial) => { if (agent.apps) { agent.apps = agent.apps?.map((app: RecursivePartial) => { return { command: "", displayName: "example", external: false, icon: "", sharingLevel: AppSharingLevel.PUBLIC, slug: "example", subdomain: false, url: "", ...app, } as App; }); } const agentResource = { apps: [], architecture: "amd64", connectionTimeoutSeconds: 300, directory: "", env: {}, id: randomUUID(), metadata: [], extraEnvs: [], scripts: [], motdFile: "", name: "dev", operatingSystem: "linux", shutdownScript: "", shutdownScriptTimeoutSeconds: 0, startupScript: "", startupScriptBehavior: "", startupScriptTimeoutSeconds: 300, troubleshootingUrl: "", token: randomUUID(), ...agent, } as Agent; try { Agent.encode(agentResource); } catch (e) { let m = `Error: agentResource encode failed, missing defaults?`; if (e instanceof Error) { if (!e.stack?.includes(e.message)) { m += `\n${e.name}: ${e.message}`; } m += `\n${e.stack}`; } else { m += `\n${e}`; } throw new Error(m); } return agentResource; }, ); } return { agents: [], dailyCost: 0, hide: false, icon: "", instanceType: "", metadata: [], name: "dev", type: "echo", ...resource, } as Resource; }; responses.apply.forEach((response, index) => { response.apply = { error: "", state: new Uint8Array(), resources: [], parameters: [], externalAuthProviders: [], ...response.apply, } as ApplyComplete; response.apply.resources = response.apply.resources?.map(fillResource); tar.addFile( `${index}.apply.protobuf`, Response.encode(response as Response).finish(), ); }); responses.plan.forEach((response, index) => { response.plan = { error: "", resources: [], parameters: [], externalAuthProviders: [], ...response.plan, } as PlanComplete; response.plan.resources = response.plan.resources?.map(fillResource); tar.addFile( `${index}.plan.protobuf`, Response.encode(response as Response).finish(), ); }); const tarFile = await tar.write(); return Buffer.from( tarFile instanceof Blob ? await tarFile.arrayBuffer() : tarFile, ); }; const randomName = () => { return randomUUID().slice(0, 8); }; // Awaiter is a helper that allows you to wait for a callback to be called. // It is useful for waiting for events to occur. export class Awaiter { private promise: Promise; private callback?: () => void; constructor() { this.promise = new Promise((r) => (this.callback = r)); } public done(): void { if (this.callback) { this.callback(); } else { this.promise = Promise.resolve(); } } public wait(): Promise { return this.promise; } } export const createServer = async ( port: number, ): Promise> => { const e = express(); // We need to specify the local IP address as the web server // tends to fail with IPv6 related error: // listen EADDRINUSE: address already in use :::50516 await new Promise((r) => e.listen(port, "0.0.0.0", r)); return e; }; const findSessionToken = async (page: Page): Promise => { const cookies = await page.context().cookies(); const sessionCookie = cookies.find((c) => c.name === "coder_session_token"); if (!sessionCookie) { throw new Error("session token not found"); } return sessionCookie.value; }; export const echoResponsesWithParameters = ( richParameters: RichParameter[], ): EchoProvisionerResponses => { return { parse: [ { parse: {}, }, ], plan: [ { plan: { parameters: richParameters, }, }, ], apply: [ { apply: { resources: [ { name: "example", }, ], }, }, ], }; }; export const fillParameters = async ( page: Page, richParameters: RichParameter[] = [], buildParameters: WorkspaceBuildParameter[] = [], ) => { for (const buildParameter of buildParameters) { const richParameter = richParameters.find( (richParam) => richParam.name === buildParameter.name, ); if (!richParameter) { throw new Error( "build parameter is expected to be present in rich parameter schema", ); } const parameterLabel = await page.waitForSelector( "[data-testid='parameter-field-" + richParameter.name + "']", { state: "visible" }, ); if (richParameter.type === "bool") { const parameterField = await parameterLabel.waitForSelector( "[data-testid='parameter-field-bool'] .MuiRadio-root input[value='" + buildParameter.value + "']", ); await parameterField.click(); } else if (richParameter.options.length > 0) { const parameterField = await parameterLabel.waitForSelector( "[data-testid='parameter-field-options'] .MuiRadio-root input[value='" + buildParameter.value + "']", ); await parameterField.click(); } else if (richParameter.type === "list(string)") { throw new Error("not implemented yet"); // FIXME } else { // text or number const parameterField = await parameterLabel.waitForSelector( "[data-testid='parameter-field-text'] input", ); await parameterField.fill(buildParameter.value); } } }; export const updateTemplate = async ( page: Page, templateName: string, responses?: EchoProvisionerResponses, ) => { const tarball = await createTemplateVersionTar(responses); const sessionToken = await findSessionToken(page); const child = spawn( "go", [ "run", coderMainPath(), "templates", "push", "--test.provisioner", "echo", "-y", "-d", "-", templateName, ], { env: { ...process.env, CODER_SESSION_TOKEN: sessionToken, CODER_URL: "http://localhost:3000", }, }, ); const uploaded = new Awaiter(); child.on("exit", (code) => { if (code === 0) { uploaded.done(); return; } throw new Error(`coder templates push failed with code ${code}`); }); child.stdin.write(tarball); child.stdin.end(); await uploaded.wait(); }; export const updateTemplateSettings = async ( page: Page, templateName: string, templateSettingValues: Pick< UpdateTemplateMeta, "name" | "display_name" | "description" >, ) => { await page.goto(`/templates/${templateName}/settings`, { waitUntil: "domcontentloaded", }); await expect(page).toHaveURL(`/templates/${templateName}/settings`); for (const [key, value] of Object.entries(templateSettingValues)) { // Skip max_port_share_level for now since the frontend is not yet able to handle it if (key === "max_port_share_level") { continue; } const labelText = capitalize(key).replace("_", " "); await page.getByLabel(labelText, { exact: true }).fill(value); } await page.getByTestId("form-submit").click(); const name = templateSettingValues.name ?? templateName; await expect(page).toHaveURL(`/templates/${name}`); }; export const updateWorkspace = async ( page: Page, workspaceName: string, richParameters: RichParameter[] = [], buildParameters: WorkspaceBuildParameter[] = [], ) => { await page.goto("/@admin/" + workspaceName, { waitUntil: "domcontentloaded", }); await expect(page).toHaveURL("/@admin/" + workspaceName); await page.getByTestId("workspace-update-button").click(); await page.getByTestId("confirm-button").click(); await fillParameters(page, richParameters, buildParameters); await page.getByTestId("form-submit").click(); await page.waitForSelector("*[data-testid='build-status'] >> text=Running", { state: "visible", }); }; export const updateWorkspaceParameters = async ( page: Page, workspaceName: string, richParameters: RichParameter[] = [], buildParameters: WorkspaceBuildParameter[] = [], ) => { await page.goto("/@admin/" + workspaceName + "/settings/parameters", { waitUntil: "domcontentloaded", }); await expect(page).toHaveURL( "/@admin/" + workspaceName + "/settings/parameters", ); await fillParameters(page, richParameters, buildParameters); await page.getByTestId("form-submit").click(); await page.waitForSelector("*[data-testid='build-status'] >> text=Running", { state: "visible", }); };