acc50a5d36
* Update dependencies and force-update qs This is mainly an attempt to get rid of as many resolutions as possible since it seems they are unnecessary except for qs (according to yarn/npm audit). For qs use 6.9.7 since Express is using 6.9.6 and that matches the most closely. Also add overrides since this is npm's version of yarn's resolutions and we need it for the shrinkwrap to generate with the right dependencies. Decided to keep pinning @types/node as well although I am not sure it is necessary. Express is pulling in v20 types. Since this is development-only we only need it in resolutions. * Run formatter Some rules seem to have changed with the dependency updates. * Replace deprecated bodyParser.json() usage * Audit npm shrinkwrap as well * Skip installing dependencies in audit It seems the tools only require the lock files. * Fix tests when using ipv6 * Add missing openssl dependency to flake
589 lines
18 KiB
TypeScript
589 lines
18 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, normalize, plural } from "../../../src/common/util"
|
|
import { onLine } from "../../../src/node/util"
|
|
import { PASSWORD, workspaceDir } from "../../utils/constants"
|
|
import { getMaybeProxiedCodeServer, 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",
|
|
}),
|
|
"utf8",
|
|
)
|
|
|
|
const extensionsDir = path.join(__dirname, "../extensions")
|
|
const languagepacksContent = {
|
|
es: {
|
|
hash: "8d919a946475223861fa0c62665a4c50",
|
|
extensions: [
|
|
{
|
|
extensionIdentifier: {
|
|
id: "ms-ceintl.vscode-language-pack-es",
|
|
uuid: "47e020a1-33db-4cc0-a1b4-42f97781749a",
|
|
},
|
|
version: "1.70.0",
|
|
},
|
|
],
|
|
translations: {
|
|
vscode: `${extensionsDir}/ms-ceintl.vscode-language-pack-es-1.70.0/translations/main.i18n.json`,
|
|
},
|
|
label: "español",
|
|
},
|
|
}
|
|
|
|
// NOTE@jsjoeio - code-server should automatically generate the languagepacks.json for
|
|
// using different display languages. This is a temporary workaround until we fix that.
|
|
await fs.writeFile(path.join(dir, "languagepacks.json"), JSON.stringify(languagepacksContent))
|
|
return dir
|
|
}
|
|
|
|
/**
|
|
* Spawn a new code-server process with its own workspace and data
|
|
* directories.
|
|
*/
|
|
private async spawn(): Promise<CodeServerProcess> {
|
|
const dir = await this.createWorkspace()
|
|
const args = await this.argsWithDefaults([
|
|
"--auth",
|
|
"none",
|
|
// The workspace to open.
|
|
...(this.args.includes("--ignore-last-opened") ? [] : [dir]),
|
|
...this.args,
|
|
// Using port zero will spawn on a random port.
|
|
"--bind-addr",
|
|
"127.0.0.1:0",
|
|
])
|
|
return new Promise((resolve, reject) => {
|
|
this.logger.debug("spawning `node " + args.join(" ") + "`")
|
|
const proc = cp.spawn("node", args, {
|
|
cwd: path.join(__dirname, "../../.."),
|
|
env: {
|
|
...process.env,
|
|
...this.env,
|
|
// Prevent code-server from using the existing instance when running
|
|
// the e2e tests from an integrated terminal.
|
|
VSCODE_IPC_HOOK_CLI: "",
|
|
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)
|
|
})
|
|
|
|
// Tracks when the HTTP and session servers are ready.
|
|
let httpAddress: string | undefined
|
|
let sessionAddress: string | undefined
|
|
|
|
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 addresses the timer will
|
|
// reject.
|
|
timer.reset()
|
|
|
|
// Log the line without the timestamp.
|
|
this.logger.debug(line.replace(/\[.+\]/, ""))
|
|
if (resolved) {
|
|
return
|
|
}
|
|
|
|
let match = line.trim().match(/HTTPS? server listening on (https?:\/\/[.:\d]+)\/?$/)
|
|
if (match) {
|
|
// Cookies don't seem to work on IP addresses so swap to localhost.
|
|
// TODO: Investigate whether this is a bug with code-server.
|
|
httpAddress = match[1].replace("127.0.0.1", "localhost")
|
|
}
|
|
|
|
match = line.trim().match(/Session server listening on (.+)$/)
|
|
if (match) {
|
|
sessionAddress = match[1]
|
|
}
|
|
|
|
if (typeof httpAddress !== "undefined" && typeof sessionAddress !== "undefined") {
|
|
resolved = true
|
|
timer.dispose()
|
|
this.logger.debug(`code-server is ready: ${httpAddress} ${sessionAddress}`)
|
|
resolve({ process: proc, address: httpAddress })
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Execute a short-lived command.
|
|
*/
|
|
async run(args: string[]): Promise<void> {
|
|
args = await this.argsWithDefaults(args)
|
|
this.logger.debug("executing `node " + args.join(" ") + "`")
|
|
await util.promisify(cp.exec)("node " + args.join(" "), {
|
|
cwd: path.join(__dirname, "../../.."),
|
|
env: {
|
|
...process.env,
|
|
...this.env,
|
|
// Prevent code-server from using the existing instance when running
|
|
// the e2e tests from an integrated terminal.
|
|
VSCODE_IPC_HOOK_CLI: "",
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Combine arguments with defaults.
|
|
*/
|
|
private async argsWithDefaults(args: string[]): Promise<string[]> {
|
|
// This will be used both as the workspace and data directory to ensure
|
|
// instances don't bleed into each other.
|
|
const dir = await this.workspaceDir
|
|
return [
|
|
this.entry,
|
|
"--extensions-dir",
|
|
path.join(dir, "extensions"),
|
|
...args,
|
|
// 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,
|
|
]
|
|
}
|
|
|
|
/**
|
|
* 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()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether or not authentication is enabled.
|
|
*/
|
|
authEnabled(): boolean {
|
|
return this.args.includes("password")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
) {
|
|
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 address = await getMaybeProxiedCodeServer(this.codeServer)
|
|
const noramlizedUrl = normalize(address + endpoint, true)
|
|
const to = new URL(noramlizedUrl)
|
|
|
|
this.codeServer.logger.info(`navigating to ${to}`)
|
|
await this.page.goto(to.toString(), { waitUntil: "networkidle" })
|
|
|
|
// Only reload editor if auth is not enabled. Otherwise we'll get stuck
|
|
// reloading the login page.
|
|
if (!this.codeServer.authEnabled()) {
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Checks if the test extension loaded
|
|
*/
|
|
async waitForTestExtensionLoaded(): Promise<void> {
|
|
const selector = "text=test extension loaded"
|
|
this.codeServer.logger.debug("Waiting for test extension to load...")
|
|
|
|
await this.page.waitForSelector(selector)
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
}
|
|
|
|
/**
|
|
* Open a file through an external command.
|
|
*/
|
|
async openFileExternally(file: string) {
|
|
await this.codeServer.run(["--reuse-window", 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)
|
|
}
|
|
}
|