1
0
mirror of https://github.com/coder/code-server.git synced 2024-12-04 23:03:06 +08:00
code-server/test/e2e/models/CodeServer.ts
Joe Previte 705e821741
fix(testing): revert change & fix playwright tests (#4310)
* fix(testing): revert change & fix playwright tests

* fix(constants): add type to import statement

* refactor(e2e): delete browser test

This test was originally added to ensure playwright was working.

At this point, we know it works so removing this test because it doesn't help
with anything specific to code-server and only adds unnecessary code to the
codebase plus increases the e2e test job duration.

* chore(e2e): use 1 worker for e2e test

I don't know if it's a resources issue, playwright, or code-server but it seems
like the e2e tests choke when multiple workers are used.

This change is okay because our CI runner only has 2 cores so it would only use
1 worker anyway, but by specifying it in our playwright config, we ensure more
stability in our e2e tests working correctly.

See these PRs:
- https://github.com/cdr/code-server/pull/3263
- https://github.com/cdr/code-server/pull/4310

* revert(vscode): add missing route with redirect

* chore(vscode): update to latest fork

* Touch up compilation step.

* Bump vendor.

* Fix VS Code minification step

* Move ClientConfiguration to common

Common code must not import Node code as it is imported by the browser.

* Ensure lib directory exists before curling

cURL errors now because VS Code was moved and the directory does not
exist.

* Update incorrect e2e test help output

Revert workers change as well; this can be overridden when desired.

* Add back extension compilation step

* Include missing resources in release

This includes a favicon, for example.  I opted to include the entire
directory to make sure we do not miss anything.  Some of the other
stuff looks potentially useful (like completions).

* Set quality property in product configuration

When httpWebWorkerExtensionHostIframe.html is fetched it uses the web
endpoint template (in which we do not include the commit) but if the
quality is not set it prepends the commit to the web endpoint instead.
The new static endpoint does not use/handle commits so this 404s.

Long-term we might want to make the new static endpoint use commits like
the old one but we will also need to update the various other static
URLs to include the commit.

For now I just fixed this by adding the quality since:
  1. Probably faster than trying to find and update all static uses.
  2. VS Code probably expects it anyway.
  3. Gives us better control over the endpoint.

* Update VS Code

This fixes several build issues.

* Bump vscode.

* Bump.

* Bump.

* Use CLI directly.

* Update tests to reflect new upstream behavior.

* Move unit tests to after the build

Our code has new dependencies on VS Code that are pulled in when the
unit tests run.  Because of this we need to build VS Code before running
the unit tests (as it only pulls built code).

* Upgrade proxy-agent dependencies

This resolves a security report with one of its dependencies (vm2).

* Symlink VS Code output directory before unit tests

This is necessary now that we import from the out directory.

* Fix issues surrounding persistent processes between tests.

* Update VS Code cache directories

These were renamed so the cached paths need to be updated.  I changed
the key as well to force a rebuild.

* Move test symlink to script

This way it works for local testing as well.

I had to use out-build instead of out-vscode-server-min because Jest
throws some obscure error about a handlebars haste map.

* Fix listening on a socket

* Update VS Code

It contains fixes for missing files in the build.

* Standardize disposals

* Dispose HTTP server

Shares code with the test HTTP server.  For now it is a function but
maybe we should make it a class that is extended by tests.

* Dispose app on exit

* Fix logging link errors

Unfortunately the logger currently chokes when provided with error
objects.

Also for some reason the bracketed text was not displaying...

* Update regex used by e2e to extract address

The address was recently changed to use URL which seems to add a
trailing slash when using toString, causing the regex match to fail.

* Log browser console in e2e tests

* Add base back to login page

This is used to set cookies when using a base path.

* Remove login page test

The file this was testing no longer exists.

* Use path.posix for static base

Since this is a web path and not platform-dependent.

* Add test for invalid password

Co-authored-by: Teffen Ellis <teffen@nirri.us>
Co-authored-by: Asher <ash@coder.com>
2021-10-28 15:27:17 -05:00

353 lines
11 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 { 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 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<CodeServerProcess> | 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<string> {
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<string> {
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<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) => {
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,
},
},
)
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("closed unexpectedly")
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.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
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) {
this.page.on("console", (message) => {
this.codeServer.logger.debug(message)
})
this.page.on("pageerror", (error) => {
logError(this.codeServer.logger, "page", error)
})
}
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<unknown>> = [() => 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()
}
}
}