Merge pull request #2648 from cdr/e2e-test-go-home

feat(testing): add e2e test for 'Go Home' button
This commit is contained in:
Joe Previte 2021-02-25 11:21:43 -07:00 committed by GitHub
commit 7dbb72a9c1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 310 additions and 9 deletions

View File

@ -24,6 +24,9 @@ jobs:
test: test:
needs: linux-amd64 needs: linux-amd64
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
PASSWORD: e45432jklfdsab
CODE_SERVER_ADDRESS: http://localhost:8080
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- name: Download release packages - name: Download release packages
@ -37,9 +40,14 @@ jobs:
- uses: microsoft/playwright-github-action@v1 - uses: microsoft/playwright-github-action@v1
- name: Install dependencies and run tests - name: Install dependencies and run tests
run: | 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 --frozen-lockfile
yarn test yarn test
- name: Upload test artifacts
uses: actions/upload-artifact@v2
with:
name: test-videos
path: ./test/videos
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest

2
.gitignore vendored
View File

@ -16,3 +16,5 @@ node-*
.home .home
coverage coverage
**/.DS_Store **/.DS_Store
test/videos
test/screenshots

View File

@ -9,6 +9,18 @@ main() {
# information. We must also run it from the root otherwise coverage will not # information. We must also run it from the root otherwise coverage will not
# include our source files. # include our source files.
cd "$OLDPWD" 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 "$@" CS_DISABLE_PLUGINS=true ./test/node_modules/.bin/jest "$@"
} }

View File

@ -143,8 +143,16 @@
"lines": 40 "lines": 40
} }
}, },
"testTimeout": 30000,
"globalSetup": "<rootDir>/test/globalSetup.ts",
"modulePathIgnorePatterns": [ "modulePathIgnorePatterns": [
"<rootDir>/release" "<rootDir>/lib/vscode",
"<rootDir>/release-packages",
"<rootDir>/release",
"<rootDir>/release-standalone",
"<rootDir>/release-npm-package",
"<rootDir>/release-gcp",
"<rootDir>/release-images"
] ]
} }
} }

View File

@ -7,7 +7,7 @@ import { rootPath } from "../constants"
import { authenticated, getCookieDomain, redirect, replaceTemplates } from "../http" import { authenticated, getCookieDomain, redirect, replaceTemplates } from "../http"
import { hash, humanPath } from "../util" import { hash, humanPath } from "../util"
enum Cookie { export enum Cookie {
Key = "key", Key = "key",
} }

3
test/constants.ts Normal file
View File

@ -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 || ""

View File

@ -1,4 +1,5 @@
import { chromium, Page, Browser } from "playwright" import { chromium, Page, Browser } from "playwright"
import { CODE_SERVER_ADDRESS } from "./constants"
let browser: Browser let browser: Browser
let page: Page let page: Page
@ -17,7 +18,7 @@ afterEach(async () => {
}) })
it("should see the login page", 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 // It should send us to the login page
expect(await page.title()).toBe("code-server login") expect(await page.title()).toBe("code-server login")
}) })

34
test/globalSetup.ts Normal file
View File

@ -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.")
}

88
test/goHome.test.ts Normal file
View File

@ -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)
})
})

35
test/helpers.ts Normal file
View File

@ -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<Cookie>, 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<Cookie>, cookieToStore: Cookie): Array<Cookie> {
const cookieName = cookieToStore.name
const doesCookieExist = checkForCookie(cookies, cookieName)
if (!doesCookieExist) {
const updatedCookies = [...cookies, cookieToStore]
return updatedCookies
}
return cookies
}

38
test/login.test.ts Normal file
View File

@ -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()
})
})

View File

@ -6,11 +6,8 @@ import * as tls from "tls"
import { Emitter } from "../src/common/emitter" import { Emitter } from "../src/common/emitter"
import { SocketProxyProvider } from "../src/node/socket" import { SocketProxyProvider } from "../src/node/socket"
import { generateCertificate, tmpdir } from "../src/node/util" import { generateCertificate, tmpdir } from "../src/node/util"
import * as wtfnode from "./wtfnode"
describe("SocketProxyProvider", () => { describe("SocketProxyProvider", () => {
wtfnode.setup()
const provider = new SocketProxyProvider() const provider = new SocketProxyProvider()
const onServerError = new Emitter<{ event: string; error: Error }>() const onServerError = new Emitter<{ event: string; error: Error }>()

View File

@ -1,4 +1,5 @@
import { JSDOM } from "jsdom" import { JSDOM } from "jsdom"
import { Cookie } from "playwright"
// Note: we need to import logger from the root // Note: we need to import logger from the root
// because this is the logger used in logError in ../src/common/util // because this is the logger used in logError in ../src/common/util
import { logger } from "../node_modules/@coder/logger" import { logger } from "../node_modules/@coder/logger"
@ -8,12 +9,16 @@ import {
getFirstString, getFirstString,
getOptions, getOptions,
logError, logError,
normalize,
plural, plural,
resolveBase, resolveBase,
split, split,
trimSlashes, trimSlashes,
normalize,
} from "../src/common/util" } 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() const dom = new JSDOM()
global.document = dom.window.document global.document = dom.window.document
@ -255,4 +260,58 @@ describe("util", () => {
expect(spy).toHaveBeenCalledWith("api: oh no") 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)
})
})
}) })

View File

@ -1,7 +1,23 @@
import * as util from "util"
import * as wtfnode from "wtfnode" 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 let active = false
/**
* Start logging open handles periodically. This can be used to see what is
* hanging open if anything.
*/
export function setup(): void { export function setup(): void {
if (active) { if (active) {
return return