diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 64614c98..b7e6805b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,3 @@ +* @cdr/code-server-reviewers + ci/helm-chart @Matthew-Beckett @alexgorbatchev diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a232b5bc..838a0ba6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,6 +24,9 @@ jobs: test: needs: linux-amd64 runs-on: ubuntu-latest + env: + PASSWORD: e45432jklfdsab + CODE_SERVER_ADDRESS: http://localhost:8080 steps: - uses: actions/checkout@v1 - name: Download release packages @@ -37,9 +40,14 @@ jobs: - uses: microsoft/playwright-github-action@v1 - name: Install dependencies and run tests run: | - node ./release-packages/code-server*-linux-amd64 & + ./release-packages/code-server*-linux-amd64/bin/code-server --home $CODE_SERVER_ADDRESS/healthz & yarn --frozen-lockfile yarn test + - name: Upload test artifacts + uses: actions/upload-artifact@v2 + with: + name: test-videos + path: ./test/videos release: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index fdb7c563..e49888f4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,6 @@ node-* /lib/coder-cloud-agent .home coverage -**/.DS_Store \ No newline at end of file +**/.DS_Store +test/videos +test/screenshots diff --git a/ci/README.md b/ci/README.md index c063b88c..4ebda4d8 100644 --- a/ci/README.md +++ b/ci/README.md @@ -43,8 +43,9 @@ Make sure you have `$GITHUB_TOKEN` set and [hub](https://github.com/github/hub) 9. Update the AUR package. - Instructions on updating the AUR package are at [cdr/code-server-aur](https://github.com/cdr/code-server-aur). 10. Wait for the npm package to be published. -11. Update the homebrew package. - - Send a pull request to [homebrew-core](https://github.com/Homebrew/homebrew-core) with the URL in the [formula](https://github.com/Homebrew/homebrew-core/blob/master/Formula/code-server.rb) updated. +11. Update the [homebrew package](https://github.com/Homebrew/homebrew-core/blob/master/Formula/code-server.rb). + 1. Install [homebrew](https://brew.sh/) + 2. Run `brew bump-formula-pr --version=3.8.1 code-server` and update the version accordingly. This will bump the version and open a PR. Note: this will only work once the version is published on npm. ## Updating Code Coverage in README diff --git a/ci/dev/test.sh b/ci/dev/test.sh index 851aa0d3..82f6ad36 100755 --- a/ci/dev/test.sh +++ b/ci/dev/test.sh @@ -9,6 +9,18 @@ main() { # information. We must also run it from the root otherwise coverage will not # include our source files. cd "$OLDPWD" + if [[ -z ${PASSWORD-} ]] || [[ -z ${CODE_SERVER_ADDRESS-} ]]; then + echo "The end-to-end testing suites rely on your local environment" + echo -e "\n" + echo "Please set the following environment variables locally:" + echo " \$PASSWORD" + echo " \$CODE_SERVER_ADDRESS" + echo -e "\n" + echo "Please make sure you have code-server running locally with the flag:" + echo " --home \$CODE_SERVER_ADDRESS/healthz " + echo -e "\n" + exit 1 + fi CS_DISABLE_PLUGINS=true ./test/node_modules/.bin/jest "$@" } diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index edaaf767..67377ee2 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -35,8 +35,9 @@ There are several differences, however. You must: - Use Node.js version 12.x (or greater) - Have [yarn](https://classic.yarnpkg.com/en/) installed (which is used to install JS packages and run development scripts) - Have [nfpm](https://github.com/goreleaser/nfpm) (which is used to build `.deb` and `.rpm` packages and [jq](https://stedolan.github.io/jq/) (used to build code-server releases) installed +- Have [shfmt](https://pkg.go.dev/mvdan.cc/sh/v3) installed to run `yarn fmt` (requires Go is installed on your system) -The [CI container](../ci/images/debian8/Dockerfile) is a useful reference for all +The [CI container](../ci/images/debian10/Dockerfile) is a useful reference for all of the dependencies code-server uses. ## Development Workflow @@ -120,10 +121,10 @@ node ./release The `code-server` script serves an HTTP API for login and starting a remote VS Code process. -The CLI code is in [./src/node](./src/node) and the HTTP routes are implemented in -[./src/node/app](./src/node/app). +The CLI code is in [src/node](../src/node) and the HTTP routes are implemented in +[src/node/routes](../src/node/routes). -Most of the meaty parts are in the VS Code portion of the codebase under [./lib/vscode](./lib/vscode), which we described next. +Most of the meaty parts are in the VS Code portion of the codebase under [lib/vscode](../lib/vscode), which we described next. ### Modifications to VS Code @@ -133,7 +134,7 @@ and exposed an API to the front-end for file access and all UI needs. Over time, Microsoft added support to VS Code to run it on the web. They have made the front-end open source, but not the server. As such, code-server v2 (and later) uses -the VS Code front-end and implements the server. We do this by using a git subtree to fork and modify VS Code. This code lives under [./lib/vscode](./lib/vscode). +the VS Code front-end and implements the server. We do this by using a git subtree to fork and modify VS Code. This code lives under [lib/vscode](../lib/vscode). Some noteworthy changes in our version of VS Code: diff --git a/docs/FAQ.md b/docs/FAQ.md index d36d70af..9b08b58d 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -4,7 +4,7 @@ - [Questions?](#questions) - [iPad Status?](#ipad-status) -- [Community projects (awesome-code-server)](#community-projects-awesome-code-server) +- [Community Projects (awesome-code-server)](#community-projects-awesome-code-server) - [How can I reuse my VS Code configuration?](#how-can-i-reuse-my-vs-code-configuration) - [Differences compared to VS Code?](#differences-compared-to-vs-code) - [How can I request a missing extension?](#how-can-i-request-a-missing-extension) @@ -43,7 +43,7 @@ Please file all questions and support requests at https://github.com/cdr/code-se Please see [./ipad.md](./ipad.md). -## Community projects (awesome-code-server) +## Community Projects (awesome-code-server) Visit the [awesome-code-server](https://github.com/cdr/awesome-code-server) repository to view community projects and guides with code-server! Feel free to add your own! diff --git a/package.json b/package.json index a41bd6b9..136c0322 100644 --- a/package.json +++ b/package.json @@ -143,8 +143,16 @@ "lines": 40 } }, + "testTimeout": 30000, + "globalSetup": "/test/globalSetup.ts", "modulePathIgnorePatterns": [ - "/release" + "/lib/vscode", + "/release-packages", + "/release", + "/release-standalone", + "/release-npm-package", + "/release-gcp", + "/release-images" ] } } diff --git a/src/browser/pages/login.css b/src/browser/pages/login.css index f0586ee8..026cac97 100644 --- a/src/browser/pages/login.css +++ b/src/browser/pages/login.css @@ -1,4 +1,6 @@ body { + min-height: 568px; + min-width: 320px; overflow: auto; } @@ -15,6 +17,12 @@ body { width: 100%; } +@media (max-width: 600px) { + .login-form > .field { + flex-direction: column; + } +} + .login-form > .error { color: red; margin-top: 16px; @@ -38,6 +46,13 @@ body { margin-left: 20px; } +@media (max-width: 600px) { + .login-form > .field > .submit { + margin-left: 0px; + margin-top: 16px; + } +} + input { -webkit-appearance: none; } diff --git a/src/node/routes/login.ts b/src/node/routes/login.ts index c3ad12ad..b89470ae 100644 --- a/src/node/routes/login.ts +++ b/src/node/routes/login.ts @@ -7,7 +7,7 @@ import { rootPath } from "../constants" import { authenticated, getCookieDomain, redirect, replaceTemplates } from "../http" import { hash, humanPath } from "../util" -enum Cookie { +export enum Cookie { Key = "key", } diff --git a/test/constants.ts b/test/constants.ts new file mode 100644 index 00000000..ac2250e1 --- /dev/null +++ b/test/constants.ts @@ -0,0 +1,3 @@ +export const CODE_SERVER_ADDRESS = process.env.CODE_SERVER_ADDRESS || "http://localhost:8080" +export const PASSWORD = process.env.PASSWORD || "e45432jklfdsab" +export const STORAGE = process.env.STORAGE || "" diff --git a/test/e2e.test.ts b/test/e2e.test.ts index b7cf3739..21df386b 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -1,4 +1,5 @@ import { chromium, Page, Browser } from "playwright" +import { CODE_SERVER_ADDRESS } from "./constants" let browser: Browser let page: Page @@ -17,7 +18,7 @@ afterEach(async () => { }) it("should see the login page", async () => { - await page.goto("http://localhost:8080") + await page.goto(CODE_SERVER_ADDRESS) // It should send us to the login page expect(await page.title()).toBe("code-server login") }) diff --git a/test/globalSetup.ts b/test/globalSetup.ts new file mode 100644 index 00000000..5ef45faa --- /dev/null +++ b/test/globalSetup.ts @@ -0,0 +1,34 @@ +// This setup runs before our e2e tests +// so that it authenticates us into code-server +// ensuring that we're logged in before we run any tests +import { chromium } from "playwright" +import { CODE_SERVER_ADDRESS, PASSWORD } from "./constants" +import * as wtfnode from "./wtfnode" + +module.exports = async () => { + console.log("\n🚨 Running Global Setup for Jest Tests") + console.log(" Please hang tight...") + const browser = await chromium.launch() + const context = await browser.newContext() + const page = await context.newPage() + + if (process.env.WTF_NODE) { + wtfnode.setup() + } + + await page.goto(CODE_SERVER_ADDRESS, { waitUntil: "domcontentloaded" }) + // Type in password + await page.fill(".password", PASSWORD) + // Click the submit button and login + await page.click(".submit") + + // Save storage state and store as an env variable + // More info: https://playwright.dev/docs/auth?_highlight=authe#reuse-authentication-state + const storage = await context.storageState() + process.env.STORAGE = JSON.stringify(storage) + + await page.close() + await browser.close() + await context.close() + console.log("✅ Global Setup for Jest Tests is now complete.") +} diff --git a/test/goHome.test.ts b/test/goHome.test.ts new file mode 100644 index 00000000..31cd773c --- /dev/null +++ b/test/goHome.test.ts @@ -0,0 +1,88 @@ +import { chromium, Page, Browser, BrowserContext, Cookie } from "playwright" +import { hash } from "../src/node/util" +import { CODE_SERVER_ADDRESS, PASSWORD, STORAGE } from "./constants" +import { createCookieIfDoesntExist } from "./helpers" + +describe("go home", () => { + let browser: Browser + let page: Page + let context: BrowserContext + + beforeAll(async () => { + browser = await chromium.launch() + // Create a new context with the saved storage state + const storageState = JSON.parse(STORAGE) || {} + + const cookieToStore = { + sameSite: "Lax" as const, + name: "key", + value: hash(PASSWORD), + domain: "localhost", + path: "/", + expires: -1, + httpOnly: false, + secure: false, + } + + // For some odd reason, the login method used in globalSetup.ts doesn't always work + // I don't know if it's on playwright clearing our cookies by accident + // or if it's our cookies disappearing. + // This means we need an additional check to make sure we're logged in. + // We do this by manually adding the cookie to the browser environment + // if it's not there at the time the test starts + const cookies: Cookie[] = storageState.cookies || [] + // If the cookie exists in cookies then + // this will return the cookies with no changes + // otherwise if it doesn't exist, it will create it + // hence the name maybeUpdatedCookies + // + // TODO(@jsjoeio) + // The playwright storage thing sometimes works and sometimes doesn't. We should investigate this further + // at some point. + // See discussion: https://github.com/cdr/code-server/pull/2648#discussion_r575434946 + + const maybeUpdatedCookies = createCookieIfDoesntExist(cookies, cookieToStore) + + context = await browser.newContext({ + storageState: { cookies: maybeUpdatedCookies }, + recordVideo: { dir: "./test/videos/" }, + }) + }) + + afterAll(async () => { + // Remove password from local storage + await context.clearCookies() + + await context.close() + await browser.close() + }) + + beforeEach(async () => { + page = await context.newPage() + }) + + // NOTE: this test will fail if you do not run code-server with --home $CODE_SERVER_ADDRESS/healthz + it("should see a 'Go Home' button in the Application Menu that goes to /healthz", async () => { + const GO_HOME_URL = `${CODE_SERVER_ADDRESS}/healthz` + // Sometimes a dialog shows up when you navigate + // asking if you're sure you want to leave + // so we listen if it comes, we accept it + page.on("dialog", (dialog) => dialog.accept()) + + // waitUntil: "domcontentloaded" + // In case the page takes a long time to load + await page.goto(CODE_SERVER_ADDRESS, { waitUntil: "domcontentloaded" }) + + // Click the Home menu + await page.click(".home-bar ul[aria-label='Home'] li") + // See the Go Home button + const goHomeButton = "a.action-menu-item span[aria-label='Go Home']" + expect(await page.isVisible(goHomeButton)) + + // Click it and navigate to /healthz + // NOTE: ran into issues of it failing intermittently + // without having button: "middle" + await Promise.all([page.waitForNavigation(), page.click(goHomeButton, { button: "middle" })]) + expect(page.url()).toBe(GO_HOME_URL) + }) +}) diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 00000000..193fd0ca --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,35 @@ +// Borrowed from playwright +export interface Cookie { + name: string + value: string + domain: string + path: string + /** + * Unix time in seconds. + */ + expires: number + httpOnly: boolean + secure: boolean + sameSite: "Strict" | "Lax" | "None" +} + +/** + * Checks if a cookie exists in array of cookies + */ +export function checkForCookie(cookies: Array, key: string): boolean { + // Check for a cookie where the name is equal to key + return Boolean(cookies.find((cookie) => cookie.name === key)) +} + +/** + * Creates a login cookie if one doesn't already exist + */ +export function createCookieIfDoesntExist(cookies: Array, cookieToStore: Cookie): Array { + const cookieName = cookieToStore.name + const doesCookieExist = checkForCookie(cookies, cookieName) + if (!doesCookieExist) { + const updatedCookies = [...cookies, cookieToStore] + return updatedCookies + } + return cookies +} diff --git a/test/login.test.ts b/test/login.test.ts new file mode 100644 index 00000000..b269acbd --- /dev/null +++ b/test/login.test.ts @@ -0,0 +1,38 @@ +import { chromium, Page, Browser, BrowserContext } from "playwright" +import { CODE_SERVER_ADDRESS, PASSWORD } from "./constants" + +describe("login", () => { + let browser: Browser + let page: Page + let context: BrowserContext + + beforeAll(async () => { + browser = await chromium.launch() + context = await browser.newContext() + }) + + afterAll(async () => { + await browser.close() + }) + + beforeEach(async () => { + page = await context.newPage() + }) + + afterEach(async () => { + await page.close() + // Remove password from local storage + await context.clearCookies() + }) + + it("should be able to login", async () => { + await page.goto(CODE_SERVER_ADDRESS) + // Type in password + await page.fill(".password", PASSWORD) + // Click the submit button and login + await page.click(".submit") + // See the editor + const codeServerEditor = await page.isVisible(".monaco-workbench") + expect(codeServerEditor).toBeTruthy() + }) +}) diff --git a/test/socket.test.ts b/test/socket.test.ts index aadf86b4..e614e94d 100644 --- a/test/socket.test.ts +++ b/test/socket.test.ts @@ -6,11 +6,8 @@ import * as tls from "tls" import { Emitter } from "../src/common/emitter" import { SocketProxyProvider } from "../src/node/socket" import { generateCertificate, tmpdir } from "../src/node/util" -import * as wtfnode from "./wtfnode" describe("SocketProxyProvider", () => { - wtfnode.setup() - const provider = new SocketProxyProvider() const onServerError = new Emitter<{ event: string; error: Error }>() diff --git a/test/util.test.ts b/test/util.test.ts index 78985554..0de2d7ea 100644 --- a/test/util.test.ts +++ b/test/util.test.ts @@ -1,4 +1,5 @@ import { JSDOM } from "jsdom" +import { Cookie } from "playwright" // Note: we need to import logger from the root // because this is the logger used in logError in ../src/common/util import { logger } from "../node_modules/@coder/logger" @@ -8,12 +9,16 @@ import { getFirstString, getOptions, logError, - normalize, plural, resolveBase, split, trimSlashes, + normalize, } from "../src/common/util" +import { Cookie as CookieEnum } from "../src/node/routes/login" +import { hash } from "../src/node/util" +import { PASSWORD } from "./constants" +import { checkForCookie, createCookieIfDoesntExist } from "./helpers" const dom = new JSDOM() global.document = dom.window.document @@ -255,4 +260,58 @@ describe("util", () => { expect(spy).toHaveBeenCalledWith("api: oh no") }) }) + + describe("checkForCookie", () => { + it("should check if the cookie exists and has a value", () => { + const fakeCookies: Cookie[] = [ + { + name: CookieEnum.Key, + value: hash(PASSWORD), + domain: "localhost", + secure: false, + sameSite: "Lax", + httpOnly: false, + expires: 18000, + path: "/", + }, + ] + expect(checkForCookie(fakeCookies, CookieEnum.Key)).toBe(true) + }) + it("should return false if there are no cookies", () => { + const fakeCookies: Cookie[] = [] + expect(checkForCookie(fakeCookies, "key")).toBe(false) + }) + }) + + describe("createCookieIfDoesntExist", () => { + it("should create a cookie if it doesn't exist", () => { + const cookies: Cookie[] = [] + const cookieToStore = { + name: CookieEnum.Key, + value: hash(PASSWORD), + domain: "localhost", + secure: false, + sameSite: "Lax" as const, + httpOnly: false, + expires: 18000, + path: "/", + } + expect(createCookieIfDoesntExist(cookies, cookieToStore)).toStrictEqual([cookieToStore]) + }) + it("should return the same cookies if the cookie already exists", () => { + const PASSWORD = "123supersecure" + const cookieToStore = { + name: CookieEnum.Key, + value: hash(PASSWORD), + domain: "localhost", + secure: false, + sameSite: "Lax" as const, + httpOnly: false, + expires: 18000, + path: "/", + } + const cookies: Cookie[] = [cookieToStore] + expect(createCookieIfDoesntExist(cookies, cookieToStore)).toStrictEqual(cookies) + }) + }) }) diff --git a/test/wtfnode.ts b/test/wtfnode.ts index 2dfce59a..2d31a4e6 100644 --- a/test/wtfnode.ts +++ b/test/wtfnode.ts @@ -1,7 +1,23 @@ +import * as util from "util" import * as wtfnode from "wtfnode" +// Jest seems to hijack console.log in a way that makes the output difficult to +// read. So we'll write directly to process.stderr instead. +const write = (...args: [any, ...any]) => { + if (args.length > 0) { + process.stderr.write(util.format(...args) + "\n") + } +} +wtfnode.setLogger("info", write) +wtfnode.setLogger("warn", write) +wtfnode.setLogger("error", write) + let active = false +/** + * Start logging open handles periodically. This can be used to see what is + * hanging open if anything. + */ export function setup(): void { if (active) { return