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
This commit is contained in:
Asher 2021-12-17 13:06:52 -06:00 committed by GitHub
parent b990dabed1
commit c4c480a068
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 406 additions and 241 deletions

View File

@ -38,6 +38,8 @@ implementation (#4414).
vscode-remote-resource endpoint still can. vscode-remote-resource endpoint still can.
- OpenVSX has been made the default marketplace. However this means web - OpenVSX has been made the default marketplace. However this means web
extensions like Vim may be broken. 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 ### Deprecated

View File

@ -1,7 +1,6 @@
import { spawn, fork, ChildProcess } from "child_process" import { spawn, fork, ChildProcess } from "child_process"
import { promises as fs } from "fs"
import * as path from "path" import * as path from "path"
import { CompilationStats, onLine, OnLineCallback } from "../../src/node/util" import { onLine, OnLineCallback } from "../../src/node/util"
interface DevelopmentCompilers { interface DevelopmentCompilers {
[key: string]: ChildProcess | undefined [key: string]: ChildProcess | undefined
@ -16,7 +15,6 @@ class Watcher {
private readonly paths = { private readonly paths = {
/** Path to uncompiled VS Code source. */ /** Path to uncompiled VS Code source. */
vscodeDir: path.join(this.rootPath, "vendor", "modules", "code-oss-dev"), vscodeDir: path.join(this.rootPath, "vendor", "modules", "code-oss-dev"),
compilationStatsFile: path.join(this.rootPath, "out", "watcher.json"),
pluginDir: process.env.PLUGIN_DIR, pluginDir: process.env.PLUGIN_DIR,
} }
@ -88,7 +86,6 @@ class Watcher {
if (strippedLine.includes("Finished compilation with")) { if (strippedLine.includes("Finished compilation with")) {
console.log("[VS Code] ✨ Finished compiling! ✨", "(Refresh your web browser ♻️)") console.log("[VS Code] ✨ Finished compiling! ✨", "(Refresh your web browser ♻️)")
this.emitCompilationStats()
this.reloadWebServer() this.reloadWebServer()
} }
} }
@ -118,19 +115,6 @@ class Watcher {
//#region Utilities //#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<void> {
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 { private dispose(code: number | null): void {
for (const [processName, devProcess] of Object.entries(this.compilers)) { for (const [processName, devProcess] of Object.entries(this.compilers)) {
console.log(`[${processName}]`, "Killing...\n") console.log(`[${processName}]`, "Killing...\n")

View File

@ -158,6 +158,7 @@
], ],
"moduleNameMapper": { "moduleNameMapper": {
"^.+\\.(css|less)$": "<rootDir>/test/utils/cssStub.ts" "^.+\\.(css|less)$": "<rootDir>/test/utils/cssStub.ts"
} },
"globalSetup": "<rootDir>/test/utils/globalUnitSetup.ts"
} }
} }

View File

@ -10,6 +10,8 @@ import { normalize } from "../common/util"
import { AuthType, DefaultedArgs } from "./cli" import { AuthType, DefaultedArgs } from "./cli"
import { version as codeServerVersion } from "./constants" import { version as codeServerVersion } from "./constants"
import { Heart } from "./heart" import { Heart } from "./heart"
import { CoderSettings, SettingsProvider } from "./settings"
import { UpdateProvider } from "./update"
import { getPasswordMethod, IsCookieValidArgs, isCookieValid, sanitizeString, escapeHtml, escapeJSON } from "./util" import { getPasswordMethod, IsCookieValidArgs, isCookieValid, sanitizeString, escapeHtml, escapeJSON } from "./util"
/** /**
@ -29,6 +31,8 @@ declare global {
export interface Request { export interface Request {
args: DefaultedArgs args: DefaultedArgs
heart: Heart heart: Heart
settings: SettingsProvider<CoderSettings>
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. * Redirect relatively to `/${to}`. Query variables on the current URI will be
* `to` should be a simple path without any query parameters * preserved. `to` should be a simple path without any query parameters
* `override` will merge with the existing query (use `undefined` to unset). * `override` will merge with the existing query (use `undefined` to unset).
*/ */
export const redirect = ( export const redirect = (
@ -284,3 +288,10 @@ export const getCookieOptions = (req: express.Request): express.CookieOptions =>
sameSite: "lax", 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)
}

View File

@ -1,7 +1,6 @@
import { Request, Router } from "express" import { Request, Router } from "express"
import { HttpCode, HttpError } from "../../common/http" import { HttpCode, HttpError } from "../../common/http"
import { normalize } from "../../common/util" import { authenticated, ensureAuthenticated, redirect, self } from "../http"
import { authenticated, ensureAuthenticated, redirect } from "../http"
import { proxy } from "../proxy" import { proxy } from "../proxy"
import { Router as WsRouter } from "../wsRouter" import { Router as WsRouter } from "../wsRouter"
@ -56,7 +55,7 @@ router.all("*", async (req, res, next) => {
return next() return next()
} }
// Redirect all other pages to the login. // Redirect all other pages to the login.
const to = normalize(`${req.baseUrl}${req.path}`) const to = self(req)
return redirect(req, res, "login", { return redirect(req, res, "login", {
to: to !== "/" ? to : undefined, to: to !== "/" ? to : undefined,
}) })

View File

@ -14,6 +14,8 @@ import { commit, rootPath } from "../constants"
import { Heart } from "../heart" import { Heart } from "../heart"
import { ensureAuthenticated, redirect } from "../http" import { ensureAuthenticated, redirect } from "../http"
import { PluginAPI } from "../plugin" import { PluginAPI } from "../plugin"
import { CoderSettings, SettingsProvider } from "../settings"
import { UpdateProvider } from "../update"
import { getMediaMime, paths } from "../util" import { getMediaMime, paths } from "../util"
import * as apps from "./apps" import * as apps from "./apps"
import * as domainProxy from "./domainProxy" import * as domainProxy from "./domainProxy"
@ -47,6 +49,9 @@ export const register = async (app: App, args: DefaultedArgs): Promise<Disposabl
app.router.use(cookieParser()) app.router.use(cookieParser())
app.wsRouter.use(cookieParser()) app.wsRouter.use(cookieParser())
const settings = new SettingsProvider<CoderSettings>(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) => { const common: express.RequestHandler = (req, _, next) => {
// /healthz|/healthz/ needs to be excluded otherwise health checks will make // /healthz|/healthz/ needs to be excluded otherwise health checks will make
// it look like code-server is always in use. // it look like code-server is always in use.
@ -57,6 +62,8 @@ export const register = async (app: App, args: DefaultedArgs): Promise<Disposabl
// Add common variables routes can use. // Add common variables routes can use.
req.args = args req.args = args
req.heart = heart req.heart = heart
req.settings = settings
req.updater = updater
next() next()
} }

View File

@ -3,8 +3,7 @@ import * as path from "path"
import * as qs from "qs" import * as qs from "qs"
import * as pluginapi from "../../../typings/pluginapi" import * as pluginapi from "../../../typings/pluginapi"
import { HttpCode, HttpError } from "../../common/http" import { HttpCode, HttpError } from "../../common/http"
import { normalize } from "../../common/util" import { authenticated, ensureAuthenticated, redirect, self } from "../http"
import { authenticated, ensureAuthenticated, redirect } from "../http"
import { proxy as _proxy } from "../proxy" import { proxy as _proxy } from "../proxy"
const getProxyTarget = (req: Request, passthroughPath?: boolean): string => { const getProxyTarget = (req: Request, passthroughPath?: boolean): string => {
@ -25,7 +24,7 @@ export function proxy(
if (!authenticated(req)) { if (!authenticated(req)) {
// If visiting the root (/:port only) redirect to the login page. // If visiting the root (/:port only) redirect to the login page.
if (!req.params[0] || req.params[0] === "/") { if (!req.params[0] || req.params[0] === "/") {
const to = normalize(`${req.baseUrl}${req.path}`) const to = self(req)
return redirect(req, res, "login", { return redirect(req, res, "login", {
to: to !== "/" ? to : undefined, to: to !== "/" ? to : undefined,
}) })

View File

@ -1,18 +1,15 @@
import { Router } from "express" import { Router } from "express"
import { version } from "../constants" import { version } from "../constants"
import { ensureAuthenticated } from "../http" import { ensureAuthenticated } from "../http"
import { UpdateProvider } from "../update"
export const router = Router() export const router = Router()
const provider = new UpdateProvider()
router.get("/check", ensureAuthenticated, async (req, res) => { 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({ res.json({
checked: update.checked, checked: update.checked,
latest: update.version, latest: update.version,
current: version, current: version,
isLatest: provider.isLatestVersion(update), isLatest: req.updater.isLatestVersion(update),
}) })
}) })

View File

@ -2,10 +2,10 @@ import { logger } from "@coder/logger"
import * as express from "express" import * as express from "express"
import { WebsocketRequest } from "../../../typings/pluginapi" import { WebsocketRequest } from "../../../typings/pluginapi"
import { logError } from "../../common/util" import { logError } from "../../common/util"
import { isDevMode } from "../constants"
import { toVsCodeArgs } from "../cli" import { toVsCodeArgs } from "../cli"
import { ensureAuthenticated, authenticated, redirect } from "../http" import { isDevMode } from "../constants"
import { loadAMDModule, readCompilationStats } from "../util" import { authenticated, ensureAuthenticated, redirect, self } from "../http"
import { loadAMDModule } from "../util"
import { Router as WsRouter } from "../wsRouter" import { Router as WsRouter } from "../wsRouter"
import { errorHandler } from "./errors" import { errorHandler } from "./errors"
@ -25,12 +25,39 @@ export class CodeServerRouteWrapper {
const isAuthenticated = await authenticated(req) const isAuthenticated = await authenticated(req)
if (!isAuthenticated) { if (!isAuthenticated) {
const to = self(req)
return redirect(req, res, "login", { return redirect(req, res, "login", {
// req.baseUrl can be blank if already at the root. to: to !== "/" ? to : undefined,
to: req.baseUrl && req.baseUrl !== "/" ? req.baseUrl : 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() next()
} }
@ -66,15 +93,6 @@ export class CodeServerRouteWrapper {
return next() 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... // Create the server...
const { args } = req const { args } = req
@ -89,9 +107,12 @@ export class CodeServerRouteWrapper {
try { try {
this._codeServerMain = await createVSServer(null, await toVsCodeArgs(args)) this._codeServerMain = await createVSServer(null, await toVsCodeArgs(args))
} catch (createServerError) { } catch (error) {
logError(logger, "CodeServerRouteWrapper", createServerError) logError(logger, "CodeServerRouteWrapper", error)
return next(createServerError) if (isDevMode) {
return next(new Error((error instanceof Error ? error.message : error) + " (VS Code may still be compiling)"))
}
return next(error)
} }
return next() return next()

View File

@ -1,8 +1,6 @@
import { logger } from "@coder/logger" import { logger } from "@coder/logger"
import { Query } from "express-serve-static-core" import { Query } from "express-serve-static-core"
import { promises as fs } from "fs" import { promises as fs } from "fs"
import * as path from "path"
import { paths } from "./util"
export type Settings = { [key: string]: Settings | string | boolean | number } export type Settings = { [key: string]: Settings | string | boolean | number }
@ -54,14 +52,5 @@ export interface UpdateSettings {
* Global code-server settings. * Global code-server settings.
*/ */
export interface CoderSettings extends UpdateSettings { export interface CoderSettings extends UpdateSettings {
lastVisited: { query?: Query
url: string
workspace: boolean
}
query: Query
} }
/**
* Global code-server settings file.
*/
export const settings = new SettingsProvider<CoderSettings>(path.join(paths.data, "coder.json"))

View File

@ -4,7 +4,7 @@ import * as https from "https"
import * as semver from "semver" import * as semver from "semver"
import * as url from "url" import * as url from "url"
import { version } from "./constants" import { version } from "./constants"
import { settings as globalSettings, SettingsProvider, UpdateSettings } from "./settings" import { SettingsProvider, UpdateSettings } from "./settings"
export interface Update { export interface Update {
checked: number checked: number
@ -27,12 +27,11 @@ export class UpdateProvider {
* The URL for getting the latest version of code-server. Should return JSON * The URL for getting the latest version of code-server. Should return JSON
* that fulfills `LatestResponse`. * 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 * Update information will be stored here.
* settings will be used.
*/ */
private readonly settings: SettingsProvider<UpdateSettings> = globalSettings, private readonly settings: SettingsProvider<UpdateSettings>,
) {} ) {}
/** /**

View File

@ -3,15 +3,14 @@ import * as argon2 from "argon2"
import * as cp from "child_process" import * as cp from "child_process"
import * as crypto from "crypto" import * as crypto from "crypto"
import envPaths from "env-paths" 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 net from "net"
import * as os from "os" import * as os from "os"
import * as path from "path" import * as path from "path"
import safeCompare from "safe-compare" import safeCompare from "safe-compare"
import * as util from "util" import * as util from "util"
import xdgBasedir from "xdg-basedir" import xdgBasedir from "xdg-basedir"
import { logError } from "../common/util" import { vsRootPath } from "./constants"
import { isDevMode, rootPath, vsRootPath } from "./constants"
export interface Paths { export interface Paths {
data: string data: string
@ -523,34 +522,3 @@ export const loadAMDModule = async <T>(amdPath: string, exportName: string): Pro
return module[exportName] as T return module[exportName] as T
} }
export interface CompilationStats {
lastCompiledAt: Date
}
export const readCompilationStats = async (): Promise<null | CompilationStats> => {
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
}

View File

@ -1,8 +1,7 @@
import * as cp from "child_process" import * as cp from "child_process"
import * as fs from "fs"
import * as path from "path" import * as path from "path"
import util from "util" import util from "util"
import { tmpdir } from "../utils/helpers" import { clean, tmpdir } from "../utils/helpers"
import { describe, expect, test } from "./baseFixture" import { describe, expect, test } from "./baseFixture"
describe("Integrated Terminal", true, () => { describe("Integrated Terminal", true, () => {
@ -10,20 +9,16 @@ describe("Integrated Terminal", true, () => {
// so we don't have to logged in // so we don't have to logged in
const testFileName = "pipe" const testFileName = "pipe"
const testString = "new string test from e2e test" const testString = "new string test from e2e test"
let tmpFolderPath = ""
let tmpFile = ""
const testName = "integrated-terminal"
test.beforeAll(async () => { test.beforeAll(async () => {
tmpFolderPath = await tmpdir("integrated-terminal") await clean(testName)
tmpFile = path.join(tmpFolderPath, testFileName)
})
test.afterAll(async () => {
// Ensure directory was removed
await fs.promises.rmdir(tmpFolderPath, { recursive: true })
}) })
test("should echo a string to a file", async ({ codeServerPage }) => { 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 command = `mkfifo '${tmpFile}' && cat '${tmpFile}'`
const exec = util.promisify(cp.exec) const exec = util.promisify(cp.exec)
const output = exec(command, { encoding: "utf8" }) const output = exec(command, { encoding: "utf8" })

View File

@ -12,7 +12,7 @@ const config: PlaywrightTestConfig = {
testDir: path.join(__dirname, "e2e"), // Search for tests in this directory. testDir: path.join(__dirname, "e2e"), // Search for tests in this directory.
timeout: 60000, // Each test is given 60 seconds. timeout: 60000, // Each test is given 60 seconds.
retries: process.env.CI ? 2 : 1, // Retry in CI due to flakiness. 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", reporter: "list",
// Put any shared options on the top level. // Put any shared options on the top level.
use: { use: {

View File

@ -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 { logger } from "@coder/logger"
import { Emitter } from "../../../src/common/emitter" import { Emitter } from "../../../src/common/emitter"
import { mockLogger } from "../../utils/helpers"
describe("emitter", () => { describe("emitter", () => {
let spy: jest.SpyInstance
beforeEach(() => { beforeEach(() => {
spy = jest.spyOn(logger, "error") mockLogger()
}) })
afterEach(() => { afterEach(() => {
jest.clearAllMocks() jest.clearAllMocks()
}) })
afterAll(() => {
jest.restoreAllMocks()
})
it("should run the correct callbacks", async () => { it("should run the correct callbacks", async () => {
const HELLO_WORLD = "HELLO_WORLD" const HELLO_WORLD = "HELLO_WORLD"
const GOODBYE_WORLD = "GOODBYE_WORLD" const GOODBYE_WORLD = "GOODBYE_WORLD"
@ -85,8 +77,8 @@ describe("emitter", () => {
await emitter.emit({ event: HELLO_WORLD, callback: mockCallback }) await emitter.emit({ event: HELLO_WORLD, callback: mockCallback })
// Check that error was called // Check that error was called
expect(spy).toHaveBeenCalled() expect(logger.error).toHaveBeenCalled()
expect(spy).toHaveBeenCalledTimes(1) expect(logger.error).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith(message) expect(logger.error).toHaveBeenCalledWith(message)
}) })
}) })

View File

@ -1,6 +1,7 @@
import { logger } from "@coder/logger"
import { JSDOM } from "jsdom" import { JSDOM } from "jsdom"
import * as util from "../../../src/common/util" import * as util from "../../../src/common/util"
import { createLoggerMock } from "../../utils/helpers" import { mockLogger } from "../../utils/helpers"
const dom = new JSDOM() const dom = new JSDOM()
global.document = dom.window.document global.document = dom.window.document
@ -94,31 +95,29 @@ describe("util", () => {
}) })
describe("logError", () => { describe("logError", () => {
beforeAll(() => {
mockLogger()
})
afterEach(() => { afterEach(() => {
jest.clearAllMocks() jest.clearAllMocks()
}) })
afterAll(() => {
jest.restoreAllMocks()
})
const loggerModule = createLoggerMock()
it("should log an error with the message and stack trace", () => { it("should log an error with the message and stack trace", () => {
const message = "You don't have access to that folder." const message = "You don't have access to that folder."
const error = new Error(message) const error = new Error(message)
util.logError(loggerModule.logger, "ui", error) util.logError(logger, "ui", error)
expect(loggerModule.logger.error).toHaveBeenCalled() expect(logger.error).toHaveBeenCalled()
expect(loggerModule.logger.error).toHaveBeenCalledWith(`ui: ${error.message} ${error.stack}`) expect(logger.error).toHaveBeenCalledWith(`ui: ${error.message} ${error.stack}`)
}) })
it("should log an error, even if not an instance of error", () => { 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(logger.error).toHaveBeenCalled()
expect(loggerModule.logger.error).toHaveBeenCalledWith("api: oh no") expect(logger.error).toHaveBeenCalledWith("api: oh no")
}) })
}) })
}) })

View File

@ -1,12 +1,16 @@
import { promises as fs } from "fs" 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). * This file is for testing test helpers (not core code).
*/ */
describe("test helpers", () => { describe("test helpers", () => {
const testName = "temp-dir"
beforeAll(async () => {
await clean(testName)
})
it("should return a temp directory", async () => { it("should return a temp directory", async () => {
const testName = "temp-dir"
const pathToTempDir = await tmpdir(testName) const pathToTempDir = await tmpdir(testName)
expect(pathToTempDir).toContain(testName) expect(pathToTempDir).toContain(testName)
expect(fs.access(pathToTempDir)).resolves.toStrictEqual(undefined) expect(fs.access(pathToTempDir)).resolves.toStrictEqual(undefined)

View File

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

View File

@ -1,27 +1,29 @@
import { logger } from "@coder/logger" import { logger } from "@coder/logger"
import { promises, rmdirSync } from "fs" import { promises } from "fs"
import * as http from "http" import * as http from "http"
import * as https from "https" import * as https from "https"
import * as path from "path" import * as path from "path"
import { createApp, ensureAddress, handleArgsSocketCatchError, handleServerError } from "../../../src/node/app" import { createApp, ensureAddress, handleArgsSocketCatchError, handleServerError } from "../../../src/node/app"
import { OptionalString, setDefaults } from "../../../src/node/cli" import { OptionalString, setDefaults } from "../../../src/node/cli"
import { generateCertificate } from "../../../src/node/util" import { generateCertificate } from "../../../src/node/util"
import { getAvailablePort, tmpdir } from "../../utils/helpers" import { clean, mockLogger, getAvailablePort, tmpdir } from "../../utils/helpers"
describe("createApp", () => { describe("createApp", () => {
let spy: jest.SpyInstance
let unlinkSpy: jest.SpyInstance let unlinkSpy: jest.SpyInstance
let port: number let port: number
let tmpDirPath: string let tmpDirPath: string
let tmpFilePath: string let tmpFilePath: string
beforeAll(async () => { 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") tmpFilePath = path.join(tmpDirPath, "unlink-socket-file")
}) })
beforeEach(async () => { beforeEach(async () => {
spy = jest.spyOn(logger, "error")
// NOTE:@jsjoeio // NOTE:@jsjoeio
// Be mindful when spying. // Be mindful when spying.
// You can't spy on fs functions if you do import * as fs // You can't spy on fs functions if you do import * as fs
@ -36,12 +38,6 @@ describe("createApp", () => {
jest.clearAllMocks() 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 () => { it("should return an Express app, a WebSockets Express app and an http server", async () => {
const defaultArgs = await setDefaults({ const defaultArgs = await setDefaults({
port, port,
@ -70,8 +66,8 @@ describe("createApp", () => {
// By emitting an error event // By emitting an error event
// Ref: https://stackoverflow.com/a/33872506/3015595 // Ref: https://stackoverflow.com/a/33872506/3015595
app.server.emit("error", testError) app.server.emit("error", testError)
expect(spy).toHaveBeenCalledTimes(1) expect(logger.error).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith(`http server error: ${testError.message} ${testError.stack}`) expect(logger.error).toHaveBeenCalledWith(`http server error: ${testError.message} ${testError.stack}`)
// Cleanup // Cleanup
app.dispose() app.dispose()
@ -152,20 +148,14 @@ describe("ensureAddress", () => {
}) })
describe("handleServerError", () => { describe("handleServerError", () => {
let spy: jest.SpyInstance beforeAll(() => {
mockLogger()
beforeEach(() => {
spy = jest.spyOn(logger, "error")
}) })
afterEach(() => { afterEach(() => {
jest.clearAllMocks() jest.clearAllMocks()
}) })
afterAll(() => {
jest.restoreAllMocks()
})
it("should call reject if resolved is false", async () => { it("should call reject if resolved is false", async () => {
const resolved = false const resolved = false
const reject = jest.fn((err: Error) => undefined) const reject = jest.fn((err: Error) => undefined)
@ -184,33 +174,27 @@ describe("handleServerError", () => {
handleServerError(resolved, error, reject) handleServerError(resolved, error, reject)
expect(spy).toHaveBeenCalledTimes(1) expect(logger.error).toHaveBeenCalledTimes(1)
expect(spy).toThrowErrorMatchingSnapshot() expect(logger.error).toHaveBeenCalledWith(`http server error: ${error.message} ${error.stack}`)
}) })
}) })
describe("handleArgsSocketCatchError", () => { describe("handleArgsSocketCatchError", () => {
let spy: jest.SpyInstance beforeAll(() => {
mockLogger()
beforeEach(() => {
spy = jest.spyOn(logger, "error")
}) })
afterEach(() => { afterEach(() => {
jest.clearAllMocks() jest.clearAllMocks()
}) })
afterAll(() => {
jest.restoreAllMocks()
})
it("should log an error if its not an NodeJS.ErrnoException", () => { it("should log an error if its not an NodeJS.ErrnoException", () => {
const error = new Error() const error = new Error()
handleArgsSocketCatchError(error) handleArgsSocketCatchError(error)
expect(spy).toHaveBeenCalledTimes(1) expect(logger.error).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith(error) expect(logger.error).toHaveBeenCalledWith(error)
}) })
it("should log an error if its not an NodeJS.ErrnoException (and the error has a message)", () => { 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) handleArgsSocketCatchError(error)
expect(spy).toHaveBeenCalledTimes(1) expect(logger.error).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith(errorMessage) expect(logger.error).toHaveBeenCalledWith(errorMessage)
}) })
it("should not log an error if its a iNodeJS.ErrnoException", () => { it("should not log an error if its a iNodeJS.ErrnoException", () => {
@ -229,7 +213,7 @@ describe("handleArgsSocketCatchError", () => {
handleArgsSocketCatchError(error) 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)", () => { 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) handleArgsSocketCatchError(error)
expect(spy).toHaveBeenCalledTimes(1) expect(logger.error).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith(errorMessage) expect(logger.error).toHaveBeenCalledWith(errorMessage)
}) })
it("should log an error if the code is not ENOENT", () => { it("should log an error if the code is not ENOENT", () => {
@ -250,7 +234,7 @@ describe("handleArgsSocketCatchError", () => {
handleArgsSocketCatchError(error) handleArgsSocketCatchError(error)
expect(spy).toHaveBeenCalledTimes(1) expect(logger.error).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith(error) expect(logger.error).toHaveBeenCalledWith(error)
}) })
}) })

View File

@ -361,13 +361,11 @@ describe("parser", () => {
}) })
describe("cli", () => { describe("cli", () => {
let testDir: string const testName = "cli"
const vscodeIpcPath = path.join(os.tmpdir(), "vscode-ipc") const vscodeIpcPath = path.join(os.tmpdir(), "vscode-ipc")
beforeAll(async () => { beforeAll(async () => {
testDir = await tmpdir("cli") await clean(testName)
await fs.rmdir(testDir, { recursive: true })
await fs.mkdir(testDir, { recursive: true })
}) })
beforeEach(async () => { beforeEach(async () => {
@ -416,6 +414,7 @@ describe("cli", () => {
args._ = ["./file"] args._ = ["./file"]
expect(await shouldOpenInExistingInstance(args)).toStrictEqual(undefined) expect(await shouldOpenInExistingInstance(args)).toStrictEqual(undefined)
const testDir = await tmpdir(testName)
const socketPath = path.join(testDir, "socket") const socketPath = path.join(testDir, "socket")
await fs.writeFile(vscodeIpcPath, socketPath) await fs.writeFile(vscodeIpcPath, socketPath)
expect(await shouldOpenInExistingInstance(args)).toStrictEqual(undefined) expect(await shouldOpenInExistingInstance(args)).toStrictEqual(undefined)
@ -635,14 +634,15 @@ describe("readSocketPath", () => {
let tmpDirPath: string let tmpDirPath: string
let tmpFilePath: string let tmpFilePath: string
beforeEach(async () => { const testName = "readSocketPath"
tmpDirPath = await tmpdir("readSocketPath") beforeAll(async () => {
tmpFilePath = path.join(tmpDirPath, "readSocketPath.txt") await clean(testName)
await fs.writeFile(tmpFilePath, fileContents)
}) })
afterEach(async () => { beforeEach(async () => {
await fs.rmdir(tmpDirPath, { recursive: true }) 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 () => { it("should throw an error if it can't read the file", async () => {
@ -677,9 +677,10 @@ describe("toVsCodeArgs", () => {
version: false, version: false,
} }
const testName = "vscode-args"
beforeAll(async () => { beforeAll(async () => {
// Clean up temporary directories from the previous run. // Clean up temporary directories from the previous run.
await clean("vscode-args") await clean(testName)
}) })
it("should convert empty args", async () => { it("should convert empty args", async () => {
@ -691,7 +692,7 @@ describe("toVsCodeArgs", () => {
}) })
it("should convert with workspace", async () => { 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") await fs.writeFile(workspace, "foobar")
expect(await toVsCodeArgs(await setDefaults(parse([workspace])))).toStrictEqual({ expect(await toVsCodeArgs(await setDefaults(parse([workspace])))).toStrictEqual({
...vscodeDefaults, ...vscodeDefaults,
@ -702,7 +703,7 @@ describe("toVsCodeArgs", () => {
}) })
it("should convert with folder", async () => { 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({ expect(await toVsCodeArgs(await setDefaults(parse([folder])))).toStrictEqual({
...vscodeDefaults, ...vscodeDefaults,
folder, folder,
@ -712,7 +713,7 @@ describe("toVsCodeArgs", () => {
}) })
it("should ignore regular file", async () => { 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") await fs.writeFile(file, "foobar")
expect(await toVsCodeArgs(await setDefaults(parse([file])))).toStrictEqual({ expect(await toVsCodeArgs(await setDefaults(parse([file])))).toStrictEqual({
...vscodeDefaults, ...vscodeDefaults,

View File

@ -1,10 +1,10 @@
import { createLoggerMock } from "../../utils/helpers" import { logger } from "@coder/logger"
import { mockLogger } from "../../utils/helpers"
describe("constants", () => { describe("constants", () => {
let constants: typeof import("../../../src/node/constants") let constants: typeof import("../../../src/node/constants")
describe("with package.json defined", () => { describe("with package.json defined", () => {
const loggerModule = createLoggerMock()
const mockPackageJson = { const mockPackageJson = {
name: "mock-code-server", name: "mock-code-server",
description: "Run VS Code on a remote server.", description: "Run VS Code on a remote server.",
@ -14,7 +14,7 @@ describe("constants", () => {
} }
beforeAll(() => { beforeAll(() => {
jest.mock("@coder/logger", () => loggerModule) mockLogger()
jest.mock("../../../package.json", () => mockPackageJson, { virtual: true }) jest.mock("../../../package.json", () => mockPackageJson, { virtual: true })
constants = require("../../../src/node/constants") constants = require("../../../src/node/constants")
}) })
@ -38,8 +38,8 @@ describe("constants", () => {
constants.getPackageJson("./package.json") constants.getPackageJson("./package.json")
expect(loggerModule.logger.warn).toHaveBeenCalled() expect(logger.warn).toHaveBeenCalled()
expect(loggerModule.logger.warn).toHaveBeenCalledWith(expectedErrorMessage) expect(logger.warn).toHaveBeenCalledWith(expectedErrorMessage)
}) })
it("should find the package.json", () => { it("should find the package.json", () => {

View File

@ -1,11 +1,15 @@
import { shouldEnableProxy } from "../../../src/node/proxy_agent" import { shouldEnableProxy } from "../../../src/node/proxy_agent"
import { useEnv } from "../../utils/helpers" import { mockLogger, useEnv } from "../../utils/helpers"
describe("shouldEnableProxy", () => { describe("shouldEnableProxy", () => {
const [setHTTPProxy, resetHTTPProxy] = useEnv("HTTP_PROXY") const [setHTTPProxy, resetHTTPProxy] = useEnv("HTTP_PROXY")
const [setHTTPSProxy, resetHTTPSProxy] = useEnv("HTTPS_PROXY") const [setHTTPSProxy, resetHTTPSProxy] = useEnv("HTTPS_PROXY")
const [setNoProxy, resetNoProxy] = useEnv("NO_PROXY") const [setNoProxy, resetNoProxy] = useEnv("NO_PROXY")
beforeAll(() => {
mockLogger()
})
beforeEach(() => { beforeEach(() => {
jest.resetModules() // Most important - it clears the cache jest.resetModules() // Most important - it clears the cache
resetHTTPProxy() resetHTTPProxy()

View File

@ -1,8 +1,13 @@
import { RateLimiter } from "../../../../src/node/routes/login" import { RateLimiter } from "../../../../src/node/routes/login"
import { mockLogger } from "../../../utils/helpers"
import * as httpserver from "../../../utils/httpserver" import * as httpserver from "../../../utils/httpserver"
import * as integration from "../../../utils/integration" import * as integration from "../../../utils/integration"
describe("login", () => { describe("login", () => {
beforeAll(() => {
mockLogger()
})
describe("RateLimiter", () => { describe("RateLimiter", () => {
it("should allow one try ", () => { it("should allow one try ", () => {
const limiter = new RateLimiter() const limiter = new RateLimiter()

View File

@ -1,7 +1,7 @@
import { promises as fs } from "fs" import { promises as fs } from "fs"
import * as path from "path" import * as path from "path"
import { rootPath } from "../../../../src/node/constants" 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 httpserver from "../../../utils/httpserver"
import * as integration from "../../../utils/integration" import * as integration from "../../../utils/integration"
@ -23,8 +23,10 @@ describe("/_static", () => {
let testFileContent: string | undefined let testFileContent: string | undefined
let nonExistentTestFile: string | undefined let nonExistentTestFile: string | undefined
const testName = "_static"
beforeAll(async () => { beforeAll(async () => {
const testDir = await tmpdir("_static") await clean(testName)
const testDir = await tmpdir(testName)
testFile = path.join(testDir, "test") testFile = path.join(testDir, "test")
testFileContent = "static file contents" testFileContent = "static file contents"
nonExistentTestFile = path.join(testDir, "i-am-not-here") nonExistentTestFile = path.join(testDir, "i-am-not-here")

View File

@ -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<WorkbenchConfig> => {
expect(resp.status).toBe(200)
const html = await resp.text()
const match = html.match(/<meta id="vscode-workbench-web-configuration" data-settings="(.+)">/)
if (!match || !match[1]) {
throw new Error("Unable to find workbench configuration")
}
const config = match[1].replace(/&quot;/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()
})
})

View File

@ -1,9 +1,8 @@
import { promises as fs } from "fs"
import * as http from "http" import * as http from "http"
import * as path from "path" import * as path from "path"
import { tmpdir } from "../../../src/node/constants"
import { SettingsProvider, UpdateSettings } from "../../../src/node/settings" import { SettingsProvider, UpdateSettings } from "../../../src/node/settings"
import { LatestResponse, UpdateProvider } from "../../../src/node/update" import { LatestResponse, UpdateProvider } from "../../../src/node/update"
import { clean, mockLogger, tmpdir } from "../../utils/helpers"
describe("update", () => { describe("update", () => {
let version = "1.0.0" let version = "1.0.0"
@ -29,22 +28,31 @@ describe("update", () => {
response.end("not found") response.end("not found")
}) })
const jsonPath = path.join(tmpdir, "tests/updates/update.json") let _settings: SettingsProvider<UpdateSettings> | undefined
const settings = new SettingsProvider<UpdateSettings>(jsonPath) const settings = (): SettingsProvider<UpdateSettings> => {
if (!_settings) {
throw new Error("Settings provider has not been created")
}
return _settings
}
let _provider: UpdateProvider | undefined let _provider: UpdateProvider | undefined
const provider = (): UpdateProvider => { const provider = (): UpdateProvider => {
if (!_provider) { if (!_provider) {
const address = server.address() throw new Error("Update provider has not been created")
if (!address || typeof address === "string" || !address.port) {
throw new Error("unexpected address")
}
_provider = new UpdateProvider(`http://${address.address}:${address.port}/latest`, settings)
} }
return _provider return _provider
} }
beforeAll(async () => { beforeAll(async () => {
mockLogger()
const testName = "update"
await clean(testName)
const testDir = await tmpdir(testName)
const jsonPath = path.join(testDir, "update.json")
_settings = new SettingsProvider<UpdateSettings>(jsonPath)
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
server.on("error", reject) server.on("error", reject)
server.on("listening", resolve) server.on("listening", resolve)
@ -53,8 +61,13 @@ describe("update", () => {
host: "localhost", 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(() => { afterAll(() => {
@ -72,7 +85,7 @@ describe("update", () => {
const now = Date.now() const now = Date.now()
const update = await p.getUpdate() 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(isNaN(update.checked)).toEqual(false)
expect(update.checked < Date.now() && update.checked >= now).toEqual(true) expect(update.checked < Date.now() && update.checked >= now).toEqual(true)
expect(update.version).toStrictEqual("2.1.0") expect(update.version).toStrictEqual("2.1.0")
@ -86,7 +99,7 @@ describe("update", () => {
const now = Date.now() const now = Date.now()
const update = await p.getUpdate() 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(isNaN(update.checked)).toStrictEqual(false)
expect(update.checked < now).toBe(true) expect(update.checked < now).toBe(true)
expect(update.version).toStrictEqual("2.1.0") expect(update.version).toStrictEqual("2.1.0")
@ -100,7 +113,7 @@ describe("update", () => {
const now = Date.now() const now = Date.now()
const update = await p.getUpdate(true) 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(isNaN(update.checked)).toStrictEqual(false)
expect(update.checked < Date.now() && update.checked >= now).toStrictEqual(true) expect(update.checked < Date.now() && update.checked >= now).toStrictEqual(true)
expect(update.version).toStrictEqual("4.1.1") expect(update.version).toStrictEqual("4.1.1")
@ -113,12 +126,12 @@ describe("update", () => {
expect(spy).toEqual([]) expect(spy).toEqual([])
let checked = Date.now() - 1000 * 60 * 60 * 23 let checked = Date.now() - 1000 * 60 * 60 * 23
await settings.write({ update: { checked, version } }) await settings().write({ update: { checked, version } })
await p.getUpdate() await p.getUpdate()
expect(spy).toEqual([]) expect(spy).toEqual([])
checked = Date.now() - 1000 * 60 * 60 * 25 checked = Date.now() - 1000 * 60 * 60 * 25
await settings.write({ update: { checked, version } }) await settings().write({ update: { checked, version } })
const update = await p.getUpdate() const update = await p.getUpdate()
expect(update.checked).not.toStrictEqual(checked) expect(update.checked).not.toStrictEqual(checked)
@ -143,14 +156,14 @@ describe("update", () => {
}) })
it("should not reject if unable to fetch", async () => { 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 now = Date.now()
let update = await provider.getUpdate(true) let update = await provider.getUpdate(true)
expect(isNaN(update.checked)).toStrictEqual(false) expect(isNaN(update.checked)).toStrictEqual(false)
expect(update.checked < Date.now() && update.checked >= now).toEqual(true) expect(update.checked < Date.now() && update.checked >= now).toEqual(true)
expect(update.version).toStrictEqual("unknown") 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() now = Date.now()
update = await provider.getUpdate(true) update = await provider.getUpdate(true)
expect(isNaN(update.checked)).toStrictEqual(false) expect(isNaN(update.checked)).toStrictEqual(false)

View File

@ -6,8 +6,8 @@ import { clean } from "./helpers"
import * as wtfnode from "./wtfnode" import * as wtfnode from "./wtfnode"
/** /**
* Perform workspace cleanup and authenticate. This should be set up to run * Perform workspace cleanup and authenticate. This should be ran before e2e
* before our tests execute. * tests execute.
*/ */
export default async function () { export default async function () {
console.log("\n🚨 Running Global Setup for Playwright End-to-End Tests") console.log("\n🚨 Running Global Setup for Playwright End-to-End Tests")

View File

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

View File

@ -1,23 +1,26 @@
import { logger } from "@coder/logger"
import { promises as fs } from "fs" import { promises as fs } from "fs"
import * as net from "net" import * as net from "net"
import * as os from "os" import * as os from "os"
import * as path from "path" 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() { export function mockLogger() {
return { jest.spyOn(logger, "debug").mockImplementation()
field: jest.fn(), jest.spyOn(logger, "error").mockImplementation()
level: 2, jest.spyOn(logger, "info").mockImplementation()
logger: { jest.spyOn(logger, "trace").mockImplementation()
debug: jest.fn(), jest.spyOn(logger, "warn").mockImplementation()
error: jest.fn(),
info: jest.fn(), jest.spyOn(console, "log").mockImplementation()
trace: jest.fn(), jest.spyOn(console, "debug").mockImplementation()
warn: jest.fn(), 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<void> {
/** /**
* Create a uniquely named temporary directory for a test. * 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<string> { export async function tmpdir(testName: string): Promise<string> {
const dir = path.join(os.tmpdir(), `code-server/tests/${testName}`) const dir = path.join(os.tmpdir(), `code-server/tests/${testName}`)

View File

@ -59,13 +59,17 @@ export class HttpServer {
* fetch fetches the request path. * fetch fetches the request path.
* The request path must be rooted! * The request path must be rooted!
*/ */
public fetch(requestPath: string, opts?: RequestInit): Promise<Response> { public fetch(requestPath: string, opts?: RequestInit, query?: { [key: string]: string }): Promise<Response> {
const address = ensureAddress(this.hs, "http") const address = ensureAddress(this.hs, "http")
if (typeof address === "string") { if (typeof address === "string") {
throw new Error("Cannot fetch socket path") throw new Error("Cannot fetch socket path")
} }
address.pathname = requestPath address.pathname = requestPath
if (query) {
Object.keys(query).forEach((key) => {
address.searchParams.append(key, query[key])
})
}
return nodeFetch(address.toString(), opts) return nodeFetch(address.toString(), opts)
} }

View File

@ -1,11 +1,27 @@
import { promises as fs } from "fs"
import * as path from "path"
import { parse, parseConfigFile, setDefaults } from "../../src/node/cli" import { parse, parseConfigFile, setDefaults } from "../../src/node/cli"
import { runCodeServer } from "../../src/node/main" import { runCodeServer } from "../../src/node/main"
import { workspaceDir } from "./constants"
import { tmpdir } from "./helpers"
import * as httpserver from "./httpserver" import * as httpserver from "./httpserver"
export async function setup(argv: string[], configFile?: string): Promise<httpserver.HttpServer> { export async function setup(argv: string[], configFile?: string): Promise<httpserver.HttpServer> {
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 configArgs = parseConfigFile(configFile || "", "test/integration.ts")
const args = await setDefaults(cliArgs, configArgs) const args = await setDefaults(cliArgs, configArgs)