diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8fcda93a..f433f641 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -334,8 +334,9 @@ jobs: needs: package-linux-amd64 runs-on: ubuntu-latest env: - PASSWORD: e45432jklfdsab - CODE_SERVER_ADDRESS: http://localhost:8080 + # Since we build code-server we might as well run tests from the release + # since VS Code will load faster due to the bundling. + CODE_SERVER_TEST_ENTRY: "./release-packages/code-server-linux-amd64" steps: - uses: actions/checkout@v2 @@ -362,9 +363,11 @@ jobs: name: release-packages path: ./release-packages - - name: Untar code-server file + - name: Untar code-server release run: | - cd release-packages && tar -xzf code-server*-linux-amd64.tar.gz + cd release-packages + tar -xzf code-server*-linux-amd64.tar.gz + mv code-server*-linux-amd64 code-server-linux-amd64 - name: Install dependencies if: steps.cache-yarn.outputs.cache-hit != 'true' @@ -380,9 +383,7 @@ jobs: yarn install --check-files - name: Run end-to-end tests - run: | - ./release-packages/code-server*-linux-amd64/bin/code-server --log trace & - yarn test:e2e + run: yarn test:e2e - name: Upload test artifacts if: always() diff --git a/ci/dev/test-e2e.sh b/ci/dev/test-e2e.sh index 1e6dcfa5..47c01cda 100755 --- a/ci/dev/test-e2e.sh +++ b/ci/dev/test-e2e.sh @@ -3,10 +3,35 @@ set -euo pipefail main() { cd "$(dirname "$0")/../.." + source ./ci/lib.sh + + local dir="$PWD" + if [[ ! ${CODE_SERVER_TEST_ENTRY-} ]]; then + echo "Set CODE_SERVER_TEST_ENTRY to test another build of code-server" + else + pushd "$CODE_SERVER_TEST_ENTRY" + dir="$PWD" + popd + fi + + echo "Testing build in '$dir'" + + # Simple sanity checks to see that we've built. There could still be things + # wrong (native modules version issues, incomplete build, etc). + if [[ ! -d $dir/out ]]; then + echo >&2 "No code-server build detected" + echo >&2 "You can build it with 'yarn build' or 'yarn watch'" + exit 1 + fi + + if [[ ! -d $dir/lib/vscode/out ]]; then + echo >&2 "No VS Code build detected" + echo >&2 "You can build it with 'yarn build:vscode' or 'yarn watch'" + exit 1 + fi + cd test - # We set these environment variables because they're used in the e2e tests - # they don't have to be these values, but these are the defaults - PASSWORD=e45432jklfdsab CODE_SERVER_ADDRESS=http://localhost:8080 yarn playwright test "$@" + yarn playwright test "$@" } main "$@" diff --git a/ci/dev/watch.ts b/ci/dev/watch.ts index 714a12f7..1503256c 100644 --- a/ci/dev/watch.ts +++ b/ci/dev/watch.ts @@ -2,6 +2,7 @@ import browserify from "browserify" import * as cp from "child_process" import * as fs from "fs" import * as path from "path" +import { onLine } from "../../src/node/util" async function main(): Promise { try { @@ -97,38 +98,6 @@ class Watcher { path.join(this.rootPath, "out/browser/pages/vscode.js"), ] - // From https://github.com/chalk/ansi-regex - const pattern = [ - "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)", - "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))", - ].join("|") - const re = new RegExp(pattern, "g") - - /** - * Split stdout on newlines and strip ANSI codes. - */ - const onLine = (proc: cp.ChildProcess, callback: (strippedLine: string, originalLine: string) => void): void => { - let buffer = "" - if (!proc.stdout) { - throw new Error("no stdout") - } - proc.stdout.setEncoding("utf8") - proc.stdout.on("data", (d) => { - const data = buffer + d - const split = data.split("\n") - const last = split.length - 1 - - for (let i = 0; i < last; ++i) { - callback(split[i].replace(re, ""), split[i]) - } - - // The last item will either be an empty string (the data ended with a - // newline) or a partial line (did not end with a newline) and we must - // wait to parse it until we get a full line. - buffer = split[last] - }) - } - let startingVscode = false let startedVscode = false onLine(vscode, (line, original) => { diff --git a/src/node/util.ts b/src/node/util.ts index 053f2df5..9129c7e8 100644 --- a/src/node/util.ts +++ b/src/node/util.ts @@ -17,6 +17,38 @@ export interface Paths { runtime: string } +// From https://github.com/chalk/ansi-regex +const pattern = [ + "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)", + "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))", +].join("|") +const re = new RegExp(pattern, "g") + +/** + * Split stdout on newlines and strip ANSI codes. + */ +export const onLine = (proc: cp.ChildProcess, callback: (strippedLine: string, originalLine: string) => void): void => { + let buffer = "" + if (!proc.stdout) { + throw new Error("no stdout") + } + proc.stdout.setEncoding("utf8") + proc.stdout.on("data", (d) => { + const data = buffer + d + const split = data.split("\n") + const last = split.length - 1 + + for (let i = 0; i < last; ++i) { + callback(split[i].replace(re, ""), split[i]) + } + + // The last item will either be an empty string (the data ended with a + // newline) or a partial line (did not end with a newline) and we must + // wait to parse it until we get a full line. + buffer = split[last] + }) +} + export const paths = getEnvPaths() /** diff --git a/test/e2e/baseFixture.ts b/test/e2e/baseFixture.ts index a5fc1551..a18722ed 100644 --- a/test/e2e/baseFixture.ts +++ b/test/e2e/baseFixture.ts @@ -1,12 +1,71 @@ +import { field, logger } from "@coder/logger" import { test as base } from "@playwright/test" -import { CodeServer } from "./models/CodeServer" +import { CodeServer, CodeServerPage } from "./models/CodeServer" -export const test = base.extend<{ codeServerPage: CodeServer }>({ - codeServerPage: async ({ page }, use) => { - const codeServer = new CodeServer(page) - await codeServer.navigate() - await use(codeServer) +/** + * Wraps `test.describe` to create and manage an instance of code-server. If you + * don't use this you will need to create your own code-server instance and pass + * it to `test.use`. + * + * If `includeCredentials` is `true` page requests will be authenticated. + */ +export const describe = (name: string, includeCredentials: boolean, fn: (codeServer: CodeServer) => void) => { + test.describe(name, () => { + // This will spawn on demand so nothing is necessary on before. + const codeServer = new CodeServer(name) + + // Kill code-server after the suite has ended. This may happen even without + // doing it explicitly but it seems prudent to be sure. + test.afterAll(async () => { + await codeServer.close() + }) + + const storageState = JSON.parse(process.env.STORAGE || "{}") + + // Sanity check to ensure the cookie is set. + const cookies = storageState?.cookies + if (includeCredentials && (!cookies || cookies.length !== 1 || !!cookies[0].key)) { + logger.error("no cookies", field("storage", JSON.stringify(cookies))) + throw new Error("no credentials to include") + } + + test.use({ + // Makes `codeServer` and `authenticated` available to the extend call + // below. + codeServer, + authenticated: includeCredentials, + // This provides a cookie that authenticates with code-server. + storageState: includeCredentials ? storageState : {}, + }) + + fn(codeServer) + }) +} + +interface TestFixtures { + authenticated: boolean + codeServer: CodeServer + codeServerPage: CodeServerPage +} + +/** + * Create a test that spawns code-server if necessary and ensures the page is + * ready. + */ +export const test = base.extend({ + authenticated: false, + codeServer: undefined, // No default; should be provided through `test.use`. + codeServerPage: async ({ authenticated, codeServer, page }, use) => { + // It's possible code-server might prevent navigation because of unsaved + // changes (seems to happen based on timing even if no changes have been + // made too). In these cases just accept. + page.on("dialog", (d) => d.accept()) + + const codeServerPage = new CodeServerPage(codeServer, page) + await codeServerPage.setup(authenticated) + await use(codeServerPage) }, }) +/** Shorthand for test.expect. */ export const expect = test.expect diff --git a/test/e2e/browser.test.ts b/test/e2e/browser.test.ts index 9e3e0154..3c3b2411 100644 --- a/test/e2e/browser.test.ts +++ b/test/e2e/browser.test.ts @@ -1,7 +1,7 @@ -import { test, expect } from "./baseFixture" +import { describe, test, expect } from "./baseFixture" // This is a "gut-check" test to make sure playwright is working as expected -test.describe("browser", () => { +describe("browser", true, () => { test("browser should display correct userAgent", async ({ codeServerPage, browserName }) => { const displayNames = { chromium: "Chrome", diff --git a/test/e2e/codeServer.test.ts b/test/e2e/codeServer.test.ts index f2a95121..cfac6f74 100644 --- a/test/e2e/codeServer.test.ts +++ b/test/e2e/codeServer.test.ts @@ -1,12 +1,7 @@ -import { CODE_SERVER_ADDRESS, storageState } from "../utils/constants" -import { test, expect } from "./baseFixture" +import { describe, test, expect } from "./baseFixture" -test.describe("CodeServer", () => { - test.use({ - storageState, - }) - - test(`should navigate to ${CODE_SERVER_ADDRESS}`, async ({ codeServerPage }) => { +describe("CodeServer", true, () => { + test("should navigate to home page", async ({ codeServerPage }) => { // We navigate codeServer before each test // and we start the test with a storage state // which means we should be logged in @@ -14,7 +9,7 @@ test.describe("CodeServer", () => { const url = codeServerPage.page.url() // We use match because there may be a / at the end // so we don't want it to fail if we expect http://localhost:8080 to match http://localhost:8080/ - expect(url).toMatch(CODE_SERVER_ADDRESS) + expect(url).toMatch(await codeServerPage.address()) }) test("should always see the code-server editor", async ({ codeServerPage }) => { diff --git a/test/e2e/globalSetup.test.ts b/test/e2e/globalSetup.test.ts index e1c54e13..8b4589b1 100644 --- a/test/e2e/globalSetup.test.ts +++ b/test/e2e/globalSetup.test.ts @@ -1,13 +1,8 @@ -import { storageState } from "../utils/constants" -import { test, expect } from "./baseFixture" +import { describe, test, expect } from "./baseFixture" // This test is to make sure the globalSetup works as expected // meaning globalSetup ran and stored the storageState -test.describe("globalSetup", () => { - test.use({ - storageState, - }) - +describe("globalSetup", true, () => { test("should keep us logged in using the storageState", async ({ codeServerPage }) => { // Make sure the editor actually loaded expect(await codeServerPage.isEditorVisible()).toBe(true) diff --git a/test/e2e/login.test.ts b/test/e2e/login.test.ts index 7975bfca..bc9d5e8e 100644 --- a/test/e2e/login.test.ts +++ b/test/e2e/login.test.ts @@ -1,13 +1,7 @@ import { PASSWORD } from "../utils/constants" -import { test, expect } from "./baseFixture" - -test.describe("login", () => { - // Reset the browser so no cookies are persisted - // by emptying the storageState - test.use({ - storageState: {}, - }) +import { describe, test, expect } from "./baseFixture" +describe("login", false, () => { test("should see the login page", async ({ codeServerPage }) => { // It should send us to the login page expect(await codeServerPage.page.title()).toBe("code-server login") diff --git a/test/e2e/logout.test.ts b/test/e2e/logout.test.ts index bec0f323..052e438d 100644 --- a/test/e2e/logout.test.ts +++ b/test/e2e/logout.test.ts @@ -1,25 +1,7 @@ -import { CODE_SERVER_ADDRESS, PASSWORD } from "../utils/constants" -import { test, expect } from "./baseFixture" - -test.describe("logout", () => { - // Reset the browser so no cookies are persisted - // by emptying the storageState - test.use({ - storageState: {}, - }) - - test("should be able login and logout", async ({ codeServerPage }) => { - // Type in password - await codeServerPage.page.fill(".password", PASSWORD) - // Click the submit button and login - await codeServerPage.page.click(".submit") - await codeServerPage.page.waitForLoadState("networkidle") - // We do this because occassionally code-server doesn't load on Firefox - // but loads if you reload once or twice - await codeServerPage.reloadUntilEditorIsReady() - // Make sure the editor actually loaded - expect(await codeServerPage.isEditorVisible()).toBe(true) +import { describe, test, expect } from "./baseFixture" +describe("logout", true, () => { + test("should be able logout", async ({ codeServerPage }) => { // Click the Application menu await codeServerPage.page.click("[aria-label='Application Menu']") @@ -28,17 +10,11 @@ test.describe("logout", () => { expect(await codeServerPage.page.isVisible(logoutButton)).toBe(true) await codeServerPage.page.hover(logoutButton) - // TODO(@jsjoeio) - // Look into how we're attaching the handlers for the logout feature - // We need to see how it's done upstream and add logging to the - // handlers themselves. - // They may be attached too slowly, hence why we need this timeout - await codeServerPage.page.waitForTimeout(2000) // Recommended by Playwright for async navigation // https://github.com/microsoft/playwright/issues/1987#issuecomment-620182151 await Promise.all([codeServerPage.page.waitForNavigation(), codeServerPage.page.click(logoutButton)]) const currentUrl = codeServerPage.page.url() - expect(currentUrl).toBe(`${CODE_SERVER_ADDRESS}/login`) + expect(currentUrl).toBe(`${await codeServerPage.address()}/login`) }) }) diff --git a/test/e2e/models/CodeServer.ts b/test/e2e/models/CodeServer.ts index 9ec5151d..42600df3 100644 --- a/test/e2e/models/CodeServer.ts +++ b/test/e2e/models/CodeServer.ts @@ -1,21 +1,163 @@ +import { 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 { CODE_SERVER_ADDRESS } from "../../utils/constants" -// This is a Page Object Model -// We use these to simplify e2e test authoring -// See Playwright docs: https://playwright.dev/docs/pom/ -export class CodeServer { - page: Page - editorSelector = "div.monaco-workbench" +import { onLine } from "../../../src/node/util" +import { PASSWORD, workspaceDir } from "../../utils/constants" +import { tmpdir } from "../../utils/helpers" - constructor(page: Page) { - this.page = page +interface CodeServerProcess { + process: cp.ChildProcess + address: string +} + +/** + * Class for spawning and managing code-server. + */ +export class CodeServer { + private process: Promise | undefined + private readonly logger: Logger + private closed = false + + constructor(name: string) { + this.logger = logger.named(name) } /** - * Navigates to CODE_SERVER_ADDRESS + * The address at which code-server can be accessed. Spawns code-server if it + * has not yet been spawned. + */ + async address(): Promise { + 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 { + 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 { + // 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, + }, + }, + ) + + proc.on("error", (error) => { + this.logger.error(error.message) + reject(error) + }) + + proc.on("close", () => { + const error = new Error("closed unexpectedly") + if (!this.closed) { + this.logger.error(error.message) + } + reject(error) + }) + + let resolved = false + proc.stdout.setEncoding("utf8") + onLine(proc, (line) => { + // 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 + resolve({ process: proc, address }) + } + }) + }) + } + + /** + * Close the code-server process. + */ + async close(): Promise { + 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) {} + + address() { + return this.codeServer.address() + } + + /** + * Navigate to code-server. */ async navigate() { - await this.page.goto(CODE_SERVER_ADDRESS, { waitUntil: "networkidle" }) + const address = await this.codeServer.address() + await this.page.goto(address, { waitUntil: "networkidle" }) } /** @@ -42,7 +184,7 @@ export class CodeServer { await this.page.waitForTimeout(1000) reloadCount += 1 if ((await this.isEditorVisible()) && (await this.isConnected())) { - console.log(` Editor became ready after ${reloadCount} reloads`) + logger.debug(`editor became ready after ${reloadCount} reloads`) break } await this.page.reload() @@ -64,7 +206,7 @@ export class CodeServer { async isConnected() { await this.page.waitForLoadState("networkidle") - const host = new URL(CODE_SERVER_ADDRESS).host + const host = new URL(await this.codeServer.address()).host const hostSelector = `[title="Editing on ${host}"]` await this.page.waitForSelector(hostSelector) @@ -104,13 +246,16 @@ export class CodeServer { } /** - * Navigates to CODE_SERVER_ADDRESS - * and reloads until the editor is ready + * Navigates to code-server then reloads until the editor is ready. * - * Helpful for running before tests + * It is recommended to run setup before using this model in any tests. */ - async setup() { + async setup(authenticated: boolean) { await this.navigate() - await this.reloadUntilEditorIsReady() + // 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() + } } } diff --git a/test/e2e/openHelpAbout.test.ts b/test/e2e/openHelpAbout.test.ts index 5c48346e..47daaaf1 100644 --- a/test/e2e/openHelpAbout.test.ts +++ b/test/e2e/openHelpAbout.test.ts @@ -1,11 +1,6 @@ -import { storageState } from "../utils/constants" -import { test, expect } from "./baseFixture" - -test.describe("Open Help > About", () => { - test.use({ - storageState, - }) +import { describe, test, expect } from "./baseFixture" +describe("Open Help > About", true, () => { test("should see a 'Help' then 'About' button in the Application Menu that opens a dialog", async ({ codeServerPage, }) => { diff --git a/test/e2e/terminal.test.ts b/test/e2e/terminal.test.ts index e682f464..836583a8 100644 --- a/test/e2e/terminal.test.ts +++ b/test/e2e/terminal.test.ts @@ -2,11 +2,10 @@ import * as cp from "child_process" import * as fs from "fs" import * as path from "path" import util from "util" -import { storageState } from "../utils/constants" import { tmpdir } from "../utils/helpers" -import { expect, test } from "./baseFixture" +import { describe, expect, test } from "./baseFixture" -test.describe("Integrated Terminal", () => { +describe("Integrated Terminal", true, () => { // Create a new context with the saved storage state // so we don't have to logged in const testFileName = "pipe" @@ -14,10 +13,6 @@ test.describe("Integrated Terminal", () => { let tmpFolderPath = "" let tmpFile = "" - test.use({ - storageState, - }) - test.beforeAll(async () => { tmpFolderPath = await tmpdir("integrated-terminal") tmpFile = path.join(tmpFolderPath, testFileName) diff --git a/test/playwright.config.ts b/test/playwright.config.ts index c3635587..679dd33f 100644 --- a/test/playwright.config.ts +++ b/test/playwright.config.ts @@ -6,14 +6,13 @@ import path from "path" const config: PlaywrightTestConfig = { testDir: path.join(__dirname, "e2e"), // Search for tests in this directory. timeout: 60000, // Each test is given 60 seconds. - retries: 3, // Retry failing tests 2 times - workers: 1, + retries: process.env.CI ? 2 : 1, // Retry in CI due to flakiness. globalSetup: require.resolve("./utils/globalSetup.ts"), reporter: "list", // Put any shared options on the top level. use: { headless: true, // Run tests in headless browsers. - video: "on", + video: "retain-on-failure", }, projects: [ @@ -34,10 +33,4 @@ const config: PlaywrightTestConfig = { ], } -if (process.env.CI) { - // In CI, retry failing tests 2 times - // in the event of flakiness - config.retries = 2 -} - export default config diff --git a/test/unit/node/util.test.ts b/test/unit/node/util.test.ts index a0679177..38534c22 100644 --- a/test/unit/node/util.test.ts +++ b/test/unit/node/util.test.ts @@ -1,14 +1,6 @@ -import { - hash, - isHashMatch, - handlePasswordValidation, - PasswordMethod, - getPasswordMethod, - hashLegacy, - isHashLegacyMatch, - isCookieValid, - sanitizeString, -} from "../../../src/node/util" +import * as cp from "child_process" +import { generateUuid } from "../../../src/common/util" +import * as util from "../../../src/node/util" describe("getEnvPaths", () => { describe("on darwin", () => { @@ -161,7 +153,7 @@ describe("getEnvPaths", () => { describe("hash", () => { it("should return a hash of the string passed in", async () => { const plainTextPassword = "mySecretPassword123" - const hashed = await hash(plainTextPassword) + const hashed = await util.hash(plainTextPassword) expect(hashed).not.toBe(plainTextPassword) }) }) @@ -169,32 +161,32 @@ describe("hash", () => { describe("isHashMatch", () => { it("should return true if the password matches the hash", async () => { const password = "codeserver1234" - const _hash = await hash(password) - const actual = await isHashMatch(password, _hash) + const _hash = await util.hash(password) + const actual = await util.isHashMatch(password, _hash) expect(actual).toBe(true) }) it("should return false if the password does not match the hash", async () => { const password = "password123" - const _hash = await hash(password) - const actual = await isHashMatch("otherPassword123", _hash) + const _hash = await util.hash(password) + const actual = await util.isHashMatch("otherPassword123", _hash) expect(actual).toBe(false) }) it("should return true with actual hash", async () => { const password = "password123" const _hash = "$argon2i$v=19$m=4096,t=3,p=1$EAoczTxVki21JDfIZpTUxg$rkXgyrW4RDGoDYrxBFD4H2DlSMEhP4h+Api1hXnGnFY" - const actual = await isHashMatch(password, _hash) + const actual = await util.isHashMatch(password, _hash) expect(actual).toBe(true) }) it("should return false if the password is empty", async () => { const password = "" const _hash = "$argon2i$v=19$m=4096,t=3,p=1$EAoczTxVki21JDfIZpTUxg$rkXgyrW4RDGoDYrxBFD4H2DlSMEhP4h+Api1hXnGnFY" - const actual = await isHashMatch(password, _hash) + const actual = await util.isHashMatch(password, _hash) expect(actual).toBe(false) }) it("should return false if the hash is empty", async () => { const password = "hellowpasssword" const _hash = "" - const actual = await isHashMatch(password, _hash) + const actual = await util.isHashMatch(password, _hash) expect(actual).toBe(false) }) }) @@ -202,7 +194,7 @@ describe("isHashMatch", () => { describe("hashLegacy", () => { it("should return a hash of the string passed in", () => { const plainTextPassword = "mySecretPassword123" - const hashed = hashLegacy(plainTextPassword) + const hashed = util.hashLegacy(plainTextPassword) expect(hashed).not.toBe(plainTextPassword) }) }) @@ -210,40 +202,40 @@ describe("hashLegacy", () => { describe("isHashLegacyMatch", () => { it("should return true if is match", () => { const password = "password123" - const _hash = hashLegacy(password) - expect(isHashLegacyMatch(password, _hash)).toBe(true) + const _hash = util.hashLegacy(password) + expect(util.isHashLegacyMatch(password, _hash)).toBe(true) }) it("should return false if is match", () => { const password = "password123" - const _hash = hashLegacy(password) - expect(isHashLegacyMatch("otherPassword123", _hash)).toBe(false) + const _hash = util.hashLegacy(password) + expect(util.isHashLegacyMatch("otherPassword123", _hash)).toBe(false) }) it("should return true if hashed from command line", () => { const password = "password123" // Hashed using printf "password123" | sha256sum | cut -d' ' -f1 const _hash = "ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f" - expect(isHashLegacyMatch(password, _hash)).toBe(true) + expect(util.isHashLegacyMatch(password, _hash)).toBe(true) }) }) describe("getPasswordMethod", () => { it("should return PLAIN_TEXT for no hashed password", () => { const hashedPassword = undefined - const passwordMethod = getPasswordMethod(hashedPassword) - const expected: PasswordMethod = "PLAIN_TEXT" + const passwordMethod = util.getPasswordMethod(hashedPassword) + const expected: util.PasswordMethod = "PLAIN_TEXT" expect(passwordMethod).toEqual(expected) }) it("should return ARGON2 for password with 'argon2'", () => { const hashedPassword = "$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY" - const passwordMethod = getPasswordMethod(hashedPassword) - const expected: PasswordMethod = "ARGON2" + const passwordMethod = util.getPasswordMethod(hashedPassword) + const expected: util.PasswordMethod = "ARGON2" expect(passwordMethod).toEqual(expected) }) it("should return SHA256 for password with legacy hash", () => { const hashedPassword = "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af" - const passwordMethod = getPasswordMethod(hashedPassword) - const expected: PasswordMethod = "SHA256" + const passwordMethod = util.getPasswordMethod(hashedPassword) + const expected: util.PasswordMethod = "SHA256" expect(passwordMethod).toEqual(expected) }) }) @@ -251,63 +243,63 @@ describe("getPasswordMethod", () => { describe("handlePasswordValidation", () => { it("should return true with a hashedPassword for a PLAIN_TEXT password", async () => { const p = "password" - const passwordValidation = await handlePasswordValidation({ + const passwordValidation = await util.handlePasswordValidation({ passwordMethod: "PLAIN_TEXT", passwordFromRequestBody: p, passwordFromArgs: p, hashedPasswordFromArgs: undefined, }) - const matchesHash = await isHashMatch(p, passwordValidation.hashedPassword) + const matchesHash = await util.isHashMatch(p, passwordValidation.hashedPassword) expect(passwordValidation.isPasswordValid).toBe(true) expect(matchesHash).toBe(true) }) it("should return false when PLAIN_TEXT password doesn't match args", async () => { const p = "password" - const passwordValidation = await handlePasswordValidation({ + const passwordValidation = await util.handlePasswordValidation({ passwordMethod: "PLAIN_TEXT", passwordFromRequestBody: "password1", passwordFromArgs: p, hashedPasswordFromArgs: undefined, }) - const matchesHash = await isHashMatch(p, passwordValidation.hashedPassword) + const matchesHash = await util.isHashMatch(p, passwordValidation.hashedPassword) expect(passwordValidation.isPasswordValid).toBe(false) expect(matchesHash).toBe(false) }) it("should return true with a hashedPassword for a SHA256 password", async () => { const p = "helloworld" - const passwordValidation = await handlePasswordValidation({ + const passwordValidation = await util.handlePasswordValidation({ passwordMethod: "SHA256", passwordFromRequestBody: p, passwordFromArgs: undefined, hashedPasswordFromArgs: "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af", }) - const matchesHash = isHashLegacyMatch(p, passwordValidation.hashedPassword) + const matchesHash = util.isHashLegacyMatch(p, passwordValidation.hashedPassword) expect(passwordValidation.isPasswordValid).toBe(true) expect(matchesHash).toBe(true) }) it("should return false when SHA256 password doesn't match hash", async () => { const p = "helloworld1" - const passwordValidation = await handlePasswordValidation({ + const passwordValidation = await util.handlePasswordValidation({ passwordMethod: "SHA256", passwordFromRequestBody: p, passwordFromArgs: undefined, hashedPasswordFromArgs: "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af", }) - const matchesHash = isHashLegacyMatch(p, passwordValidation.hashedPassword) + const matchesHash = util.isHashLegacyMatch(p, passwordValidation.hashedPassword) expect(passwordValidation.isPasswordValid).toBe(false) expect(matchesHash).toBe(false) }) it("should return true with a hashedPassword for a ARGON2 password", async () => { const p = "password" - const passwordValidation = await handlePasswordValidation({ + const passwordValidation = await util.handlePasswordValidation({ passwordMethod: "ARGON2", passwordFromRequestBody: p, passwordFromArgs: undefined, @@ -315,14 +307,14 @@ describe("handlePasswordValidation", () => { "$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY", }) - const matchesHash = await isHashMatch(p, passwordValidation.hashedPassword) + const matchesHash = await util.isHashMatch(p, passwordValidation.hashedPassword) expect(passwordValidation.isPasswordValid).toBe(true) expect(matchesHash).toBe(true) }) it("should return false when ARGON2 password doesn't match hash", async () => { const p = "password1" - const passwordValidation = await handlePasswordValidation({ + const passwordValidation = await util.handlePasswordValidation({ passwordMethod: "ARGON2", passwordFromRequestBody: p, passwordFromArgs: undefined, @@ -330,7 +322,7 @@ describe("handlePasswordValidation", () => { "$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY", }) - const matchesHash = await isHashMatch(p, passwordValidation.hashedPassword) + const matchesHash = await util.isHashMatch(p, passwordValidation.hashedPassword) expect(passwordValidation.isPasswordValid).toBe(false) expect(matchesHash).toBe(false) @@ -339,7 +331,7 @@ describe("handlePasswordValidation", () => { describe("isCookieValid", () => { it("should be valid if hashed-password for SHA256 matches cookie.key", async () => { - const isValid = await isCookieValid({ + const isValid = await util.isCookieValid({ passwordMethod: "SHA256", cookieKey: "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af", hashedPasswordFromArgs: "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af", @@ -348,7 +340,7 @@ describe("isCookieValid", () => { expect(isValid).toBe(true) }) it("should be invalid if hashed-password for SHA256 does not match cookie.key", async () => { - const isValid = await isCookieValid({ + const isValid = await util.isCookieValid({ passwordMethod: "SHA256", cookieKey: "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb9442bb6f8f8f07af", hashedPasswordFromArgs: "936a185caaa266bb9cbe981e9e05cb78cd732b0b3280eb944412bb6f8f8f07af", @@ -357,7 +349,7 @@ describe("isCookieValid", () => { expect(isValid).toBe(false) }) it("should be valid if hashed-password for ARGON2 matches cookie.key", async () => { - const isValid = await isCookieValid({ + const isValid = await util.isCookieValid({ passwordMethod: "ARGON2", cookieKey: "$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY", hashedPasswordFromArgs: @@ -367,7 +359,7 @@ describe("isCookieValid", () => { expect(isValid).toBe(true) }) it("should be invalid if hashed-password for ARGON2 does not match cookie.key", async () => { - const isValid = await isCookieValid({ + const isValid = await util.isCookieValid({ passwordMethod: "ARGON2", cookieKey: "$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9H", hashedPasswordFromArgs: @@ -377,7 +369,7 @@ describe("isCookieValid", () => { expect(isValid).toBe(false) }) it("should be valid if password for PLAIN_TEXT matches cookie.key", async () => { - const isValid = await isCookieValid({ + const isValid = await util.isCookieValid({ passwordMethod: "PLAIN_TEXT", cookieKey: "$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9HYY", passwordFromArgs: "password", @@ -386,7 +378,7 @@ describe("isCookieValid", () => { expect(isValid).toBe(true) }) it("should be invalid if hashed-password for PLAIN_TEXT does not match cookie.key", async () => { - const isValid = await isCookieValid({ + const isValid = await util.isCookieValid({ passwordMethod: "PLAIN_TEXT", cookieKey: "$argon2i$v=19$m=4096,t=3,p=1$0qR/o+0t00hsbJFQCKSfdQ$oFcM4rL6o+B7oxpuA4qlXubypbBPsf+8L531U7P9H", passwordFromArgs: "password1234", @@ -398,12 +390,47 @@ describe("isCookieValid", () => { describe("sanitizeString", () => { it("should return an empty string if passed a type other than a string", () => { - expect(sanitizeString({} as string)).toBe("") + expect(util.sanitizeString({} as string)).toBe("") }) it("should trim whitespace", () => { - expect(sanitizeString(" hello ")).toBe("hello") + expect(util.sanitizeString(" hello ")).toBe("hello") }) it("should always return an empty string", () => { - expect(sanitizeString(" ")).toBe("") + expect(util.sanitizeString(" ")).toBe("") + }) +}) + +describe("onLine", () => { + // Spawn a process that outputs anything given on stdin. + let proc: cp.ChildProcess | undefined + + beforeAll(() => { + proc = cp.spawn("node", ["-e", 'process.stdin.setEncoding("utf8");process.stdin.on("data", console.log)']) + }) + + afterAll(() => { + proc?.kill() + }) + + it("should call with individual lines", async () => { + const size = 100 + const received = new Promise((resolve) => { + const lines: string[] = [] + util.onLine(proc!, (line) => { + lines.push(line) + if (lines.length === size) { + resolve(lines) + } + }) + }) + + const expected: string[] = [] + for (let i = 0; i < size; ++i) { + expected.push(generateUuid(i)) + } + + proc?.stdin?.write(expected.join("\n")) + + expect(await received).toEqual(expected) }) }) diff --git a/test/unit/util.test.ts b/test/unit/util.test.ts index e63fcde5..6a60e153 100644 --- a/test/unit/util.test.ts +++ b/test/unit/util.test.ts @@ -1,16 +1,5 @@ import { JSDOM } from "jsdom" -import { - arrayify, - generateUuid, - getFirstString, - getOptions, - logError, - plural, - resolveBase, - split, - trimSlashes, - normalize, -} from "../../src/common/util" +import * as util from "../../src/common/util" import { createLoggerMock } from "../utils/helpers" const dom = new JSDOM() @@ -21,67 +10,67 @@ export type LocationLike = Pick describe("util", () => { describe("normalize", () => { it("should remove multiple slashes", () => { - expect(normalize("//foo//bar//baz///mumble")).toBe("/foo/bar/baz/mumble") + expect(util.normalize("//foo//bar//baz///mumble")).toBe("/foo/bar/baz/mumble") }) it("should remove trailing slashes", () => { - expect(normalize("qux///")).toBe("qux") + expect(util.normalize("qux///")).toBe("qux") }) it("should preserve trailing slash if it exists", () => { - expect(normalize("qux///", true)).toBe("qux/") - expect(normalize("qux", true)).toBe("qux") + expect(util.normalize("qux///", true)).toBe("qux/") + expect(util.normalize("qux", true)).toBe("qux") }) }) describe("split", () => { it("should split at a comma", () => { - expect(split("Hello,world", ",")).toStrictEqual(["Hello", "world"]) + expect(util.split("Hello,world", ",")).toStrictEqual(["Hello", "world"]) }) it("shouldn't split if the delimiter doesn't exist", () => { - expect(split("Hello world", ",")).toStrictEqual(["Hello world", ""]) + expect(util.split("Hello world", ",")).toStrictEqual(["Hello world", ""]) }) }) describe("plural", () => { it("should add an s if count is greater than 1", () => { - expect(plural(2, "dog")).toBe("dogs") + expect(util.plural(2, "dog")).toBe("dogs") }) it("should NOT add an s if the count is 1", () => { - expect(plural(1, "dog")).toBe("dog") + expect(util.plural(1, "dog")).toBe("dog") }) }) describe("generateUuid", () => { it("should generate a unique uuid", () => { - const uuid = generateUuid() - const uuid2 = generateUuid() + const uuid = util.generateUuid() + const uuid2 = util.generateUuid() expect(uuid).toHaveLength(24) expect(typeof uuid).toBe("string") expect(uuid).not.toBe(uuid2) }) it("should generate a uuid of a specific length", () => { - const uuid = generateUuid(10) + const uuid = util.generateUuid(10) expect(uuid).toHaveLength(10) }) }) describe("trimSlashes", () => { it("should remove leading slashes", () => { - expect(trimSlashes("/hello-world")).toBe("hello-world") + expect(util.trimSlashes("/hello-world")).toBe("hello-world") }) it("should remove trailing slashes", () => { - expect(trimSlashes("hello-world/")).toBe("hello-world") + expect(util.trimSlashes("hello-world/")).toBe("hello-world") }) it("should remove both leading and trailing slashes", () => { - expect(trimSlashes("/hello-world/")).toBe("hello-world") + expect(util.trimSlashes("/hello-world/")).toBe("hello-world") }) it("should remove multiple leading and trailing slashes", () => { - expect(trimSlashes("///hello-world////")).toBe("hello-world") + expect(util.trimSlashes("///hello-world////")).toBe("hello-world") }) }) @@ -101,23 +90,23 @@ describe("util", () => { }) it("should resolve a base", () => { - expect(resolveBase("localhost:8080")).toBe("/localhost:8080") + expect(util.resolveBase("localhost:8080")).toBe("/localhost:8080") }) it("should resolve a base with a forward slash at the beginning", () => { - expect(resolveBase("/localhost:8080")).toBe("/localhost:8080") + expect(util.resolveBase("/localhost:8080")).toBe("/localhost:8080") }) it("should resolve a base with query params", () => { - expect(resolveBase("localhost:8080?folder=hello-world")).toBe("/localhost:8080") + expect(util.resolveBase("localhost:8080?folder=hello-world")).toBe("/localhost:8080") }) it("should resolve a base with a path", () => { - expect(resolveBase("localhost:8080/hello/world")).toBe("/localhost:8080/hello/world") + expect(util.resolveBase("localhost:8080/hello/world")).toBe("/localhost:8080/hello/world") }) it("should resolve a base to an empty string when not provided", () => { - expect(resolveBase()).toBe("") + expect(util.resolveBase()).toBe("") }) }) @@ -142,7 +131,7 @@ describe("util", () => { }) it("should return options with base and cssStaticBase even if it doesn't exist", () => { - expect(getOptions()).toStrictEqual({ + expect(util.getOptions()).toStrictEqual({ base: "", csStaticBase: "", }) @@ -162,7 +151,7 @@ describe("util", () => { // it returns the element spy.mockImplementation(() => mockElement) - expect(getOptions()).toStrictEqual({ + expect(util.getOptions()).toStrictEqual({ base: "", csStaticBase: "/static/development/Users/jp/Dev/code-server", disableTelemetry: false, @@ -179,7 +168,7 @@ describe("util", () => { // spreads the original options // then parses the queryOpts location.search = '?options={"logLevel":2}' - expect(getOptions()).toStrictEqual({ + expect(util.getOptions()).toStrictEqual({ base: "", csStaticBase: "", logLevel: 2, @@ -189,12 +178,12 @@ describe("util", () => { describe("arrayify", () => { it("should return value it's already an array", () => { - expect(arrayify(["hello", "world"])).toStrictEqual(["hello", "world"]) + expect(util.arrayify(["hello", "world"])).toStrictEqual(["hello", "world"]) }) it("should wrap the value in an array if not an array", () => { expect( - arrayify({ + util.arrayify({ name: "Coder", version: "3.8", }), @@ -202,21 +191,21 @@ describe("util", () => { }) it("should return an empty array if the value is undefined", () => { - expect(arrayify(undefined)).toStrictEqual([]) + expect(util.arrayify(undefined)).toStrictEqual([]) }) }) describe("getFirstString", () => { it("should return the string if passed a string", () => { - expect(getFirstString("Hello world!")).toBe("Hello world!") + expect(util.getFirstString("Hello world!")).toBe("Hello world!") }) it("should get the first string from an array", () => { - expect(getFirstString(["Hello", "World"])).toBe("Hello") + expect(util.getFirstString(["Hello", "World"])).toBe("Hello") }) it("should return undefined if the value isn't an array or a string", () => { - expect(getFirstString({ name: "Coder" })).toBe(undefined) + expect(util.getFirstString({ name: "Coder" })).toBe(undefined) }) }) @@ -235,14 +224,14 @@ describe("util", () => { const message = "You don't have access to that folder." const error = new Error(message) - logError(loggerModule.logger, "ui", error) + util.logError(loggerModule.logger, "ui", error) expect(loggerModule.logger.error).toHaveBeenCalled() expect(loggerModule.logger.error).toHaveBeenCalledWith(`ui: ${error.message} ${error.stack}`) }) it("should log an error, even if not an instance of error", () => { - logError(loggerModule.logger, "api", "oh no") + util.logError(loggerModule.logger, "api", "oh no") expect(loggerModule.logger.error).toHaveBeenCalled() expect(loggerModule.logger.error).toHaveBeenCalledWith("api: oh no") diff --git a/test/utils/constants.ts b/test/utils/constants.ts index e927d659..9b5c2b6e 100644 --- a/test/utils/constants.ts +++ b/test/utils/constants.ts @@ -1,3 +1,2 @@ -export const CODE_SERVER_ADDRESS = process.env.CODE_SERVER_ADDRESS || "http://localhost:8080" -export const PASSWORD = process.env.PASSWORD || "e45432jklfdsab" -export const storageState = JSON.parse(process.env.STORAGE || "{}") +export const PASSWORD = "e45432jklfdsab" +export const workspaceDir = "workspaces" diff --git a/test/utils/globalSetup.ts b/test/utils/globalSetup.ts index d5fd4660..eace7f9b 100644 --- a/test/utils/globalSetup.ts +++ b/test/utils/globalSetup.ts @@ -1,40 +1,43 @@ -// 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 { Cookie } from "playwright" import { hash } from "../../src/node/util" -import { PASSWORD } from "./constants" +import { PASSWORD, workspaceDir } from "./constants" +import { clean } from "./helpers" import * as wtfnode from "./wtfnode" +/** + * Perform workspace cleanup and authenticate. This should be set up to run + * before our tests execute. + */ export default async function () { console.log("\n🚨 Running Global Setup for Playwright End-to-End Tests") console.log(" Please hang tight...") - const cookieToStore = { - sameSite: "Lax" as const, - name: "key", - value: await hash(PASSWORD), - domain: "localhost", - path: "/", - expires: -1, - httpOnly: false, - secure: false, - } - - const browser = await chromium.launch() - const page = await browser.newPage() - const storage = await page.context().storageState() + // Cleanup workspaces from previous tests. + await clean(workspaceDir) if (process.env.WTF_NODE) { wtfnode.setup() } - storage.cookies = [cookieToStore] + // TODO: Replace this with a call to code-server to get the cookie. To avoid + // too much overhead we can do an http POST request and avoid spawning a + // browser for it. + const cookies: Cookie[] = [ + { + domain: "localhost", + expires: -1, + httpOnly: false, + name: "key", + path: "/", + sameSite: "Lax", + secure: false, + value: await hash(PASSWORD), + }, + ] // Save storage state and store as an env variable // More info: https://playwright.dev/docs/auth/#reuse-authentication-state - process.env.STORAGE = JSON.stringify(storage) - await browser.close() + process.env.STORAGE = JSON.stringify({ cookies }) console.log("✅ Global Setup for Playwright End-to-End Tests is now complete.") } diff --git a/test/utils/helpers.ts b/test/utils/helpers.ts index f31752b8..a6a8d451 100644 --- a/test/utils/helpers.ts +++ b/test/utils/helpers.ts @@ -1,4 +1,4 @@ -import * as fs from "fs" +import { promises as fs } from "fs" import * as os from "os" import * as path from "path" @@ -20,13 +20,20 @@ export function createLoggerMock() { } /** - * Create a uniquely named temporary directory. - * - * These directories are placed under a single temporary code-server directory. + * Clean up directories left by a test. It is recommended to do this when a test + * starts to avoid potentially accumulating infinite test directories. + */ +export async function clean(testName: string): Promise { + const dir = path.join(os.tmpdir(), `code-server/tests/${testName}`) + await fs.rmdir(dir, { recursive: true }) +} + +/** + * Create a uniquely named temporary directory for a test. */ export async function tmpdir(testName: string): Promise { - const dir = path.join(os.tmpdir(), "code-server/tests") - await fs.promises.mkdir(dir, { recursive: true }) + const dir = path.join(os.tmpdir(), `code-server/tests/${testName}`) + await fs.mkdir(dir, { recursive: true }) - return await fs.promises.mkdtemp(path.join(dir, `${testName}-`), { encoding: "utf8" }) + return await fs.mkdtemp(path.join(dir, `${testName}-`), { encoding: "utf8" }) }