From f482c2a747d665ca6c142b6c52173e4fab1f9b7f Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 15 Dec 2021 23:55:44 +0000 Subject: [PATCH 01/13] Implement last opened functionality Fixes https://github.com/cdr/code-server/issues/4619 --- CHANGELOG.md | 2 ++ src/node/routes/vscode.ts | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbb557e49264..332c9ff7f718 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/src/node/routes/vscode.ts b/src/node/routes/vscode.ts index 4d394c25d847..558b5e7481ac 100644 --- a/src/node/routes/vscode.ts +++ b/src/node/routes/vscode.ts @@ -4,6 +4,7 @@ import { WebsocketRequest } from "../../../typings/pluginapi" import { logError } from "../../common/util" import { isDevMode } from "../constants" import { toVsCodeArgs } from "../cli" +import { settings } from "../settings" import { ensureAuthenticated, authenticated, redirect } from "../http" import { loadAMDModule, readCompilationStats } from "../util" import { Router as WsRouter } from "../wsRouter" @@ -31,6 +32,30 @@ export class CodeServerRouteWrapper { }) } + // Ew means the workspace was closed so clear the last folder/workspace. + const { query } = await settings.read() + 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. + ) { + return redirect(req, res, "", { + 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. + settings.write({ query: req.query }) + next() } From 9a8477bef89c6bda92be9abb3813d03842b7cafe Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 16 Dec 2021 19:31:48 +0000 Subject: [PATCH 02/13] Fix test temp dirs not being cleaned up --- test/e2e/terminal.test.ts | 16 ++++------ test/unit/helpers.test.ts | 8 +++-- test/unit/node/app.test.ts | 14 +++------ test/unit/node/cli.test.ts | 24 +++++++------- test/unit/node/routes/static.test.ts | 6 ++-- test/unit/node/update.test.ts | 47 +++++++++++++++++----------- test/utils/helpers.ts | 2 ++ 7 files changed, 63 insertions(+), 54 deletions(-) diff --git a/test/e2e/terminal.test.ts b/test/e2e/terminal.test.ts index 836583a8d566..3e2010086559 100644 --- a/test/e2e/terminal.test.ts +++ b/test/e2e/terminal.test.ts @@ -2,7 +2,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 +10,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/unit/helpers.test.ts b/test/unit/helpers.test.ts index e3de12e2a007..ba3a54d2810d 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/app.test.ts b/test/unit/node/app.test.ts index 99c67cd2a810..238271663512 100644 --- a/test/unit/node/app.test.ts +++ b/test/unit/node/app.test.ts @@ -1,12 +1,12 @@ 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, getAvailablePort, tmpdir } from "../../utils/helpers" describe("createApp", () => { let spy: jest.SpyInstance @@ -16,7 +16,9 @@ describe("createApp", () => { let tmpFilePath: string beforeAll(async () => { - tmpDirPath = await tmpdir("unlink-socket") + const testName = "unlink-socket" + await clean(testName) + tmpDirPath = await tmpdir(testName) tmpFilePath = path.join(tmpDirPath, "unlink-socket-file") }) @@ -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, diff --git a/test/unit/node/cli.test.ts b/test/unit/node/cli.test.ts index e49794d51a8e..504d6a977e31 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) @@ -636,15 +635,13 @@ describe("readSocketPath", () => { let tmpFilePath: string beforeEach(async () => { - tmpDirPath = await tmpdir("readSocketPath") + const testName = "readSocketPath" + await clean(testName) + tmpDirPath = await tmpdir(testName) tmpFilePath = path.join(tmpDirPath, "readSocketPath.txt") await fs.writeFile(tmpFilePath, fileContents) }) - afterEach(async () => { - await fs.rmdir(tmpDirPath, { recursive: true }) - }) - it("should throw an error if it can't read the file", async () => { // TODO@jsjoeio - implement // Test it on a directory.... ESDIR @@ -677,9 +674,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 +689,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 +700,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 +710,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/routes/static.test.ts b/test/unit/node/routes/static.test.ts index d3c03b718024..f4ed03a943b9 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/update.test.ts b/test/unit/node/update.test.ts index c76c9c7bbf84..c699b4d536c3 100644 --- a/test/unit/node/update.test.ts +++ b/test/unit/node/update.test.ts @@ -1,7 +1,6 @@ -import { promises as fs } from "fs" import * as http from "http" import * as path from "path" -import { tmpdir } from "../../../src/node/constants" +import { clean, tmpdir } from "../../utils/helpers" import { SettingsProvider, UpdateSettings } from "../../../src/node/settings" import { LatestResponse, UpdateProvider } from "../../../src/node/update" @@ -29,22 +28,29 @@ 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 () => { + 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 +59,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 +83,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 +97,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 +111,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 +124,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 +154,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/helpers.ts b/test/utils/helpers.ts index 10b4abee794e..420786914554 100644 --- a/test/utils/helpers.ts +++ b/test/utils/helpers.ts @@ -31,6 +31,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}`) From 902a8206034ed642f557786fe0420f03923687fc Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 16 Dec 2021 21:21:43 +0000 Subject: [PATCH 03/13] 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. --- test/unit/common/emitter.test.ts | 18 ++----- test/unit/common/util.test.ts | 25 +++++---- test/unit/node/__snapshots__/app.test.ts.snap | 3 -- test/unit/node/app.test.ts | 52 +++++++------------ 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/update.test.ts | 4 +- test/utils/helpers.ts | 29 ++++++----- 9 files changed, 71 insertions(+), 81 deletions(-) delete mode 100644 test/unit/node/__snapshots__/app.test.ts.snap diff --git a/test/unit/common/emitter.test.ts b/test/unit/common/emitter.test.ts index 46a5dddd7efb..cec5fa611610 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 71f42386f473..eef219210187 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", () => { - afterEach(() => { - jest.clearAllMocks() + beforeAll(() => { + mockLogger() }) - afterAll(() => { - jest.restoreAllMocks() + afterEach(() => { + jest.clearAllMocks() }) - 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/node/__snapshots__/app.test.ts.snap b/test/unit/node/__snapshots__/app.test.ts.snap deleted file mode 100644 index 10a0e6c6d765..000000000000 --- 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 238271663512..79279ceb29a8 100644 --- a/test/unit/node/app.test.ts +++ b/test/unit/node/app.test.ts @@ -6,16 +6,17 @@ 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 { clean, 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 () => { + mockLogger() + const testName = "unlink-socket" await clean(testName) tmpDirPath = await tmpdir(testName) @@ -23,7 +24,6 @@ describe("createApp", () => { }) 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 @@ -66,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() @@ -148,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) @@ -180,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)", () => { @@ -215,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", () => { @@ -225,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)", () => { @@ -236,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", () => { @@ -246,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/constants.test.ts b/test/unit/node/constants.test.ts index 5b9a8d87a712..8a41583da798 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 a2552b7f0399..72fdfa9cc229 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 94cc265a6c8c..b132c0e87e06 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/update.test.ts b/test/unit/node/update.test.ts index c699b4d536c3..f2b444aa2a3a 100644 --- a/test/unit/node/update.test.ts +++ b/test/unit/node/update.test.ts @@ -1,6 +1,6 @@ import * as http from "http" import * as path from "path" -import { clean, tmpdir } from "../../utils/helpers" +import { clean, mockLogger, tmpdir } from "../../utils/helpers" import { SettingsProvider, UpdateSettings } from "../../../src/node/settings" import { LatestResponse, UpdateProvider } from "../../../src/node/update" @@ -45,6 +45,8 @@ describe("update", () => { } beforeAll(async () => { + mockLogger() + const testName = "update" await clean(testName) const testDir = await tmpdir(testName) diff --git a/test/utils/helpers.ts b/test/utils/helpers.ts index 420786914554..92e06fbcf204 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() } /** From 2fbb43f1079095b08a285e9efa287fa55e421f27 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 16 Dec 2021 22:16:50 +0000 Subject: [PATCH 04/13] Use separate data directories for unit test instances Exactly as we do for the e2e tests. --- package.json | 3 ++- test/playwright.config.ts | 2 +- .../{globalSetup.ts => globalE2eSetup.ts} | 4 ++-- test/utils/globalUnitSetup.ts | 9 +++++++++ test/utils/integration.ts | 20 +++++++++++++++++-- 5 files changed, 32 insertions(+), 6 deletions(-) rename test/utils/{globalSetup.ts => globalE2eSetup.ts} (92%) create mode 100644 test/utils/globalUnitSetup.ts diff --git a/package.json b/package.json index d4f05c5c71ae..f24b986aebdb 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/test/playwright.config.ts b/test/playwright.config.ts index 2f77fb9cbbcc..c969dabfad4a 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/utils/globalSetup.ts b/test/utils/globalE2eSetup.ts similarity index 92% rename from test/utils/globalSetup.ts rename to test/utils/globalE2eSetup.ts index 7b19d882902a..712938300aec 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 000000000000..b1b8add70115 --- /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/integration.ts b/test/utils/integration.ts index 5c4f0cc6aa7f..4acbd0341b77 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) From 08cc53f4fababa8f8ed3342ba6df7e6474e3167b Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 16 Dec 2021 23:21:27 +0000 Subject: [PATCH 05/13] fixup! Implement last opened functionality --- src/node/routes/vscode.ts | 36 ++++++++++++++++++++---------------- src/node/settings.ts | 6 +----- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/node/routes/vscode.ts b/src/node/routes/vscode.ts index 558b5e7481ac..6a898879b4b0 100644 --- a/src/node/routes/vscode.ts +++ b/src/node/routes/vscode.ts @@ -34,27 +34,31 @@ export class CodeServerRouteWrapper { // Ew means the workspace was closed so clear the last folder/workspace. const { query } = await settings.read() - if (req.query.ew) { - delete query.folder - delete query.workspace - } + if (query) { + 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. - ) { - return redirect(req, res, "", { - folder: query.folder, - workspace: 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. + ) { + // Redirect to the same page but with the query parameters attached + // (preserving the trailing slash if any). + return redirect(req, res, req.baseUrl + (req.originalUrl.endsWith("/") ? "/" : ""), { + 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. - settings.write({ query: req.query }) + await settings.write({ query: req.query }) next() } diff --git a/src/node/settings.ts b/src/node/settings.ts index 4cce755a8a08..7ca71594a16a 100644 --- a/src/node/settings.ts +++ b/src/node/settings.ts @@ -54,11 +54,7 @@ export interface UpdateSettings { * Global code-server settings. */ export interface CoderSettings extends UpdateSettings { - lastVisited: { - url: string - workspace: boolean - } - query: Query + query?: Query } /** From 953f3a41dfca6d5345681b9cf196d8a1532488fa Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 17 Dec 2021 00:04:43 +0000 Subject: [PATCH 06/13] Add integration tests for vscode route --- test/unit/node/routes/vscode.test.ts | 158 +++++++++++++++++++++++++++ test/utils/httpserver.ts | 8 +- 2 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 test/unit/node/routes/vscode.test.ts diff --git a/test/unit/node/routes/vscode.test.ts b/test/unit/node/routes/vscode.test.ts new file mode 100644 index 000000000000..d896f846762c --- /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/utils/httpserver.ts b/test/utils/httpserver.ts index 74c1c00e6e64..53d43de9a81d 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) } From 6860c7b5265c7ee09c725cb042fe4a72aa9e9341 Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 17 Dec 2021 00:19:10 +0000 Subject: [PATCH 07/13] Make settings use --user-data-dir Without this test instances step on each other feet and they also clobber your own non-test settings. --- src/node/http.ts | 4 ++++ src/node/routes/index.ts | 7 +++++++ src/node/routes/update.ts | 7 ++----- src/node/routes/vscode.ts | 5 ++--- src/node/settings.ts | 7 ------- src/node/update.ts | 9 ++++----- 6 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/node/http.ts b/src/node/http.ts index ca226908e5b1..c3547cbe68ac 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 { UpdateProvider } from "./update" +import { CoderSettings, SettingsProvider } from "./settings" 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 } } } diff --git a/src/node/routes/index.ts b/src/node/routes/index.ts index 5658e1df04af..622deac88cf5 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 { UpdateProvider } from "../update" +import { CoderSettings, SettingsProvider } from "../settings" 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/cdr/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 { - 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 6a898879b4b0..47a592a540cb 100644 --- a/src/node/routes/vscode.ts +++ b/src/node/routes/vscode.ts @@ -4,7 +4,6 @@ import { WebsocketRequest } from "../../../typings/pluginapi" import { logError } from "../../common/util" import { isDevMode } from "../constants" import { toVsCodeArgs } from "../cli" -import { settings } from "../settings" import { ensureAuthenticated, authenticated, redirect } from "../http" import { loadAMDModule, readCompilationStats } from "../util" import { Router as WsRouter } from "../wsRouter" @@ -33,7 +32,7 @@ export class CodeServerRouteWrapper { } // Ew means the workspace was closed so clear the last folder/workspace. - const { query } = await settings.read() + const { query } = await req.settings.read() if (query) { if (req.query.ew) { delete query.folder @@ -58,7 +57,7 @@ export class CodeServerRouteWrapper { // Store the query parameters so we can use them on the next load. This // also allows users to create functionality around query parameters. - await settings.write({ query: req.query }) + await req.settings.write({ query: req.query }) next() } diff --git a/src/node/settings.ts b/src/node/settings.ts index 7ca71594a16a..709ce950cb89 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 } @@ -56,8 +54,3 @@ export interface UpdateSettings { export interface CoderSettings extends UpdateSettings { 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 e9679fa42a21..03d61ed99608 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, ) {} /** From 01a5407eb153d6e7511e28f647d9e2ed4d2c85be Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 17 Dec 2021 00:42:54 +0000 Subject: [PATCH 08/13] fmt --- src/node/http.ts | 2 +- src/node/routes/index.ts | 2 +- src/node/routes/vscode.ts | 2 +- test/e2e/terminal.test.ts | 1 - test/unit/node/update.test.ts | 2 +- 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/node/http.ts b/src/node/http.ts index c3547cbe68ac..f7798a68c9d5 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -10,8 +10,8 @@ import { normalize } from "../common/util" import { AuthType, DefaultedArgs } from "./cli" import { version as codeServerVersion } from "./constants" import { Heart } from "./heart" -import { UpdateProvider } from "./update" import { CoderSettings, SettingsProvider } from "./settings" +import { UpdateProvider } from "./update" import { getPasswordMethod, IsCookieValidArgs, isCookieValid, sanitizeString, escapeHtml, escapeJSON } from "./util" /** diff --git a/src/node/routes/index.ts b/src/node/routes/index.ts index 622deac88cf5..81f62edeb8c6 100644 --- a/src/node/routes/index.ts +++ b/src/node/routes/index.ts @@ -14,8 +14,8 @@ import { commit, rootPath } from "../constants" import { Heart } from "../heart" import { ensureAuthenticated, redirect } from "../http" import { PluginAPI } from "../plugin" -import { UpdateProvider } from "../update" import { CoderSettings, SettingsProvider } from "../settings" +import { UpdateProvider } from "../update" import { getMediaMime, paths } from "../util" import * as apps from "./apps" import * as domainProxy from "./domainProxy" diff --git a/src/node/routes/vscode.ts b/src/node/routes/vscode.ts index 47a592a540cb..3b831335f242 100644 --- a/src/node/routes/vscode.ts +++ b/src/node/routes/vscode.ts @@ -2,8 +2,8 @@ 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 { isDevMode } from "../constants" import { ensureAuthenticated, authenticated, redirect } from "../http" import { loadAMDModule, readCompilationStats } from "../util" import { Router as WsRouter } from "../wsRouter" diff --git a/test/e2e/terminal.test.ts b/test/e2e/terminal.test.ts index 3e2010086559..5012fdef5a9b 100644 --- a/test/e2e/terminal.test.ts +++ b/test/e2e/terminal.test.ts @@ -1,5 +1,4 @@ import * as cp from "child_process" -import * as fs from "fs" import * as path from "path" import util from "util" import { clean, tmpdir } from "../utils/helpers" diff --git a/test/unit/node/update.test.ts b/test/unit/node/update.test.ts index f2b444aa2a3a..49c938b125a0 100644 --- a/test/unit/node/update.test.ts +++ b/test/unit/node/update.test.ts @@ -1,8 +1,8 @@ import * as http from "http" import * as path from "path" -import { clean, mockLogger, tmpdir } from "../../utils/helpers" 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" From ecdbe0febedc40c793eee39dda8099112948d71d Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 17 Dec 2021 00:45:56 +0000 Subject: [PATCH 09/13] fixup! fixup! Implement last opened functionality --- src/node/routes/vscode.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node/routes/vscode.ts b/src/node/routes/vscode.ts index 3b831335f242..8f4d81da3660 100644 --- a/src/node/routes/vscode.ts +++ b/src/node/routes/vscode.ts @@ -31,9 +31,9 @@ export class CodeServerRouteWrapper { }) } - // Ew means the workspace was closed so clear the last folder/workspace. 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 From 601f57ab0f9049b9520027817be38e2d59da5123 Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 17 Dec 2021 01:01:00 +0000 Subject: [PATCH 10/13] Make redirects consistent They will preserve the trailing slash if there is one. --- src/node/http.ts | 11 +++++++++-- src/node/routes/domainProxy.ts | 5 ++--- src/node/routes/pathProxy.ts | 5 ++--- src/node/routes/vscode.ts | 11 +++++------ 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/node/http.ts b/src/node/http.ts index f7798a68c9d5..dbd72d84eae8 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -139,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 = ( @@ -288,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 56b0ea1bb37f..83194b8c18c1 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/pathProxy.ts b/src/node/routes/pathProxy.ts index 5f4e9776d150..6c20ab6b3e0f 100644 --- a/src/node/routes/pathProxy.ts +++ b/src/node/routes/pathProxy.ts @@ -3,8 +3,7 @@ import * as path from "path" import * as qs from "qs" import * as pluginapi from "../../../typings/pluginapi" 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 as _proxy } from "../proxy" const getProxyTarget = (req: Request, passthroughPath?: boolean): string => { @@ -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/vscode.ts b/src/node/routes/vscode.ts index 8f4d81da3660..a962ca6df465 100644 --- a/src/node/routes/vscode.ts +++ b/src/node/routes/vscode.ts @@ -4,7 +4,7 @@ import { WebsocketRequest } from "../../../typings/pluginapi" import { logError } from "../../common/util" import { toVsCodeArgs } from "../cli" import { isDevMode } from "../constants" -import { ensureAuthenticated, authenticated, redirect } from "../http" +import { authenticated, ensureAuthenticated, redirect, self } from "../http" import { loadAMDModule, readCompilationStats } from "../util" import { Router as WsRouter } from "../wsRouter" import { errorHandler } from "./errors" @@ -25,9 +25,9 @@ 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, }) } @@ -46,9 +46,8 @@ export class CodeServerRouteWrapper { (query.folder || query.workspace) && !req.args["ignore-last-opened"] // This flag disables this behavior. ) { - // Redirect to the same page but with the query parameters attached - // (preserving the trailing slash if any). - return redirect(req, res, req.baseUrl + (req.originalUrl.endsWith("/") ? "/" : ""), { + const to = self(req) + return redirect(req, res, to, { folder: query.folder, workspace: query.workspace, }) From 2a08ee5de5f9522bd6a89dee54ede0c1968fdcf2 Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 17 Dec 2021 01:28:26 +0000 Subject: [PATCH 11/13] 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. --- ci/dev/watch.ts | 18 +----------------- src/node/routes/vscode.ts | 20 +++++++------------- src/node/util.ts | 36 ++---------------------------------- 3 files changed, 10 insertions(+), 64 deletions(-) diff --git a/ci/dev/watch.ts b/ci/dev/watch.ts index 8ed59c4230fc..55a5b14d1f4c 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/src/node/routes/vscode.ts b/src/node/routes/vscode.ts index a962ca6df465..963fe66018a9 100644 --- a/src/node/routes/vscode.ts +++ b/src/node/routes/vscode.ts @@ -5,7 +5,7 @@ import { logError } from "../../common/util" import { toVsCodeArgs } from "../cli" import { isDevMode } from "../constants" import { authenticated, ensureAuthenticated, redirect, self } from "../http" -import { loadAMDModule, readCompilationStats } from "../util" +import { loadAMDModule } from "../util" import { Router as WsRouter } from "../wsRouter" import { errorHandler } from "./errors" @@ -93,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 @@ -116,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/util.ts b/src/node/util.ts index d33163e2357d..56ae83e38c86 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 -} From 8e49c0df97ababae7158905a61396be36f6043e9 Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 17 Dec 2021 17:52:46 +0000 Subject: [PATCH 12/13] fixup! Fix test temp dirs not being cleaned up --- test/unit/node/cli.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/unit/node/cli.test.ts b/test/unit/node/cli.test.ts index 504d6a977e31..ac848c9bbc23 100644 --- a/test/unit/node/cli.test.ts +++ b/test/unit/node/cli.test.ts @@ -634,9 +634,12 @@ describe("readSocketPath", () => { let tmpDirPath: string let tmpFilePath: string - beforeEach(async () => { - const testName = "readSocketPath" + const testName = "readSocketPath" + beforeAll(async () => { await clean(testName) + }) + + beforeEach(async () => { tmpDirPath = await tmpdir(testName) tmpFilePath = path.join(tmpDirPath, "readSocketPath.txt") await fs.writeFile(tmpFilePath, fileContents) From 26f986820d8a649d231989177152972e33c0f21f Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 17 Dec 2021 12:35:25 -0600 Subject: [PATCH 13/13] Update code-server update endpoint name Co-authored-by: Joe Previte --- src/node/routes/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node/routes/index.ts b/src/node/routes/index.ts index 81f62edeb8c6..ff85f036c205 100644 --- a/src/node/routes/index.ts +++ b/src/node/routes/index.ts @@ -50,7 +50,7 @@ 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/cdr/code-server/releases/latest", settings) + 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