From c4c480a06880263642e0afdde3c5e27f29f32f2d Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 17 Dec 2021 13:06:52 -0600 Subject: [PATCH] Implement last opened functionality (#4633) * Implement last opened functionality Fixes https://github.com/cdr/code-server/issues/4619 * Fix test temp dirs not being cleaned up * Mock logger everywhere This suppresses all the error and debug output we generate which makes it hard to actually find which test has failed. It also gives us a standard way to test logging for the few places we do that. * Use separate data directories for unit test instances Exactly as we do for the e2e tests. * Add integration tests for vscode route * Make settings use --user-data-dir Without this test instances step on each other feet and they also clobber your own non-test settings. * Make redirects consistent They will preserve the trailing slash if there is one. * Remove compilation check If you do a regular non-watch build there are no compilation stats so this bricks VS Code in CI when running the unit tests. I am not sure how best to fix this for the case where you have a build that has not been packaged yet so I just removed it for now and added a message to check if VS Code is compiling when in dev mode. * Update code-server update endpoint name --- CHANGELOG.md | 2 + ci/dev/watch.ts | 18 +- package.json | 3 +- src/node/http.ts | 15 +- src/node/routes/domainProxy.ts | 5 +- src/node/routes/index.ts | 7 + src/node/routes/pathProxy.ts | 5 +- src/node/routes/update.ts | 7 +- src/node/routes/vscode.ts | 55 ++++-- src/node/settings.ts | 13 +- src/node/update.ts | 9 +- src/node/util.ts | 36 +--- test/e2e/terminal.test.ts | 17 +- test/playwright.config.ts | 2 +- test/unit/common/emitter.test.ts | 18 +- test/unit/common/util.test.ts | 25 ++- test/unit/helpers.test.ts | 8 +- test/unit/node/__snapshots__/app.test.ts.snap | 3 - test/unit/node/app.test.ts | 64 +++---- test/unit/node/cli.test.ts | 29 ++-- test/unit/node/constants.test.ts | 10 +- test/unit/node/proxy_agent.test.ts | 6 +- test/unit/node/routes/login.test.ts | 5 + test/unit/node/routes/static.test.ts | 6 +- test/unit/node/routes/vscode.test.ts | 158 ++++++++++++++++++ test/unit/node/update.test.ts | 49 ++++-- .../{globalSetup.ts => globalE2eSetup.ts} | 4 +- test/utils/globalUnitSetup.ts | 9 + test/utils/helpers.ts | 31 ++-- test/utils/httpserver.ts | 8 +- test/utils/integration.ts | 20 ++- 31 files changed, 406 insertions(+), 241 deletions(-) delete mode 100644 test/unit/node/__snapshots__/app.test.ts.snap create mode 100644 test/unit/node/routes/vscode.test.ts rename test/utils/{globalSetup.ts => globalE2eSetup.ts} (92%) create mode 100644 test/utils/globalUnitSetup.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bbb557e4..332c9ff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ implementation (#4414). vscode-remote-resource endpoint still can. - OpenVSX has been made the default marketplace. However this means web extensions like Vim may be broken. +- The last opened folder/workspace is no longer stored separately in the + settings file (we rely on the already-existing query object instead). ### Deprecated diff --git a/ci/dev/watch.ts b/ci/dev/watch.ts index 8ed59c42..55a5b14d 100644 --- a/ci/dev/watch.ts +++ b/ci/dev/watch.ts @@ -1,7 +1,6 @@ import { spawn, fork, ChildProcess } from "child_process" -import { promises as fs } from "fs" import * as path from "path" -import { CompilationStats, onLine, OnLineCallback } from "../../src/node/util" +import { onLine, OnLineCallback } from "../../src/node/util" interface DevelopmentCompilers { [key: string]: ChildProcess | undefined @@ -16,7 +15,6 @@ class Watcher { private readonly paths = { /** Path to uncompiled VS Code source. */ vscodeDir: path.join(this.rootPath, "vendor", "modules", "code-oss-dev"), - compilationStatsFile: path.join(this.rootPath, "out", "watcher.json"), pluginDir: process.env.PLUGIN_DIR, } @@ -88,7 +86,6 @@ class Watcher { if (strippedLine.includes("Finished compilation with")) { console.log("[VS Code] ✨ Finished compiling! ✨", "(Refresh your web browser ♻️)") - this.emitCompilationStats() this.reloadWebServer() } } @@ -118,19 +115,6 @@ class Watcher { //#region Utilities - /** - * Emits a file containing compilation data. - * This is especially useful when Express needs to determine if VS Code is still compiling. - */ - private emitCompilationStats(): Promise { - const stats: CompilationStats = { - lastCompiledAt: new Date(), - } - - console.log("Writing watcher stats...") - return fs.writeFile(this.paths.compilationStatsFile, JSON.stringify(stats, null, 2)) - } - private dispose(code: number | null): void { for (const [processName, devProcess] of Object.entries(this.compilers)) { console.log(`[${processName}]`, "Killing...\n") diff --git a/package.json b/package.json index d4f05c5c..f24b986a 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,7 @@ ], "moduleNameMapper": { "^.+\\.(css|less)$": "/test/utils/cssStub.ts" - } + }, + "globalSetup": "/test/utils/globalUnitSetup.ts" } } diff --git a/src/node/http.ts b/src/node/http.ts index ca226908..dbd72d84 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -10,6 +10,8 @@ import { normalize } from "../common/util" import { AuthType, DefaultedArgs } from "./cli" import { version as codeServerVersion } from "./constants" import { Heart } from "./heart" +import { CoderSettings, SettingsProvider } from "./settings" +import { UpdateProvider } from "./update" import { getPasswordMethod, IsCookieValidArgs, isCookieValid, sanitizeString, escapeHtml, escapeJSON } from "./util" /** @@ -29,6 +31,8 @@ declare global { export interface Request { args: DefaultedArgs heart: Heart + settings: SettingsProvider + updater: UpdateProvider } } } @@ -135,8 +139,8 @@ export const relativeRoot = (originalUrl: string): string => { } /** - * Redirect relatively to `/${to}`. Query variables on the current URI will be preserved. - * `to` should be a simple path without any query parameters + * Redirect relatively to `/${to}`. Query variables on the current URI will be + * preserved. `to` should be a simple path without any query parameters * `override` will merge with the existing query (use `undefined` to unset). */ export const redirect = ( @@ -284,3 +288,10 @@ export const getCookieOptions = (req: express.Request): express.CookieOptions => sameSite: "lax", } } + +/** + * Return the full path to the current page, preserving any trailing slash. + */ +export const self = (req: express.Request): string => { + return normalize(`${req.baseUrl}${req.originalUrl.endsWith("/") ? "/" : ""}`, true) +} diff --git a/src/node/routes/domainProxy.ts b/src/node/routes/domainProxy.ts index 56b0ea1b..83194b8c 100644 --- a/src/node/routes/domainProxy.ts +++ b/src/node/routes/domainProxy.ts @@ -1,7 +1,6 @@ import { Request, Router } from "express" import { HttpCode, HttpError } from "../../common/http" -import { normalize } from "../../common/util" -import { authenticated, ensureAuthenticated, redirect } from "../http" +import { authenticated, ensureAuthenticated, redirect, self } from "../http" import { proxy } from "../proxy" import { Router as WsRouter } from "../wsRouter" @@ -56,7 +55,7 @@ router.all("*", async (req, res, next) => { return next() } // Redirect all other pages to the login. - const to = normalize(`${req.baseUrl}${req.path}`) + const to = self(req) return redirect(req, res, "login", { to: to !== "/" ? to : undefined, }) diff --git a/src/node/routes/index.ts b/src/node/routes/index.ts index 5658e1df..ff85f036 100644 --- a/src/node/routes/index.ts +++ b/src/node/routes/index.ts @@ -14,6 +14,8 @@ import { commit, rootPath } from "../constants" import { Heart } from "../heart" import { ensureAuthenticated, redirect } from "../http" import { PluginAPI } from "../plugin" +import { CoderSettings, SettingsProvider } from "../settings" +import { UpdateProvider } from "../update" import { getMediaMime, paths } from "../util" import * as apps from "./apps" import * as domainProxy from "./domainProxy" @@ -47,6 +49,9 @@ export const register = async (app: App, args: DefaultedArgs): Promise(path.join(args["user-data-dir"], "coder.json")) + const updater = new UpdateProvider("https://api.github.com/repos/coder/code-server/releases/latest", settings) + const common: express.RequestHandler = (req, _, next) => { // /healthz|/healthz/ needs to be excluded otherwise health checks will make // it look like code-server is always in use. @@ -57,6 +62,8 @@ export const register = async (app: App, args: DefaultedArgs): Promise { @@ -25,7 +24,7 @@ export function proxy( if (!authenticated(req)) { // If visiting the root (/:port only) redirect to the login page. if (!req.params[0] || req.params[0] === "/") { - const to = normalize(`${req.baseUrl}${req.path}`) + const to = self(req) return redirect(req, res, "login", { to: to !== "/" ? to : undefined, }) diff --git a/src/node/routes/update.ts b/src/node/routes/update.ts index 5c9aa181..60d2011e 100644 --- a/src/node/routes/update.ts +++ b/src/node/routes/update.ts @@ -1,18 +1,15 @@ import { Router } from "express" import { version } from "../constants" import { ensureAuthenticated } from "../http" -import { UpdateProvider } from "../update" export const router = Router() -const provider = new UpdateProvider() - router.get("/check", ensureAuthenticated, async (req, res) => { - const update = await provider.getUpdate(req.query.force === "true") + const update = await req.updater.getUpdate(req.query.force === "true") res.json({ checked: update.checked, latest: update.version, current: version, - isLatest: provider.isLatestVersion(update), + isLatest: req.updater.isLatestVersion(update), }) }) diff --git a/src/node/routes/vscode.ts b/src/node/routes/vscode.ts index 4d394c25..963fe660 100644 --- a/src/node/routes/vscode.ts +++ b/src/node/routes/vscode.ts @@ -2,10 +2,10 @@ import { logger } from "@coder/logger" import * as express from "express" import { WebsocketRequest } from "../../../typings/pluginapi" import { logError } from "../../common/util" -import { isDevMode } from "../constants" import { toVsCodeArgs } from "../cli" -import { ensureAuthenticated, authenticated, redirect } from "../http" -import { loadAMDModule, readCompilationStats } from "../util" +import { isDevMode } from "../constants" +import { authenticated, ensureAuthenticated, redirect, self } from "../http" +import { loadAMDModule } from "../util" import { Router as WsRouter } from "../wsRouter" import { errorHandler } from "./errors" @@ -25,12 +25,39 @@ export class CodeServerRouteWrapper { const isAuthenticated = await authenticated(req) if (!isAuthenticated) { + const to = self(req) return redirect(req, res, "login", { - // req.baseUrl can be blank if already at the root. - to: req.baseUrl && req.baseUrl !== "/" ? req.baseUrl : undefined, + to: to !== "/" ? to : undefined, }) } + const { query } = await req.settings.read() + if (query) { + // Ew means the workspace was closed so clear the last folder/workspace. + if (req.query.ew) { + delete query.folder + delete query.workspace + } + + // Redirect to the last folder/workspace if nothing else is opened. + if ( + !req.query.folder && + !req.query.workspace && + (query.folder || query.workspace) && + !req.args["ignore-last-opened"] // This flag disables this behavior. + ) { + const to = self(req) + return redirect(req, res, to, { + folder: query.folder, + workspace: query.workspace, + }) + } + } + + // Store the query parameters so we can use them on the next load. This + // also allows users to create functionality around query parameters. + await req.settings.write({ query: req.query }) + next() } @@ -66,15 +93,6 @@ export class CodeServerRouteWrapper { return next() } - if (isDevMode) { - // Is the development mode file watcher still busy? - const compileStats = await readCompilationStats() - - if (!compileStats || !compileStats.lastCompiledAt) { - return next(new Error("VS Code may still be compiling...")) - } - } - // Create the server... const { args } = req @@ -89,9 +107,12 @@ export class CodeServerRouteWrapper { try { this._codeServerMain = await createVSServer(null, await toVsCodeArgs(args)) - } catch (createServerError) { - logError(logger, "CodeServerRouteWrapper", createServerError) - return next(createServerError) + } catch (error) { + logError(logger, "CodeServerRouteWrapper", error) + if (isDevMode) { + return next(new Error((error instanceof Error ? error.message : error) + " (VS Code may still be compiling)")) + } + return next(error) } return next() diff --git a/src/node/settings.ts b/src/node/settings.ts index 4cce755a..709ce950 100644 --- a/src/node/settings.ts +++ b/src/node/settings.ts @@ -1,8 +1,6 @@ import { logger } from "@coder/logger" import { Query } from "express-serve-static-core" import { promises as fs } from "fs" -import * as path from "path" -import { paths } from "./util" export type Settings = { [key: string]: Settings | string | boolean | number } @@ -54,14 +52,5 @@ export interface UpdateSettings { * Global code-server settings. */ export interface CoderSettings extends UpdateSettings { - lastVisited: { - url: string - workspace: boolean - } - query: Query + query?: Query } - -/** - * Global code-server settings file. - */ -export const settings = new SettingsProvider(path.join(paths.data, "coder.json")) diff --git a/src/node/update.ts b/src/node/update.ts index e9679fa4..03d61ed9 100644 --- a/src/node/update.ts +++ b/src/node/update.ts @@ -4,7 +4,7 @@ import * as https from "https" import * as semver from "semver" import * as url from "url" import { version } from "./constants" -import { settings as globalSettings, SettingsProvider, UpdateSettings } from "./settings" +import { SettingsProvider, UpdateSettings } from "./settings" export interface Update { checked: number @@ -27,12 +27,11 @@ export class UpdateProvider { * The URL for getting the latest version of code-server. Should return JSON * that fulfills `LatestResponse`. */ - private readonly latestUrl = "https://api.github.com/repos/cdr/code-server/releases/latest", + private readonly latestUrl: string, /** - * Update information will be stored here. If not provided, the global - * settings will be used. + * Update information will be stored here. */ - private readonly settings: SettingsProvider = globalSettings, + private readonly settings: SettingsProvider, ) {} /** diff --git a/src/node/util.ts b/src/node/util.ts index d33163e2..56ae83e3 100644 --- a/src/node/util.ts +++ b/src/node/util.ts @@ -3,15 +3,14 @@ import * as argon2 from "argon2" import * as cp from "child_process" import * as crypto from "crypto" import envPaths from "env-paths" -import { promises as fs, Stats } from "fs" +import { promises as fs } from "fs" import * as net from "net" import * as os from "os" import * as path from "path" import safeCompare from "safe-compare" import * as util from "util" import xdgBasedir from "xdg-basedir" -import { logError } from "../common/util" -import { isDevMode, rootPath, vsRootPath } from "./constants" +import { vsRootPath } from "./constants" export interface Paths { data: string @@ -523,34 +522,3 @@ export const loadAMDModule = async (amdPath: string, exportName: string): Pro return module[exportName] as T } - -export interface CompilationStats { - lastCompiledAt: Date -} - -export const readCompilationStats = async (): Promise => { - if (!isDevMode) { - throw new Error("Compilation stats are only present in development") - } - - const filePath = path.join(rootPath, "out/watcher.json") - let stat: Stats - try { - stat = await fs.stat(filePath) - } catch (error) { - return null - } - - if (!stat.isFile()) { - return null - } - - try { - const file = await fs.readFile(filePath) - return JSON.parse(file.toString("utf-8")) - } catch (error) { - logError(logger, "VS Code", error) - } - - return null -} diff --git a/test/e2e/terminal.test.ts b/test/e2e/terminal.test.ts index 836583a8..5012fdef 100644 --- a/test/e2e/terminal.test.ts +++ b/test/e2e/terminal.test.ts @@ -1,8 +1,7 @@ import * as cp from "child_process" -import * as fs from "fs" import * as path from "path" import util from "util" -import { tmpdir } from "../utils/helpers" +import { clean, tmpdir } from "../utils/helpers" import { describe, expect, test } from "./baseFixture" describe("Integrated Terminal", true, () => { @@ -10,20 +9,16 @@ describe("Integrated Terminal", true, () => { // so we don't have to logged in const testFileName = "pipe" const testString = "new string test from e2e test" - let tmpFolderPath = "" - let tmpFile = "" + const testName = "integrated-terminal" test.beforeAll(async () => { - tmpFolderPath = await tmpdir("integrated-terminal") - tmpFile = path.join(tmpFolderPath, testFileName) - }) - - test.afterAll(async () => { - // Ensure directory was removed - await fs.promises.rmdir(tmpFolderPath, { recursive: true }) + await clean(testName) }) test("should echo a string to a file", async ({ codeServerPage }) => { + const tmpFolderPath = await tmpdir(testName) + const tmpFile = path.join(tmpFolderPath, testFileName) + const command = `mkfifo '${tmpFile}' && cat '${tmpFile}'` const exec = util.promisify(cp.exec) const output = exec(command, { encoding: "utf8" }) diff --git a/test/playwright.config.ts b/test/playwright.config.ts index 2f77fb9c..c969dabf 100644 --- a/test/playwright.config.ts +++ b/test/playwright.config.ts @@ -12,7 +12,7 @@ const config: PlaywrightTestConfig = { testDir: path.join(__dirname, "e2e"), // Search for tests in this directory. timeout: 60000, // Each test is given 60 seconds. retries: process.env.CI ? 2 : 1, // Retry in CI due to flakiness. - globalSetup: require.resolve("./utils/globalSetup.ts"), + globalSetup: require.resolve("./utils/globalE2eSetup.ts"), reporter: "list", // Put any shared options on the top level. use: { diff --git a/test/unit/common/emitter.test.ts b/test/unit/common/emitter.test.ts index 46a5dddd..cec5fa61 100644 --- a/test/unit/common/emitter.test.ts +++ b/test/unit/common/emitter.test.ts @@ -1,24 +1,16 @@ -// Note: we need to import logger from the root -// because this is the logger used in logError in ../src/common/util import { logger } from "@coder/logger" - import { Emitter } from "../../../src/common/emitter" +import { mockLogger } from "../../utils/helpers" describe("emitter", () => { - let spy: jest.SpyInstance - beforeEach(() => { - spy = jest.spyOn(logger, "error") + mockLogger() }) afterEach(() => { jest.clearAllMocks() }) - afterAll(() => { - jest.restoreAllMocks() - }) - it("should run the correct callbacks", async () => { const HELLO_WORLD = "HELLO_WORLD" const GOODBYE_WORLD = "GOODBYE_WORLD" @@ -85,8 +77,8 @@ describe("emitter", () => { await emitter.emit({ event: HELLO_WORLD, callback: mockCallback }) // Check that error was called - expect(spy).toHaveBeenCalled() - expect(spy).toHaveBeenCalledTimes(1) - expect(spy).toHaveBeenCalledWith(message) + expect(logger.error).toHaveBeenCalled() + expect(logger.error).toHaveBeenCalledTimes(1) + expect(logger.error).toHaveBeenCalledWith(message) }) }) diff --git a/test/unit/common/util.test.ts b/test/unit/common/util.test.ts index 71f42386..eef21921 100644 --- a/test/unit/common/util.test.ts +++ b/test/unit/common/util.test.ts @@ -1,6 +1,7 @@ +import { logger } from "@coder/logger" import { JSDOM } from "jsdom" import * as util from "../../../src/common/util" -import { createLoggerMock } from "../../utils/helpers" +import { mockLogger } from "../../utils/helpers" const dom = new JSDOM() global.document = dom.window.document @@ -94,31 +95,29 @@ describe("util", () => { }) describe("logError", () => { + beforeAll(() => { + mockLogger() + }) + afterEach(() => { jest.clearAllMocks() }) - afterAll(() => { - jest.restoreAllMocks() - }) - - const loggerModule = createLoggerMock() - it("should log an error with the message and stack trace", () => { const message = "You don't have access to that folder." const error = new Error(message) - util.logError(loggerModule.logger, "ui", error) + util.logError(logger, "ui", error) - expect(loggerModule.logger.error).toHaveBeenCalled() - expect(loggerModule.logger.error).toHaveBeenCalledWith(`ui: ${error.message} ${error.stack}`) + expect(logger.error).toHaveBeenCalled() + expect(logger.error).toHaveBeenCalledWith(`ui: ${error.message} ${error.stack}`) }) it("should log an error, even if not an instance of error", () => { - util.logError(loggerModule.logger, "api", "oh no") + util.logError(logger, "api", "oh no") - expect(loggerModule.logger.error).toHaveBeenCalled() - expect(loggerModule.logger.error).toHaveBeenCalledWith("api: oh no") + expect(logger.error).toHaveBeenCalled() + expect(logger.error).toHaveBeenCalledWith("api: oh no") }) }) }) diff --git a/test/unit/helpers.test.ts b/test/unit/helpers.test.ts index e3de12e2..ba3a54d2 100644 --- a/test/unit/helpers.test.ts +++ b/test/unit/helpers.test.ts @@ -1,12 +1,16 @@ import { promises as fs } from "fs" -import { getAvailablePort, tmpdir, useEnv } from "../../test/utils/helpers" +import { clean, getAvailablePort, tmpdir, useEnv } from "../../test/utils/helpers" /** * This file is for testing test helpers (not core code). */ describe("test helpers", () => { + const testName = "temp-dir" + beforeAll(async () => { + await clean(testName) + }) + it("should return a temp directory", async () => { - const testName = "temp-dir" const pathToTempDir = await tmpdir(testName) expect(pathToTempDir).toContain(testName) expect(fs.access(pathToTempDir)).resolves.toStrictEqual(undefined) diff --git a/test/unit/node/__snapshots__/app.test.ts.snap b/test/unit/node/__snapshots__/app.test.ts.snap deleted file mode 100644 index 10a0e6c6..00000000 --- a/test/unit/node/__snapshots__/app.test.ts.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`handleServerError should log an error if resolved is true 1`] = `"Cannot read property 'handle' of undefined"`; diff --git a/test/unit/node/app.test.ts b/test/unit/node/app.test.ts index 99c67cd2..79279ceb 100644 --- a/test/unit/node/app.test.ts +++ b/test/unit/node/app.test.ts @@ -1,27 +1,29 @@ import { logger } from "@coder/logger" -import { promises, rmdirSync } from "fs" +import { promises } from "fs" import * as http from "http" import * as https from "https" import * as path from "path" import { createApp, ensureAddress, handleArgsSocketCatchError, handleServerError } from "../../../src/node/app" import { OptionalString, setDefaults } from "../../../src/node/cli" import { generateCertificate } from "../../../src/node/util" -import { getAvailablePort, tmpdir } from "../../utils/helpers" +import { clean, mockLogger, getAvailablePort, tmpdir } from "../../utils/helpers" describe("createApp", () => { - let spy: jest.SpyInstance let unlinkSpy: jest.SpyInstance let port: number let tmpDirPath: string let tmpFilePath: string beforeAll(async () => { - tmpDirPath = await tmpdir("unlink-socket") + mockLogger() + + const testName = "unlink-socket" + await clean(testName) + tmpDirPath = await tmpdir(testName) tmpFilePath = path.join(tmpDirPath, "unlink-socket-file") }) beforeEach(async () => { - spy = jest.spyOn(logger, "error") // NOTE:@jsjoeio // Be mindful when spying. // You can't spy on fs functions if you do import * as fs @@ -36,12 +38,6 @@ describe("createApp", () => { jest.clearAllMocks() }) - afterAll(() => { - jest.restoreAllMocks() - // Ensure directory was removed - rmdirSync(tmpDirPath, { recursive: true }) - }) - it("should return an Express app, a WebSockets Express app and an http server", async () => { const defaultArgs = await setDefaults({ port, @@ -70,8 +66,8 @@ describe("createApp", () => { // By emitting an error event // Ref: https://stackoverflow.com/a/33872506/3015595 app.server.emit("error", testError) - expect(spy).toHaveBeenCalledTimes(1) - expect(spy).toHaveBeenCalledWith(`http server error: ${testError.message} ${testError.stack}`) + expect(logger.error).toHaveBeenCalledTimes(1) + expect(logger.error).toHaveBeenCalledWith(`http server error: ${testError.message} ${testError.stack}`) // Cleanup app.dispose() @@ -152,20 +148,14 @@ describe("ensureAddress", () => { }) describe("handleServerError", () => { - let spy: jest.SpyInstance - - beforeEach(() => { - spy = jest.spyOn(logger, "error") + beforeAll(() => { + mockLogger() }) afterEach(() => { jest.clearAllMocks() }) - afterAll(() => { - jest.restoreAllMocks() - }) - it("should call reject if resolved is false", async () => { const resolved = false const reject = jest.fn((err: Error) => undefined) @@ -184,33 +174,27 @@ describe("handleServerError", () => { handleServerError(resolved, error, reject) - expect(spy).toHaveBeenCalledTimes(1) - expect(spy).toThrowErrorMatchingSnapshot() + expect(logger.error).toHaveBeenCalledTimes(1) + expect(logger.error).toHaveBeenCalledWith(`http server error: ${error.message} ${error.stack}`) }) }) describe("handleArgsSocketCatchError", () => { - let spy: jest.SpyInstance - - beforeEach(() => { - spy = jest.spyOn(logger, "error") + beforeAll(() => { + mockLogger() }) afterEach(() => { jest.clearAllMocks() }) - afterAll(() => { - jest.restoreAllMocks() - }) - it("should log an error if its not an NodeJS.ErrnoException", () => { const error = new Error() handleArgsSocketCatchError(error) - expect(spy).toHaveBeenCalledTimes(1) - expect(spy).toHaveBeenCalledWith(error) + expect(logger.error).toHaveBeenCalledTimes(1) + expect(logger.error).toHaveBeenCalledWith(error) }) it("should log an error if its not an NodeJS.ErrnoException (and the error has a message)", () => { @@ -219,8 +203,8 @@ describe("handleArgsSocketCatchError", () => { handleArgsSocketCatchError(error) - expect(spy).toHaveBeenCalledTimes(1) - expect(spy).toHaveBeenCalledWith(errorMessage) + expect(logger.error).toHaveBeenCalledTimes(1) + expect(logger.error).toHaveBeenCalledWith(errorMessage) }) it("should not log an error if its a iNodeJS.ErrnoException", () => { @@ -229,7 +213,7 @@ describe("handleArgsSocketCatchError", () => { handleArgsSocketCatchError(error) - expect(spy).toHaveBeenCalledTimes(0) + expect(logger.error).toHaveBeenCalledTimes(0) }) it("should log an error if the code is not ENOENT (and the error has a message)", () => { @@ -240,8 +224,8 @@ describe("handleArgsSocketCatchError", () => { handleArgsSocketCatchError(error) - expect(spy).toHaveBeenCalledTimes(1) - expect(spy).toHaveBeenCalledWith(errorMessage) + expect(logger.error).toHaveBeenCalledTimes(1) + expect(logger.error).toHaveBeenCalledWith(errorMessage) }) it("should log an error if the code is not ENOENT", () => { @@ -250,7 +234,7 @@ describe("handleArgsSocketCatchError", () => { handleArgsSocketCatchError(error) - expect(spy).toHaveBeenCalledTimes(1) - expect(spy).toHaveBeenCalledWith(error) + expect(logger.error).toHaveBeenCalledTimes(1) + expect(logger.error).toHaveBeenCalledWith(error) }) }) diff --git a/test/unit/node/cli.test.ts b/test/unit/node/cli.test.ts index e49794d5..ac848c9b 100644 --- a/test/unit/node/cli.test.ts +++ b/test/unit/node/cli.test.ts @@ -361,13 +361,11 @@ describe("parser", () => { }) describe("cli", () => { - let testDir: string + const testName = "cli" const vscodeIpcPath = path.join(os.tmpdir(), "vscode-ipc") beforeAll(async () => { - testDir = await tmpdir("cli") - await fs.rmdir(testDir, { recursive: true }) - await fs.mkdir(testDir, { recursive: true }) + await clean(testName) }) beforeEach(async () => { @@ -416,6 +414,7 @@ describe("cli", () => { args._ = ["./file"] expect(await shouldOpenInExistingInstance(args)).toStrictEqual(undefined) + const testDir = await tmpdir(testName) const socketPath = path.join(testDir, "socket") await fs.writeFile(vscodeIpcPath, socketPath) expect(await shouldOpenInExistingInstance(args)).toStrictEqual(undefined) @@ -635,14 +634,15 @@ describe("readSocketPath", () => { let tmpDirPath: string let tmpFilePath: string - beforeEach(async () => { - tmpDirPath = await tmpdir("readSocketPath") - tmpFilePath = path.join(tmpDirPath, "readSocketPath.txt") - await fs.writeFile(tmpFilePath, fileContents) + const testName = "readSocketPath" + beforeAll(async () => { + await clean(testName) }) - afterEach(async () => { - await fs.rmdir(tmpDirPath, { recursive: true }) + beforeEach(async () => { + tmpDirPath = await tmpdir(testName) + tmpFilePath = path.join(tmpDirPath, "readSocketPath.txt") + await fs.writeFile(tmpFilePath, fileContents) }) it("should throw an error if it can't read the file", async () => { @@ -677,9 +677,10 @@ describe("toVsCodeArgs", () => { version: false, } + const testName = "vscode-args" beforeAll(async () => { // Clean up temporary directories from the previous run. - await clean("vscode-args") + await clean(testName) }) it("should convert empty args", async () => { @@ -691,7 +692,7 @@ describe("toVsCodeArgs", () => { }) it("should convert with workspace", async () => { - const workspace = path.join(await tmpdir("vscode-args"), "test.code-workspace") + const workspace = path.join(await tmpdir(testName), "test.code-workspace") await fs.writeFile(workspace, "foobar") expect(await toVsCodeArgs(await setDefaults(parse([workspace])))).toStrictEqual({ ...vscodeDefaults, @@ -702,7 +703,7 @@ describe("toVsCodeArgs", () => { }) it("should convert with folder", async () => { - const folder = await tmpdir("vscode-args") + const folder = await tmpdir(testName) expect(await toVsCodeArgs(await setDefaults(parse([folder])))).toStrictEqual({ ...vscodeDefaults, folder, @@ -712,7 +713,7 @@ describe("toVsCodeArgs", () => { }) it("should ignore regular file", async () => { - const file = path.join(await tmpdir("vscode-args"), "file") + const file = path.join(await tmpdir(testName), "file") await fs.writeFile(file, "foobar") expect(await toVsCodeArgs(await setDefaults(parse([file])))).toStrictEqual({ ...vscodeDefaults, diff --git a/test/unit/node/constants.test.ts b/test/unit/node/constants.test.ts index 5b9a8d87..8a41583d 100644 --- a/test/unit/node/constants.test.ts +++ b/test/unit/node/constants.test.ts @@ -1,10 +1,10 @@ -import { createLoggerMock } from "../../utils/helpers" +import { logger } from "@coder/logger" +import { mockLogger } from "../../utils/helpers" describe("constants", () => { let constants: typeof import("../../../src/node/constants") describe("with package.json defined", () => { - const loggerModule = createLoggerMock() const mockPackageJson = { name: "mock-code-server", description: "Run VS Code on a remote server.", @@ -14,7 +14,7 @@ describe("constants", () => { } beforeAll(() => { - jest.mock("@coder/logger", () => loggerModule) + mockLogger() jest.mock("../../../package.json", () => mockPackageJson, { virtual: true }) constants = require("../../../src/node/constants") }) @@ -38,8 +38,8 @@ describe("constants", () => { constants.getPackageJson("./package.json") - expect(loggerModule.logger.warn).toHaveBeenCalled() - expect(loggerModule.logger.warn).toHaveBeenCalledWith(expectedErrorMessage) + expect(logger.warn).toHaveBeenCalled() + expect(logger.warn).toHaveBeenCalledWith(expectedErrorMessage) }) it("should find the package.json", () => { diff --git a/test/unit/node/proxy_agent.test.ts b/test/unit/node/proxy_agent.test.ts index a2552b7f..72fdfa9c 100644 --- a/test/unit/node/proxy_agent.test.ts +++ b/test/unit/node/proxy_agent.test.ts @@ -1,11 +1,15 @@ import { shouldEnableProxy } from "../../../src/node/proxy_agent" -import { useEnv } from "../../utils/helpers" +import { mockLogger, useEnv } from "../../utils/helpers" describe("shouldEnableProxy", () => { const [setHTTPProxy, resetHTTPProxy] = useEnv("HTTP_PROXY") const [setHTTPSProxy, resetHTTPSProxy] = useEnv("HTTPS_PROXY") const [setNoProxy, resetNoProxy] = useEnv("NO_PROXY") + beforeAll(() => { + mockLogger() + }) + beforeEach(() => { jest.resetModules() // Most important - it clears the cache resetHTTPProxy() diff --git a/test/unit/node/routes/login.test.ts b/test/unit/node/routes/login.test.ts index 94cc265a..b132c0e8 100644 --- a/test/unit/node/routes/login.test.ts +++ b/test/unit/node/routes/login.test.ts @@ -1,8 +1,13 @@ import { RateLimiter } from "../../../../src/node/routes/login" +import { mockLogger } from "../../../utils/helpers" import * as httpserver from "../../../utils/httpserver" import * as integration from "../../../utils/integration" describe("login", () => { + beforeAll(() => { + mockLogger() + }) + describe("RateLimiter", () => { it("should allow one try ", () => { const limiter = new RateLimiter() diff --git a/test/unit/node/routes/static.test.ts b/test/unit/node/routes/static.test.ts index d3c03b71..f4ed03a9 100644 --- a/test/unit/node/routes/static.test.ts +++ b/test/unit/node/routes/static.test.ts @@ -1,7 +1,7 @@ import { promises as fs } from "fs" import * as path from "path" import { rootPath } from "../../../../src/node/constants" -import { tmpdir } from "../../../utils/helpers" +import { clean, tmpdir } from "../../../utils/helpers" import * as httpserver from "../../../utils/httpserver" import * as integration from "../../../utils/integration" @@ -23,8 +23,10 @@ describe("/_static", () => { let testFileContent: string | undefined let nonExistentTestFile: string | undefined + const testName = "_static" beforeAll(async () => { - const testDir = await tmpdir("_static") + await clean(testName) + const testDir = await tmpdir(testName) testFile = path.join(testDir, "test") testFileContent = "static file contents" nonExistentTestFile = path.join(testDir, "i-am-not-here") diff --git a/test/unit/node/routes/vscode.test.ts b/test/unit/node/routes/vscode.test.ts new file mode 100644 index 00000000..d896f846 --- /dev/null +++ b/test/unit/node/routes/vscode.test.ts @@ -0,0 +1,158 @@ +import { promises as fs } from "fs" +import { Response } from "node-fetch" +import * as path from "path" +import { clean, tmpdir } from "../../../utils/helpers" +import * as httpserver from "../../../utils/httpserver" +import * as integration from "../../../utils/integration" + +interface WorkbenchConfig { + folderUri?: { + path: string + } + workspaceUri?: { + path: string + } +} + +describe("vscode", () => { + let codeServer: httpserver.HttpServer | undefined + + const testName = "vscode" + beforeAll(async () => { + await clean(testName) + }) + + afterEach(async () => { + if (codeServer) { + await codeServer.dispose() + codeServer = undefined + } + }) + + const routes = ["/", "/vscode", "/vscode/"] + + it("should load all route variations", async () => { + codeServer = await integration.setup(["--auth=none"], "") + + for (const route of routes) { + const resp = await codeServer.fetch(route) + expect(resp.status).toBe(200) + const html = await resp.text() + const url = new URL(resp.url) // Check there were no redirections. + expect(url.pathname + decodeURIComponent(url.search)).toBe(route) + switch (route) { + case "/": + case "/vscode/": + expect(html).toContain(`src="./static/`) + break + case "/vscode": + expect(html).toContain(`src="./vscode/static/`) + break + } + } + }) + + /** + * Get the workbench config from the provided response. + */ + const getConfig = async (resp: Response): Promise => { + expect(resp.status).toBe(200) + const html = await resp.text() + const match = html.match(//) + if (!match || !match[1]) { + throw new Error("Unable to find workbench configuration") + } + const config = match[1].replace(/"/g, '"') + try { + return JSON.parse(config) + } catch (error) { + console.error("Failed to parse workbench configuration", config) + throw error + } + } + + it("should have no default folder or workspace", async () => { + codeServer = await integration.setup(["--auth=none"], "") + + const config = await getConfig(await codeServer.fetch("/")) + expect(config.folderUri).toBeUndefined() + expect(config.workspaceUri).toBeUndefined() + }) + + it("should have a default folder", async () => { + const defaultDir = await tmpdir(testName) + codeServer = await integration.setup(["--auth=none", defaultDir], "") + + // At first it will load the directory provided on the command line. + const config = await getConfig(await codeServer.fetch("/")) + expect(config.folderUri?.path).toBe(defaultDir) + expect(config.workspaceUri).toBeUndefined() + }) + + it("should have a default workspace", async () => { + const defaultWorkspace = path.join(await tmpdir(testName), "test.code-workspace") + await fs.writeFile(defaultWorkspace, "") + codeServer = await integration.setup(["--auth=none", defaultWorkspace], "") + + // At first it will load the workspace provided on the command line. + const config = await getConfig(await codeServer.fetch("/")) + expect(config.folderUri).toBeUndefined() + expect(config.workspaceUri?.path).toBe(defaultWorkspace) + }) + + it("should redirect to last query folder/workspace", async () => { + codeServer = await integration.setup(["--auth=none"], "") + + const folder = await tmpdir(testName) + const workspace = path.join(await tmpdir(testName), "test.code-workspace") + let resp = await codeServer.fetch("/", undefined, { + folder, + workspace, + }) + expect(resp.status).toBe(200) + await resp.text() + + // If you visit again without query parameters it will re-attach them by + // redirecting. It should always redirect to the same route. + for (const route of routes) { + resp = await codeServer.fetch(route) + const url = new URL(resp.url) + expect(url.pathname).toBe(route) + expect(decodeURIComponent(url.search)).toBe(`?folder=${folder}&workspace=${workspace}`) + await resp.text() + } + + // Closing the folder should stop the redirecting. + resp = await codeServer.fetch("/", undefined, { ew: "true" }) + let url = new URL(resp.url) + expect(url.pathname).toBe("/") + expect(decodeURIComponent(url.search)).toBe("?ew=true") + await resp.text() + + resp = await codeServer.fetch("/") + url = new URL(resp.url) + expect(url.pathname).toBe("/") + expect(decodeURIComponent(url.search)).toBe("") + await resp.text() + }) + + it("should not redirect when last opened is ignored", async () => { + codeServer = await integration.setup(["--auth=none", "--ignore-last-opened"], "") + + const folder = await tmpdir(testName) + const workspace = path.join(await tmpdir(testName), "test.code-workspace") + let resp = await codeServer.fetch("/", undefined, { + folder, + workspace, + }) + expect(resp.status).toBe(200) + await resp.text() + + // No redirections. + resp = await codeServer.fetch("/") + const url = new URL(resp.url) + expect(url.pathname).toBe("/") + expect(decodeURIComponent(url.search)).toBe("") + await resp.text() + }) +}) diff --git a/test/unit/node/update.test.ts b/test/unit/node/update.test.ts index c76c9c7b..49c938b1 100644 --- a/test/unit/node/update.test.ts +++ b/test/unit/node/update.test.ts @@ -1,9 +1,8 @@ -import { promises as fs } from "fs" import * as http from "http" import * as path from "path" -import { tmpdir } from "../../../src/node/constants" import { SettingsProvider, UpdateSettings } from "../../../src/node/settings" import { LatestResponse, UpdateProvider } from "../../../src/node/update" +import { clean, mockLogger, tmpdir } from "../../utils/helpers" describe("update", () => { let version = "1.0.0" @@ -29,22 +28,31 @@ describe("update", () => { response.end("not found") }) - const jsonPath = path.join(tmpdir, "tests/updates/update.json") - const settings = new SettingsProvider(jsonPath) + let _settings: SettingsProvider | undefined + const settings = (): SettingsProvider => { + if (!_settings) { + throw new Error("Settings provider has not been created") + } + return _settings + } let _provider: UpdateProvider | undefined const provider = (): UpdateProvider => { if (!_provider) { - const address = server.address() - if (!address || typeof address === "string" || !address.port) { - throw new Error("unexpected address") - } - _provider = new UpdateProvider(`http://${address.address}:${address.port}/latest`, settings) + throw new Error("Update provider has not been created") } return _provider } beforeAll(async () => { + mockLogger() + + const testName = "update" + await clean(testName) + const testDir = await tmpdir(testName) + const jsonPath = path.join(testDir, "update.json") + _settings = new SettingsProvider(jsonPath) + await new Promise((resolve, reject) => { server.on("error", reject) server.on("listening", resolve) @@ -53,8 +61,13 @@ describe("update", () => { host: "localhost", }) }) - await fs.rmdir(path.join(tmpdir, "tests/updates"), { recursive: true }) - await fs.mkdir(path.join(tmpdir, "tests/updates"), { recursive: true }) + + const address = server.address() + if (!address || typeof address === "string" || !address.port) { + throw new Error("unexpected address") + } + + _provider = new UpdateProvider(`http://${address.address}:${address.port}/latest`, _settings) }) afterAll(() => { @@ -72,7 +85,7 @@ describe("update", () => { const now = Date.now() const update = await p.getUpdate() - await expect(settings.read()).resolves.toEqual({ update }) + await expect(settings().read()).resolves.toEqual({ update }) expect(isNaN(update.checked)).toEqual(false) expect(update.checked < Date.now() && update.checked >= now).toEqual(true) expect(update.version).toStrictEqual("2.1.0") @@ -86,7 +99,7 @@ describe("update", () => { const now = Date.now() const update = await p.getUpdate() - await expect(settings.read()).resolves.toEqual({ update }) + await expect(settings().read()).resolves.toEqual({ update }) expect(isNaN(update.checked)).toStrictEqual(false) expect(update.checked < now).toBe(true) expect(update.version).toStrictEqual("2.1.0") @@ -100,7 +113,7 @@ describe("update", () => { const now = Date.now() const update = await p.getUpdate(true) - await expect(settings.read()).resolves.toEqual({ update }) + await expect(settings().read()).resolves.toEqual({ update }) expect(isNaN(update.checked)).toStrictEqual(false) expect(update.checked < Date.now() && update.checked >= now).toStrictEqual(true) expect(update.version).toStrictEqual("4.1.1") @@ -113,12 +126,12 @@ describe("update", () => { expect(spy).toEqual([]) let checked = Date.now() - 1000 * 60 * 60 * 23 - await settings.write({ update: { checked, version } }) + await settings().write({ update: { checked, version } }) await p.getUpdate() expect(spy).toEqual([]) checked = Date.now() - 1000 * 60 * 60 * 25 - await settings.write({ update: { checked, version } }) + await settings().write({ update: { checked, version } }) const update = await p.getUpdate() expect(update.checked).not.toStrictEqual(checked) @@ -143,14 +156,14 @@ describe("update", () => { }) it("should not reject if unable to fetch", async () => { - let provider = new UpdateProvider("invalid", settings) + let provider = new UpdateProvider("invalid", settings()) let now = Date.now() let update = await provider.getUpdate(true) expect(isNaN(update.checked)).toStrictEqual(false) expect(update.checked < Date.now() && update.checked >= now).toEqual(true) expect(update.version).toStrictEqual("unknown") - provider = new UpdateProvider("http://probably.invalid.dev.localhost/latest", settings) + provider = new UpdateProvider("http://probably.invalid.dev.localhost/latest", settings()) now = Date.now() update = await provider.getUpdate(true) expect(isNaN(update.checked)).toStrictEqual(false) diff --git a/test/utils/globalSetup.ts b/test/utils/globalE2eSetup.ts similarity index 92% rename from test/utils/globalSetup.ts rename to test/utils/globalE2eSetup.ts index 7b19d882..71293830 100644 --- a/test/utils/globalSetup.ts +++ b/test/utils/globalE2eSetup.ts @@ -6,8 +6,8 @@ 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. + * Perform workspace cleanup and authenticate. This should be ran before e2e + * tests execute. */ export default async function () { console.log("\n🚨 Running Global Setup for Playwright End-to-End Tests") diff --git a/test/utils/globalUnitSetup.ts b/test/utils/globalUnitSetup.ts new file mode 100644 index 00000000..b1b8add7 --- /dev/null +++ b/test/utils/globalUnitSetup.ts @@ -0,0 +1,9 @@ +import { workspaceDir } from "./constants" +import { clean } from "./helpers" + +/** + * Perform workspace cleanup. This should be ran before unit tests execute. + */ +export default async function () { + await clean(workspaceDir) +} diff --git a/test/utils/helpers.ts b/test/utils/helpers.ts index 10b4abee..92e06fbc 100644 --- a/test/utils/helpers.ts +++ b/test/utils/helpers.ts @@ -1,23 +1,26 @@ +import { logger } from "@coder/logger" import { promises as fs } from "fs" import * as net from "net" import * as os from "os" import * as path from "path" /** - * Return a mock of @coder/logger. + * Spy on the logger and console and replace with mock implementations to + * suppress the output. */ -export function createLoggerMock() { - return { - field: jest.fn(), - level: 2, - logger: { - debug: jest.fn(), - error: jest.fn(), - info: jest.fn(), - trace: jest.fn(), - warn: jest.fn(), - }, - } +export function mockLogger() { + jest.spyOn(logger, "debug").mockImplementation() + jest.spyOn(logger, "error").mockImplementation() + jest.spyOn(logger, "info").mockImplementation() + jest.spyOn(logger, "trace").mockImplementation() + jest.spyOn(logger, "warn").mockImplementation() + + jest.spyOn(console, "log").mockImplementation() + jest.spyOn(console, "debug").mockImplementation() + jest.spyOn(console, "error").mockImplementation() + jest.spyOn(console, "info").mockImplementation() + jest.spyOn(console, "trace").mockImplementation() + jest.spyOn(console, "warn").mockImplementation() } /** @@ -31,6 +34,8 @@ export async function clean(testName: string): Promise { /** * Create a uniquely named temporary directory for a test. + * + * `tmpdir` should usually be preceeded by at least one call to `clean`. */ export async function tmpdir(testName: string): Promise { const dir = path.join(os.tmpdir(), `code-server/tests/${testName}`) diff --git a/test/utils/httpserver.ts b/test/utils/httpserver.ts index 74c1c00e..53d43de9 100644 --- a/test/utils/httpserver.ts +++ b/test/utils/httpserver.ts @@ -59,13 +59,17 @@ export class HttpServer { * fetch fetches the request path. * The request path must be rooted! */ - public fetch(requestPath: string, opts?: RequestInit): Promise { + public fetch(requestPath: string, opts?: RequestInit, query?: { [key: string]: string }): Promise { const address = ensureAddress(this.hs, "http") if (typeof address === "string") { throw new Error("Cannot fetch socket path") } address.pathname = requestPath - + if (query) { + Object.keys(query).forEach((key) => { + address.searchParams.append(key, query[key]) + }) + } return nodeFetch(address.toString(), opts) } diff --git a/test/utils/integration.ts b/test/utils/integration.ts index 5c4f0cc6..4acbd034 100644 --- a/test/utils/integration.ts +++ b/test/utils/integration.ts @@ -1,11 +1,27 @@ +import { promises as fs } from "fs" +import * as path from "path" import { parse, parseConfigFile, setDefaults } from "../../src/node/cli" import { runCodeServer } from "../../src/node/main" +import { workspaceDir } from "./constants" +import { tmpdir } from "./helpers" import * as httpserver from "./httpserver" export async function setup(argv: string[], configFile?: string): Promise { - argv = ["--bind-addr=localhost:0", "--log=warn", ...argv] + // This will be used as the data directory to ensure instances do not bleed + // into each other. + const dir = await tmpdir(workspaceDir) - const cliArgs = parse(argv) + // VS Code complains if the logs dir is missing which spams the output. + // TODO: Does that mean we are not creating it when we should be? + await fs.mkdir(path.join(dir, "logs")) + + const cliArgs = parse([ + `--config=${path.join(dir, "config.yaml")}`, + `--user-data-dir=${dir}`, + "--bind-addr=localhost:0", + "--log=warn", + ...argv, + ]) const configArgs = parseConfigFile(configFile || "", "test/integration.ts") const args = await setDefaults(cliArgs, configArgs)