Archived
1
0
This repository has been archived on 2024-09-09. You can view files and clone it, but cannot push or open issues or pull requests.
code-server/test/e2e/models/CodeServer.ts
Asher 9087e0c091
fix: invoking code-server in integrated terminal (#5360)
* Include bin scripts for all platforms

These will get symlinked as part of the postinstall.  These scripts
provide everything ours does inside the integrated terminal plus more.

* Improve OS detection

Specifically for Windows although we do not yet support Windows.

Also standardize the duplicate arch functions since they had drifted
from each other bit.

* Remove duplicate asar symlink

Since standalone releases run the postinstall they will get the asar
symlink there.  That means the symlink will not exist for the npm
package and we will not need to ignore it.

The symlink portion is split out so it can be re-used for other
symlinks (for example linking bin scripts).

* Add symlinks to bin scripts

* Add test for opening a file from the terminal

* Add global Playwright timeout

Otherwise it will exceed the Actions timeout and get rudely killed
without any output.

* Make sed work on macOS

* Fix Node path in bin scripts

* Disable shellcheck expansion error

* Make scripts executable

* Remove .bak files created by sed

* Include Code build script in cache hash

Otherwise if we change the script it will not rebuild Code.

* Make sure the terminal opens

The selector was timing out even though it matched more than one element
but matching on the focused one appears to work.

In addition add a loop so it can keep trying to open the terminal
if something goes wrong with the focus.
2022-08-04 16:03:28 +00:00

495 lines
15 KiB
TypeScript

import { field, 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 * as util from "util"
import { logError, plural } from "../../../src/common/util"
import { onLine } from "../../../src/node/util"
import { PASSWORD, workspaceDir } from "../../utils/constants"
import { idleTimer, tmpdir } from "../../utils/helpers"
interface CodeServerProcess {
process: cp.ChildProcess
address: string
}
class Context {
private _canceled = false
private _done = false
public canceled(): boolean {
return this._canceled
}
public finished(): boolean {
return this._done
}
public cancel(): void {
this._canceled = true
}
public finish(): void {
this._done = true
}
}
/**
* Class for spawning and managing code-server.
*/
export class CodeServer {
private process: Promise<CodeServerProcess> | undefined
public readonly logger: Logger
private closed = false
constructor(
name: string,
private readonly args: string[],
private readonly env: NodeJS.ProcessEnv,
private _workspaceDir: Promise<string> | string | undefined,
private readonly entry = process.env.CODE_SERVER_TEST_ENTRY || ".",
) {
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<string> {
if (!this.process) {
this.process = this.spawn()
}
const { address } = await this.process
return address
}
/**
* The workspace directory code-server opens with.
*/
get workspaceDir(): Promise<string> | string {
if (!this._workspaceDir) {
this._workspaceDir = tmpdir(workspaceDir)
}
return this._workspaceDir
}
/**
* Create a random workspace and seed it with settings.
*/
private async createWorkspace(): Promise<string> {
const dir = await this.workspaceDir
await fs.mkdir(path.join(dir, "User"), { recursive: true })
await fs.writeFile(
path.join(dir, "User/settings.json"),
JSON.stringify({
"workbench.startupEditor": "none",
// NOTE@jsjoeio - needed to prevent Trust Policy prompt
// in end-to-end tests.
"security.workspace.trust.enabled": false,
}),
"utf8",
)
return dir
}
/**
* Spawn a new code-server process with its own workspace and data
* directories.
*/
private async spawn(): Promise<CodeServerProcess> {
// 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) => {
const args = [
this.entry,
"--extensions-dir",
path.join(dir, "extensions"),
...this.args,
// 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,
]
this.logger.debug("spawning `node " + args.join(" ") + "`")
const proc = cp.spawn("node", args, {
cwd: path.join(__dirname, "../../.."),
env: {
...process.env,
...this.env,
PASSWORD,
},
})
const timer = idleTimer("Failed to extract address; did the format change?", reject)
proc.on("error", (error) => {
this.logger.error(error.message)
timer.dispose()
reject(error)
})
proc.on("close", (code) => {
const error = new Error("code-server closed unexpectedly. Try running with LOG_LEVEL=debug to see more info.")
if (!this.closed) {
this.logger.error(error.message, field("code", code))
}
timer.dispose()
reject(error)
})
let resolved = false
proc.stdout.setEncoding("utf8")
onLine(proc, (line) => {
// As long as we are actively getting input reset the timer. If we stop
// getting input and still have not found the address the timer will
// reject.
timer.reset()
// Log the line without the timestamp.
this.logger.debug(line.replace(/\[.+\]/, ""))
if (resolved) {
return
}
const match = line.trim().match(/HTTPS? 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
timer.dispose()
resolve({ process: proc, address })
}
})
})
}
/**
* Close the code-server process.
*/
async close(): Promise<void> {
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,
private readonly authenticated: boolean,
) {
this.page.on("console", (message) => {
this.codeServer.logger.debug(message.text())
})
this.page.on("pageerror", (error) => {
logError(this.codeServer.logger, "page", error)
})
}
address() {
return this.codeServer.address()
}
/**
* The workspace directory code-server opens with.
*/
get workspaceDir() {
return this.codeServer.workspaceDir
}
/**
* Navigate to a code-server endpoint (root by default). Then wait for the
* editor to become available.
*/
async navigate(endpoint = "/") {
const to = new URL(endpoint, await this.codeServer.address())
await this.page.goto(to.toString(), { waitUntil: "networkidle" })
// Only reload editor if authenticated. Otherwise we'll get stuck
// reloading the login page.
if (this.authenticated) {
await this.reloadUntilEditorIsReady()
}
}
/**
* 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()
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) {
// 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()) {
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
}
/**
* Focuses the integrated terminal by navigating through the command palette.
*
* This should focus the terminal no matter if it already has focus and/or is
* or isn't visible already. It will always create a new terminal to avoid
* clobbering parallel tests.
*/
async focusTerminal() {
const doFocus = async (): Promise<boolean> => {
await this.executeCommandViaMenus("Terminal: Create New Terminal")
try {
await this.page.waitForLoadState("load")
await this.page.waitForSelector("textarea.xterm-helper-textarea:focus-within", { timeout: 5000 })
return true
} catch (error) {
return false
}
}
let attempts = 1
while (!(await doFocus())) {
++attempts
this.codeServer.logger.debug(`no focused terminal textarea, retrying (${attempts}/∞)`)
}
this.codeServer.logger.debug(`opening terminal took ${attempts} ${plural(attempts, "attempt")}`)
}
/**
* Open a file by using menus.
*/
async openFile(file: string) {
await this.navigateMenus(["File", "Open File"])
await this.navigateQuickInput([path.basename(file)])
await this.waitForTab(file)
}
/**
* Wait for a tab to open for the specified file.
*/
async waitForTab(file: string): Promise<void> {
await this.page.waitForSelector(`.tab :text("${path.basename(file)}")`)
}
/**
* See if the specified tab is open.
*/
async tabIsVisible(file: string): Promise<boolean> {
return this.page.isVisible(`.tab :text("${path.basename(file)}")`)
}
/**
* 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 items in the selector. `open` is a function that will
* open the menu/popup containing the items through which to navigation.
*/
async navigateItems(items: string[], selector: string, open?: (selector: string) => void): Promise<void> {
const logger = this.codeServer.logger.named(selector)
/**
* If the selector loses focus or gets removed this will resolve with false,
* signaling we need to try again.
*/
const openThenWaitClose = async (ctx: Context) => {
if (open) {
await open(selector)
}
this.codeServer.logger.debug(`watching ${selector}`)
try {
await this.page.waitForSelector(`${selector}:not(:focus-within)`)
} catch (error) {
if (!ctx.finished()) {
this.codeServer.logger.debug(`${selector} navigation: ${(error as any).message || error}`)
}
}
return false
}
/**
* This will step through each item, aborting and returning false if
* canceled or if any navigation step has an error which signals we need to
* try again.
*/
const navigate = async (ctx: Context) => {
const steps: Array<{ fn: () => Promise<unknown>; name: string }> = [
{
fn: () => this.page.waitForSelector(`${selector}:focus-within`),
name: "focus",
},
]
for (const item of items) {
// 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({
fn: () => this.page.hover(`${selector} :text("${item}")`, { trial: true }),
name: `${item}:hover:trial`,
})
steps.push({
fn: () => this.page.hover(`${selector} :text("${item}")`, { force: true }),
name: `${item}:hover:force`,
})
steps.push({
fn: () => this.page.click(`${selector} :text("${item}")`, { trial: true }),
name: `${item}:click:trial`,
})
steps.push({
fn: () => this.page.click(`${selector} :text("${item}")`, { force: true }),
name: `${item}:click:force`,
})
}
for (const step of steps) {
try {
logger.debug(`navigation step: ${step.name}`)
await step.fn()
if (ctx.canceled()) {
logger.debug("navigation canceled")
return false
}
} catch (error) {
logger.debug(`navigation: ${(error as any).message || error}`)
return false
}
}
return true
}
// We are seeing the menu closing after opening if we open it too soon and
// the picker getting recreated in the middle of trying to select an item.
// To counter this we will keep trying to navigate through the items every
// time we lose focus or there is an error.
let attempts = 1
let context = new Context()
while (!(await Promise.race([openThenWaitClose(context), navigate(context)]))) {
++attempts
logger.debug(`closed, retrying (${attempts}/∞)`)
context.cancel()
context = new Context()
}
context.finish()
logger.debug(`navigation took ${attempts} ${plural(attempts, "attempt")}`)
}
/**
* Navigate through a currently opened "quick input" widget, retrying on
* failure.
*/
async navigateQuickInput(items: string[]): Promise<void> {
await this.navigateItems(items, ".quick-input-widget")
}
/**
* Navigate through the menu, retrying on failure.
*/
async navigateMenus(menus: string[]): Promise<void> {
await this.navigateItems(menus, '[aria-label="Application Menu"]', async (selector) => {
await this.page.click(selector)
})
}
/**
* Execute a command in the root of the instance's workspace directory.
*/
async exec(command: string): Promise<void> {
await util.promisify(cp.exec)(command, {
cwd: await this.workspaceDir,
})
}
/**
* Install an extension by ID to the instance's temporary extension
* directory.
*/
async installExtension(id: string): Promise<void> {
const dir = path.join(await this.workspaceDir, "extensions")
await util.promisify(cp.exec)(`node . --install-extension ${id} --extensions-dir ${dir}`, {
cwd: path.join(__dirname, "../../.."),
})
}
/**
* Wait for state to be flushed to the database.
*/
async stateFlush(): Promise<void> {
// If we reload too quickly VS Code will be unable to save the state changes
// so wait until those have been written to the database. It flushes every
// five seconds so we need to wait at least that long.
// TODO@asher: There must be a better way.
await this.page.waitForTimeout(5500)
}
}