import { Logger, logger } from "@coder/logger" import * as cp from "child_process" import { promises as fs } from "fs" import * as path from "path" import { Page } from "playwright" import { onLine } from "../../../src/node/util" import { PASSWORD, workspaceDir } from "../../utils/constants" import { tmpdir } from "../../utils/helpers" interface CodeServerProcess { process: cp.ChildProcess address: string } class CancelToken { private _canceled = false public canceled(): boolean { return this._canceled } public cancel(): void { this._canceled = true } } /** * Class for spawning and managing code-server. */ export class CodeServer { private process: Promise | undefined public readonly logger: Logger private closed = false constructor(name: string) { this.logger = logger.named(name) } /** * The address at which code-server can be accessed. Spawns code-server if it * has not yet been spawned. */ async address(): Promise { if (!this.process) { this.process = this.spawn() } const { address } = await this.process return address } /** * Create a random workspace and seed it with settings. */ private async createWorkspace(): Promise { const dir = await tmpdir(workspaceDir) await fs.mkdir(path.join(dir, ".vscode")) await fs.writeFile( path.join(dir, ".vscode/settings.json"), JSON.stringify({ "workbench.startupEditor": "none", }), "utf8", ) return dir } /** * Spawn a new code-server process with its own workspace and data * directories. */ private async spawn(): Promise { // This will be used both as the workspace and data directory to ensure // instances don't bleed into each other. const dir = await this.createWorkspace() return new Promise((resolve, reject) => { this.logger.debug("spawning") const proc = cp.spawn( "node", [ process.env.CODE_SERVER_TEST_ENTRY || ".", // Using port zero will spawn on a random port. "--bind-addr", "127.0.0.1:0", // Setting the XDG variables would be easier and more thorough but the // modules we import ignores those variables for non-Linux operating // systems so use these flags instead. "--config", path.join(dir, "config.yaml"), "--user-data-dir", dir, // The last argument is the workspace to open. dir, ], { cwd: path.join(__dirname, "../../.."), env: { ...process.env, PASSWORD, }, }, ) proc.on("error", (error) => { this.logger.error(error.message) reject(error) }) proc.on("close", () => { const error = new Error("closed unexpectedly") if (!this.closed) { this.logger.error(error.message) } reject(error) }) let resolved = false proc.stdout.setEncoding("utf8") onLine(proc, (line) => { // Log the line without the timestamp. this.logger.trace(line.replace(/\[.+\]/, "")) if (resolved) { return } const match = line.trim().match(/HTTP server listening on (https?:\/\/[.:\d]+)$/) if (match) { // Cookies don't seem to work on IP address so swap to localhost. // TODO: Investigate whether this is a bug with code-server. const address = match[1].replace("127.0.0.1", "localhost") this.logger.debug(`spawned on ${address}`) resolved = true resolve({ process: proc, address }) } }) }) } /** * Close the code-server process. */ async close(): Promise { logger.debug("closing") if (this.process) { const proc = (await this.process).process this.closed = true // To prevent the close handler from erroring. proc.kill() } } } /** * This is a "Page Object Model" (https://playwright.dev/docs/pom/) meant to * wrap over a page and represent actions on that page in a more readable way. * This targets a specific code-server instance which must be passed in. * Navigation and setup performed by this model will cause the code-server * process to spawn if it hasn't yet. */ export class CodeServerPage { private readonly editorSelector = "div.monaco-workbench" constructor(private readonly codeServer: CodeServer, public readonly page: Page) {} address() { return this.codeServer.address() } /** * Navigate to code-server. */ async navigate() { const address = await this.codeServer.address() await this.page.goto(address, { waitUntil: "networkidle" }) } /** * Checks if the editor is visible * and that we are connected to the host * * Reload until both checks pass */ async reloadUntilEditorIsReady() { this.codeServer.logger.debug("Waiting for editor to be ready...") const editorIsVisible = await this.isEditorVisible() const editorIsConnected = await this.isConnected() let reloadCount = 0 // Occassionally code-server timeouts in Firefox // we're not sure why // but usually a reload or two fixes it // TODO@jsjoeio @oxy look into Firefox reconnection/timeout issues while (!editorIsVisible && !editorIsConnected) { // When a reload happens, we want to wait for all resources to be // loaded completely. Hence why we use that instead of DOMContentLoaded // Read more: https://thisthat.dev/dom-content-loaded-vs-load/ await this.page.waitForLoadState("load") // Give it an extra second just in case it's feeling extra slow await this.page.waitForTimeout(1000) reloadCount += 1 if ((await this.isEditorVisible()) && (await this.isConnected())) { this.codeServer.logger.debug(`editor became ready after ${reloadCount} reloads`) break } await this.page.reload() } this.codeServer.logger.debug("Editor is ready!") } /** * Checks if the editor is visible */ async isEditorVisible() { this.codeServer.logger.debug("Waiting for editor to be visible...") // Make sure the editor actually loaded await this.page.waitForSelector(this.editorSelector) const visible = await this.page.isVisible(this.editorSelector) this.codeServer.logger.debug(`Editor is ${visible ? "not visible" : "visible"}!`) return visible } /** * Checks if the editor is visible */ async isConnected() { this.codeServer.logger.debug("Waiting for network idle...") await this.page.waitForLoadState("networkidle") const host = new URL(await this.codeServer.address()).host // NOTE: This seems to be pretty brittle between version changes. const hostSelector = `[aria-label="remote ${host}"]` this.codeServer.logger.debug(`Waiting selector: ${hostSelector}`) await this.page.waitForSelector(hostSelector) return await this.page.isVisible(hostSelector) } /** * Focuses Integrated Terminal * by using "Terminal: Focus Terminal" * from the Command Palette * * This should focus the terminal no matter * if it already has focus and/or is or isn't * visible already. */ async focusTerminal() { await this.executeCommandViaMenus("Terminal: Focus Terminal") // Wait for terminal textarea to show up await this.page.waitForSelector("textarea.xterm-helper-textarea") } /** * Navigate to the command palette via menus then execute a command by typing * it then clicking the match from the results. */ async executeCommandViaMenus(command: string) { await this.navigateMenus(["View", "Command Palette"]) await this.page.keyboard.type(command) await this.page.hover(`text=${command}`) await this.page.click(`text=${command}`) } /** * Navigate through the specified set of menus. If it fails it will keep * trying. */ async navigateMenus(menus: string[]) { const navigate = async (cancelToken: CancelToken) => { const steps: Array<() => Promise> = [() => this.page.waitForSelector(`${menuSelector}:focus-within`)] for (const menu of menus) { // Normally these will wait for the item to be visible and then execute // the action. The problem is that if the menu closes these will still // be waiting and continue to execute once the menu is visible again, // potentially conflicting with the new set of navigations (for example // if the old promise clicks logout before the new one can). By // splitting them into two steps each we can cancel before running the // action. steps.push(() => this.page.hover(`text=${menu}`, { trial: true })) steps.push(() => this.page.hover(`text=${menu}`, { force: true })) steps.push(() => this.page.click(`text=${menu}`, { trial: true })) steps.push(() => this.page.click(`text=${menu}`, { force: true })) } for (const step of steps) { await step() if (cancelToken.canceled()) { this.codeServer.logger.debug("menu navigation canceled") return false } } return true } const menuSelector = '[aria-label="Application Menu"]' const open = async () => { await this.page.click(menuSelector) await this.page.waitForSelector(`${menuSelector}:not(:focus-within)`) return false } // TODO: Starting in 1.57 something closes the menu after opening it if we // open it too soon. To counter that we'll watch for when the menu loses // focus and when/if it does we'll try again. // I tried using the classic menu but it doesn't show up at all for some // reason. I also tried toggle but the menu disappears after toggling. let retryCount = 0 let cancelToken = new CancelToken() while (!(await Promise.race([open(), navigate(cancelToken)]))) { this.codeServer.logger.debug("menu was closed, retrying") ++retryCount cancelToken.cancel() cancelToken = new CancelToken() } this.codeServer.logger.debug(`menu navigation retries: ${retryCount}`) } /** * Navigates to code-server then reloads until the editor is ready. * * It is recommended to run setup before using this model in any tests. */ async setup(authenticated: boolean) { await this.navigate() // If we aren't authenticated we'll see a login page so we can't wait until // the editor is ready. if (authenticated) { await this.reloadUntilEditorIsReady() } } }