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/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/package.json b/package.json index decec942..7f427a78 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/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