mirror of
https://github.com/coder/code-server.git
synced 2025-08-02 14:02:51 +08:00
Add language customization flag (#7374)
This allows you to customize any string (that has a translation) or add your own translations.
This commit is contained in:

committed by
GitHub

parent
8b3d9b9e0a
commit
92fca0dcc3
@ -22,6 +22,9 @@
|
|||||||
- [Proxying to a Svelte app](#proxying-to-a-svelte-app)
|
- [Proxying to a Svelte app](#proxying-to-a-svelte-app)
|
||||||
- [Prefixing `/absproxy/<port>` with a path](#prefixing-absproxyport-with-a-path)
|
- [Prefixing `/absproxy/<port>` with a path](#prefixing-absproxyport-with-a-path)
|
||||||
- [Preflight requests](#preflight-requests)
|
- [Preflight requests](#preflight-requests)
|
||||||
|
- [Internationalization and customization](#internationalization-and-customization)
|
||||||
|
- [Available keys and placeholders](#available-keys-and-placeholders)
|
||||||
|
- [Legacy flag](#legacy-flag)
|
||||||
|
|
||||||
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||||
<!-- prettier-ignore-end -->
|
<!-- prettier-ignore-end -->
|
||||||
@ -458,3 +461,45 @@ By default, if you have auth enabled, code-server will authenticate all proxied
|
|||||||
requests including preflight requests. This can cause issues because preflight
|
requests including preflight requests. This can cause issues because preflight
|
||||||
requests do not typically include credentials. To allow all preflight requests
|
requests do not typically include credentials. To allow all preflight requests
|
||||||
through the proxy without authentication, use `--skip-auth-preflight`.
|
through the proxy without authentication, use `--skip-auth-preflight`.
|
||||||
|
|
||||||
|
## Internationalization and customization
|
||||||
|
|
||||||
|
code-server allows you to provide a JSON file to configure certain strings. This can be used for both internationalization and customization.
|
||||||
|
|
||||||
|
Create a JSON file with your custom strings:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"WELCOME": "Welcome to {{app}}",
|
||||||
|
"LOGIN_TITLE": "{{app}} Access Portal",
|
||||||
|
"LOGIN_BELOW": "Please log in to continue",
|
||||||
|
"PASSWORD_PLACEHOLDER": "Enter Password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then reference the file:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
code-server --i18n /path/to/custom-strings.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Or this can be done in the config file:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
i18n: /path/to/custom-strings.json
|
||||||
|
```
|
||||||
|
|
||||||
|
You can combine this with the `--locale` flag to configure language support for both code-server and VS Code in cases where code-server has no support but VS Code does. If you are using this for internationalization, please consider sending us a pull request to contribute it to `src/node/i18n/locales`.
|
||||||
|
|
||||||
|
### Available keys and placeholders
|
||||||
|
|
||||||
|
Refer to [../src/node/i18n/locales/en.json](../src/node/i18n/locales/en.json) for a full list of the available keys for translations. Note that the only placeholders supported for each key are the ones used in the default string.
|
||||||
|
|
||||||
|
The `--app-name` flag controls the `{{app}}` placeholder in templates. If you want to change the name, you can either:
|
||||||
|
|
||||||
|
1. Set `--app-name` (potentially alongside `--i18n`)
|
||||||
|
2. Use `--i18n` and hardcode the name in your strings
|
||||||
|
|
||||||
|
### Legacy flag
|
||||||
|
|
||||||
|
The `--welcome-text` flag is now deprecated. Use the `WELCOME` key instead.
|
||||||
|
@ -93,6 +93,7 @@ export interface UserProvidedArgs extends UserProvidedCodeArgs {
|
|||||||
"app-name"?: string
|
"app-name"?: string
|
||||||
"welcome-text"?: string
|
"welcome-text"?: string
|
||||||
"abs-proxy-base-path"?: string
|
"abs-proxy-base-path"?: string
|
||||||
|
i18n?: string
|
||||||
/* Positional arguments. */
|
/* Positional arguments. */
|
||||||
_?: string[]
|
_?: string[]
|
||||||
}
|
}
|
||||||
@ -284,17 +285,24 @@ export const options: Options<Required<UserProvidedArgs>> = {
|
|||||||
"app-name": {
|
"app-name": {
|
||||||
type: "string",
|
type: "string",
|
||||||
short: "an",
|
short: "an",
|
||||||
description: "The name to use in branding. Will be shown in titlebar and welcome message",
|
description:
|
||||||
|
"Will replace the {{app}} placeholder in any strings, which by default includes the title bar and welcome message",
|
||||||
},
|
},
|
||||||
"welcome-text": {
|
"welcome-text": {
|
||||||
type: "string",
|
type: "string",
|
||||||
short: "w",
|
short: "w",
|
||||||
description: "Text to show on login page",
|
description: "Text to show on login page",
|
||||||
|
deprecated: true,
|
||||||
},
|
},
|
||||||
"abs-proxy-base-path": {
|
"abs-proxy-base-path": {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "The base path to prefix to all absproxy requests",
|
description: "The base path to prefix to all absproxy requests",
|
||||||
},
|
},
|
||||||
|
i18n: {
|
||||||
|
type: "string",
|
||||||
|
path: true,
|
||||||
|
description: "Path to JSON file with custom translations. Merges with default strings and supports all i18n keys.",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const optionDescriptions = (opts: Partial<Options<Required<UserProvidedArgs>>> = options): string[] => {
|
export const optionDescriptions = (opts: Partial<Options<Required<UserProvidedArgs>>> = options): string[] => {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { promises as fs } from "fs"
|
||||||
import i18next, { init } from "i18next"
|
import i18next, { init } from "i18next"
|
||||||
import * as en from "./locales/en.json"
|
import * as en from "./locales/en.json"
|
||||||
import * as ja from "./locales/ja.json"
|
import * as ja from "./locales/ja.json"
|
||||||
@ -5,29 +6,54 @@ import * as th from "./locales/th.json"
|
|||||||
import * as ur from "./locales/ur.json"
|
import * as ur from "./locales/ur.json"
|
||||||
import * as zhCn from "./locales/zh-cn.json"
|
import * as zhCn from "./locales/zh-cn.json"
|
||||||
|
|
||||||
|
const defaultResources = {
|
||||||
|
en: {
|
||||||
|
translation: en,
|
||||||
|
},
|
||||||
|
"zh-cn": {
|
||||||
|
translation: zhCn,
|
||||||
|
},
|
||||||
|
th: {
|
||||||
|
translation: th,
|
||||||
|
},
|
||||||
|
ja: {
|
||||||
|
translation: ja,
|
||||||
|
},
|
||||||
|
ur: {
|
||||||
|
translation: ur,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCustomStrings(filePath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Read custom strings from file path only
|
||||||
|
const fileContent = await fs.readFile(filePath, "utf8")
|
||||||
|
const customStringsData = JSON.parse(fileContent)
|
||||||
|
|
||||||
|
// User-provided strings override all languages.
|
||||||
|
Object.keys(defaultResources).forEach((locale) => {
|
||||||
|
i18next.addResourceBundle(locale, "translation", customStringsData)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||||
|
throw new Error(`Custom strings file not found: ${filePath}\nPlease ensure the file exists and is readable.`)
|
||||||
|
} else if (error instanceof SyntaxError) {
|
||||||
|
throw new Error(`Invalid JSON in custom strings file: ${filePath}\n${error.message}`)
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to load custom strings from ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init({
|
init({
|
||||||
lng: "en",
|
lng: "en",
|
||||||
fallbackLng: "en", // language to use if translations in user language are not available.
|
fallbackLng: "en", // language to use if translations in user language are not available.
|
||||||
returnNull: false,
|
returnNull: false,
|
||||||
lowerCaseLng: true,
|
lowerCaseLng: true,
|
||||||
debug: process.env.NODE_ENV === "development",
|
debug: process.env.NODE_ENV === "development",
|
||||||
resources: {
|
resources: defaultResources,
|
||||||
en: {
|
|
||||||
translation: en,
|
|
||||||
},
|
|
||||||
"zh-cn": {
|
|
||||||
translation: zhCn,
|
|
||||||
},
|
|
||||||
th: {
|
|
||||||
translation: th,
|
|
||||||
},
|
|
||||||
ja: {
|
|
||||||
translation: ja,
|
|
||||||
},
|
|
||||||
ur: {
|
|
||||||
translation: ur,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export default i18next
|
export default i18next
|
||||||
|
@ -7,6 +7,7 @@ import { plural } from "../common/util"
|
|||||||
import { createApp, ensureAddress } from "./app"
|
import { createApp, ensureAddress } from "./app"
|
||||||
import { AuthType, DefaultedArgs, Feature, toCodeArgs, UserProvidedArgs } from "./cli"
|
import { AuthType, DefaultedArgs, Feature, toCodeArgs, UserProvidedArgs } from "./cli"
|
||||||
import { commit, version, vsRootPath } from "./constants"
|
import { commit, version, vsRootPath } from "./constants"
|
||||||
|
import { loadCustomStrings } from "./i18n"
|
||||||
import { register } from "./routes"
|
import { register } from "./routes"
|
||||||
import { VSCodeModule } from "./routes/vscode"
|
import { VSCodeModule } from "./routes/vscode"
|
||||||
import { isDirectory, open } from "./util"
|
import { isDirectory, open } from "./util"
|
||||||
@ -122,6 +123,12 @@ export const runCodeServer = async (
|
|||||||
): Promise<{ dispose: Disposable["dispose"]; server: http.Server }> => {
|
): Promise<{ dispose: Disposable["dispose"]; server: http.Server }> => {
|
||||||
logger.info(`code-server ${version} ${commit}`)
|
logger.info(`code-server ${version} ${commit}`)
|
||||||
|
|
||||||
|
// Load custom strings if provided
|
||||||
|
if (args.i18n) {
|
||||||
|
await loadCustomStrings(args.i18n)
|
||||||
|
logger.info("Loaded custom strings")
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`Using user-data-dir ${args["user-data-dir"]}`)
|
logger.info(`Using user-data-dir ${args["user-data-dir"]}`)
|
||||||
logger.debug(`Using extensions-dir ${args["extensions-dir"]}`)
|
logger.debug(`Using extensions-dir ${args["extensions-dir"]}`)
|
||||||
|
|
||||||
|
@ -31,23 +31,32 @@ const getRoot = async (req: Request, error?: Error): Promise<string> => {
|
|||||||
const locale = req.args["locale"] || "en"
|
const locale = req.args["locale"] || "en"
|
||||||
i18n.changeLanguage(locale)
|
i18n.changeLanguage(locale)
|
||||||
const appName = req.args["app-name"] || "code-server"
|
const appName = req.args["app-name"] || "code-server"
|
||||||
const welcomeText = req.args["welcome-text"] || (i18n.t("WELCOME", { app: appName }) as string)
|
const welcomeText = escapeHtml(req.args["welcome-text"] || (i18n.t("WELCOME", { app: appName }) as string))
|
||||||
|
|
||||||
|
// Determine password message using i18n
|
||||||
let passwordMsg = i18n.t("LOGIN_PASSWORD", { configFile: req.args.config })
|
let passwordMsg = i18n.t("LOGIN_PASSWORD", { configFile: req.args.config })
|
||||||
if (req.args.usingEnvPassword) {
|
if (req.args.usingEnvPassword) {
|
||||||
passwordMsg = i18n.t("LOGIN_USING_ENV_PASSWORD")
|
passwordMsg = i18n.t("LOGIN_USING_ENV_PASSWORD")
|
||||||
} else if (req.args.usingEnvHashedPassword) {
|
} else if (req.args.usingEnvHashedPassword) {
|
||||||
passwordMsg = i18n.t("LOGIN_USING_HASHED_PASSWORD")
|
passwordMsg = i18n.t("LOGIN_USING_HASHED_PASSWORD")
|
||||||
}
|
}
|
||||||
|
passwordMsg = escapeHtml(passwordMsg)
|
||||||
|
|
||||||
|
// Get messages from i18n (with HTML escaping for security)
|
||||||
|
const loginTitle = escapeHtml(i18n.t("LOGIN_TITLE", { app: appName }))
|
||||||
|
const loginBelow = escapeHtml(i18n.t("LOGIN_BELOW"))
|
||||||
|
const passwordPlaceholder = escapeHtml(i18n.t("PASSWORD_PLACEHOLDER"))
|
||||||
|
const submitText = escapeHtml(i18n.t("SUBMIT"))
|
||||||
|
|
||||||
return replaceTemplates(
|
return replaceTemplates(
|
||||||
req,
|
req,
|
||||||
content
|
content
|
||||||
.replace(/{{I18N_LOGIN_TITLE}}/g, i18n.t("LOGIN_TITLE", { app: appName }))
|
.replace(/{{I18N_LOGIN_TITLE}}/g, loginTitle)
|
||||||
.replace(/{{WELCOME_TEXT}}/g, welcomeText)
|
.replace(/{{WELCOME_TEXT}}/g, welcomeText)
|
||||||
.replace(/{{PASSWORD_MSG}}/g, passwordMsg)
|
.replace(/{{PASSWORD_MSG}}/g, passwordMsg)
|
||||||
.replace(/{{I18N_LOGIN_BELOW}}/g, i18n.t("LOGIN_BELOW"))
|
.replace(/{{I18N_LOGIN_BELOW}}/g, loginBelow)
|
||||||
.replace(/{{I18N_PASSWORD_PLACEHOLDER}}/g, i18n.t("PASSWORD_PLACEHOLDER"))
|
.replace(/{{I18N_PASSWORD_PLACEHOLDER}}/g, passwordPlaceholder)
|
||||||
.replace(/{{I18N_SUBMIT}}/g, i18n.t("SUBMIT"))
|
.replace(/{{I18N_SUBMIT}}/g, submitText)
|
||||||
.replace(/{{ERROR}}/, error ? `<div class="error">${escapeHtml(error.message)}</div>` : ""),
|
.replace(/{{ERROR}}/, error ? `<div class="error">${escapeHtml(error.message)}</div>` : ""),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -75,6 +75,7 @@ describe("parser", () => {
|
|||||||
"--verbose",
|
"--verbose",
|
||||||
["--app-name", "custom instance name"],
|
["--app-name", "custom instance name"],
|
||||||
["--welcome-text", "welcome to code"],
|
["--welcome-text", "welcome to code"],
|
||||||
|
["--i18n", "path/to/custom-strings.json"],
|
||||||
"2",
|
"2",
|
||||||
|
|
||||||
["--locale", "ja"],
|
["--locale", "ja"],
|
||||||
@ -145,6 +146,7 @@ describe("parser", () => {
|
|||||||
verbose: true,
|
verbose: true,
|
||||||
"app-name": "custom instance name",
|
"app-name": "custom instance name",
|
||||||
"welcome-text": "welcome to code",
|
"welcome-text": "welcome to code",
|
||||||
|
i18n: path.resolve("path/to/custom-strings.json"),
|
||||||
version: true,
|
version: true,
|
||||||
"bind-addr": "192.169.0.1:8080",
|
"bind-addr": "192.169.0.1:8080",
|
||||||
"session-socket": "/tmp/override-code-server-ipc-socket",
|
"session-socket": "/tmp/override-code-server-ipc-socket",
|
||||||
@ -347,6 +349,28 @@ describe("parser", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should parse i18n flag with file path", async () => {
|
||||||
|
// Test with file path (no validation at CLI parsing level)
|
||||||
|
const args = parse(["--i18n", "/path/to/custom-strings.json"])
|
||||||
|
expect(args).toEqual({
|
||||||
|
i18n: "/path/to/custom-strings.json",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should parse i18n flag with relative file path", async () => {
|
||||||
|
// Test with relative file path
|
||||||
|
expect(() => parse(["--i18n", "./custom-strings.json"])).not.toThrow()
|
||||||
|
expect(() => parse(["--i18n", "strings.json"])).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should support app-name and deprecated welcome-text flags", async () => {
|
||||||
|
const args = parse(["--app-name", "My App", "--welcome-text", "Welcome!"])
|
||||||
|
expect(args).toEqual({
|
||||||
|
"app-name": "My App",
|
||||||
|
"welcome-text": "Welcome!",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it("should use env var github token", async () => {
|
it("should use env var github token", async () => {
|
||||||
process.env.GITHUB_TOKEN = "ga-foo"
|
process.env.GITHUB_TOKEN = "ga-foo"
|
||||||
const args = parse([])
|
const args = parse([])
|
||||||
|
154
test/unit/node/i18n.test.ts
Normal file
154
test/unit/node/i18n.test.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { promises as fs } from "fs"
|
||||||
|
import * as os from "os"
|
||||||
|
import * as path from "path"
|
||||||
|
import { loadCustomStrings } from "../../../src/node/i18n"
|
||||||
|
|
||||||
|
describe("i18n", () => {
|
||||||
|
let tempDir: string
|
||||||
|
let validJsonFile: string
|
||||||
|
let invalidJsonFile: string
|
||||||
|
let nonExistentFile: string
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create temporary directory for test files
|
||||||
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-server-i18n-test-"))
|
||||||
|
|
||||||
|
// Create test files
|
||||||
|
validJsonFile = path.join(tempDir, "valid.json")
|
||||||
|
invalidJsonFile = path.join(tempDir, "invalid.json")
|
||||||
|
nonExistentFile = path.join(tempDir, "does-not-exist.json")
|
||||||
|
|
||||||
|
// Write valid JSON file
|
||||||
|
await fs.writeFile(
|
||||||
|
validJsonFile,
|
||||||
|
JSON.stringify({
|
||||||
|
WELCOME: "Custom Welcome",
|
||||||
|
LOGIN_TITLE: "My Custom App",
|
||||||
|
LOGIN_BELOW: "Please log in to continue",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Write invalid JSON file
|
||||||
|
await fs.writeFile(invalidJsonFile, '{"WELCOME": "Missing closing quote}')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Clean up temporary directory
|
||||||
|
await fs.rmdir(tempDir, { recursive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("loadCustomStrings", () => {
|
||||||
|
it("should load valid JSON file successfully", async () => {
|
||||||
|
// Should not throw an error
|
||||||
|
await expect(loadCustomStrings(validJsonFile)).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should throw clear error for non-existent file", async () => {
|
||||||
|
await expect(loadCustomStrings(nonExistentFile)).rejects.toThrow(
|
||||||
|
`Custom strings file not found: ${nonExistentFile}\nPlease ensure the file exists and is readable.`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should throw clear error for invalid JSON", async () => {
|
||||||
|
await expect(loadCustomStrings(invalidJsonFile)).rejects.toThrow(
|
||||||
|
`Invalid JSON in custom strings file: ${invalidJsonFile}`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle empty JSON object", async () => {
|
||||||
|
const emptyJsonFile = path.join(tempDir, "empty.json")
|
||||||
|
await fs.writeFile(emptyJsonFile, "{}")
|
||||||
|
|
||||||
|
await expect(loadCustomStrings(emptyJsonFile)).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle nested JSON objects", async () => {
|
||||||
|
const nestedJsonFile = path.join(tempDir, "nested.json")
|
||||||
|
await fs.writeFile(
|
||||||
|
nestedJsonFile,
|
||||||
|
JSON.stringify({
|
||||||
|
WELCOME: "Hello World",
|
||||||
|
NESTED: {
|
||||||
|
KEY: "Value",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(loadCustomStrings(nestedJsonFile)).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle special characters and unicode", async () => {
|
||||||
|
const unicodeJsonFile = path.join(tempDir, "unicode.json")
|
||||||
|
await fs.writeFile(
|
||||||
|
unicodeJsonFile,
|
||||||
|
JSON.stringify({
|
||||||
|
WELCOME: "欢迎来到 code-server",
|
||||||
|
LOGIN_TITLE: "Willkommen bei {{app}}",
|
||||||
|
SPECIAL: "Special chars: àáâãäåæçèéêë 🚀 ♠️ ∆",
|
||||||
|
}),
|
||||||
|
"utf8",
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(loadCustomStrings(unicodeJsonFile)).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle generic errors that are not ENOENT or SyntaxError", async () => {
|
||||||
|
const testFile = path.join(tempDir, "test.json")
|
||||||
|
await fs.writeFile(testFile, "{}")
|
||||||
|
|
||||||
|
// Mock fs.readFile to throw a generic error
|
||||||
|
const originalReadFile = fs.readFile
|
||||||
|
const mockError = new Error("Permission denied")
|
||||||
|
fs.readFile = jest.fn().mockRejectedValue(mockError)
|
||||||
|
|
||||||
|
await expect(loadCustomStrings(testFile)).rejects.toThrow(
|
||||||
|
`Failed to load custom strings from ${testFile}: Permission denied`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Restore original function
|
||||||
|
fs.readFile = originalReadFile
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle errors that are not Error instances", async () => {
|
||||||
|
const testFile = path.join(tempDir, "test.json")
|
||||||
|
await fs.writeFile(testFile, "{}")
|
||||||
|
|
||||||
|
// Mock fs.readFile to throw a non-Error object
|
||||||
|
const originalReadFile = fs.readFile
|
||||||
|
fs.readFile = jest.fn().mockRejectedValue("String error")
|
||||||
|
|
||||||
|
await expect(loadCustomStrings(testFile)).rejects.toThrow(
|
||||||
|
`Failed to load custom strings from ${testFile}: String error`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Restore original function
|
||||||
|
fs.readFile = originalReadFile
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle null/undefined errors", async () => {
|
||||||
|
const testFile = path.join(tempDir, "test.json")
|
||||||
|
await fs.writeFile(testFile, "{}")
|
||||||
|
|
||||||
|
// Mock fs.readFile to throw null
|
||||||
|
const originalReadFile = fs.readFile
|
||||||
|
fs.readFile = jest.fn().mockRejectedValue(null)
|
||||||
|
|
||||||
|
await expect(loadCustomStrings(testFile)).rejects.toThrow(`Failed to load custom strings from ${testFile}: null`)
|
||||||
|
|
||||||
|
// Restore original function
|
||||||
|
fs.readFile = originalReadFile
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should complete without errors for valid input", async () => {
|
||||||
|
const testFile = path.join(tempDir, "resource-test.json")
|
||||||
|
const customStrings = {
|
||||||
|
WELCOME: "Custom Welcome Message",
|
||||||
|
LOGIN_TITLE: "Custom Login Title",
|
||||||
|
}
|
||||||
|
await fs.writeFile(testFile, JSON.stringify(customStrings))
|
||||||
|
|
||||||
|
// Should not throw any errors
|
||||||
|
await expect(loadCustomStrings(testFile)).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
175
test/unit/node/main.test.ts
Normal file
175
test/unit/node/main.test.ts
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import { promises as fs } from "fs"
|
||||||
|
import * as path from "path"
|
||||||
|
import { setDefaults, parse } from "../../../src/node/cli"
|
||||||
|
import { loadCustomStrings } from "../../../src/node/i18n"
|
||||||
|
import { tmpdir } from "../../utils/helpers"
|
||||||
|
|
||||||
|
// Mock the i18n module
|
||||||
|
jest.mock("../../../src/node/i18n", () => ({
|
||||||
|
loadCustomStrings: jest.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock logger to avoid console output during tests
|
||||||
|
jest.mock("@coder/logger", () => ({
|
||||||
|
logger: {
|
||||||
|
info: jest.fn(),
|
||||||
|
debug: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
level: 0,
|
||||||
|
},
|
||||||
|
field: jest.fn(),
|
||||||
|
Level: {
|
||||||
|
Trace: 0,
|
||||||
|
Debug: 1,
|
||||||
|
Info: 2,
|
||||||
|
Warn: 3,
|
||||||
|
Error: 4,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockedLoadCustomStrings = loadCustomStrings as jest.MockedFunction<typeof loadCustomStrings>
|
||||||
|
|
||||||
|
describe("main", () => {
|
||||||
|
let tempDir: string
|
||||||
|
let mockServer: any
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = await tmpdir("code-server-main-test")
|
||||||
|
|
||||||
|
// Reset mocks
|
||||||
|
jest.clearAllMocks()
|
||||||
|
|
||||||
|
// Mock the server creation to avoid actually starting a server
|
||||||
|
mockServer = {
|
||||||
|
server: {
|
||||||
|
listen: jest.fn(),
|
||||||
|
address: jest.fn(() => ({ address: "127.0.0.1", port: 8080 })),
|
||||||
|
close: jest.fn(),
|
||||||
|
},
|
||||||
|
editorSessionManagerServer: {
|
||||||
|
address: jest.fn(() => null),
|
||||||
|
},
|
||||||
|
dispose: jest.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Clean up temp directory
|
||||||
|
try {
|
||||||
|
await fs.rmdir(tempDir, { recursive: true })
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("runCodeServer", () => {
|
||||||
|
it("should load custom strings when i18n flag is provided", async () => {
|
||||||
|
// Create a test custom strings file
|
||||||
|
const customStringsFile = path.join(tempDir, "custom-strings.json")
|
||||||
|
await fs.writeFile(
|
||||||
|
customStringsFile,
|
||||||
|
JSON.stringify({
|
||||||
|
WELCOME: "Custom Welcome",
|
||||||
|
LOGIN_TITLE: "My App",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create args with i18n flag
|
||||||
|
const cliArgs = parse([
|
||||||
|
`--config=${path.join(tempDir, "config.yaml")}`,
|
||||||
|
`--user-data-dir=${tempDir}`,
|
||||||
|
"--bind-addr=localhost:0",
|
||||||
|
"--log=warn",
|
||||||
|
"--auth=none",
|
||||||
|
`--i18n=${customStringsFile}`,
|
||||||
|
])
|
||||||
|
const args = await setDefaults(cliArgs)
|
||||||
|
|
||||||
|
// Mock the app module
|
||||||
|
jest.doMock("../../../src/node/app", () => ({
|
||||||
|
createApp: jest.fn().mockResolvedValue(mockServer),
|
||||||
|
ensureAddress: jest.fn().mockReturnValue(new URL("http://localhost:8080")),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock routes module
|
||||||
|
jest.doMock("../../../src/node/routes", () => ({
|
||||||
|
register: jest.fn().mockResolvedValue(jest.fn()),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock loadCustomStrings to succeed
|
||||||
|
mockedLoadCustomStrings.mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
// Import runCodeServer after mocking
|
||||||
|
const mainModule = await import("../../../src/node/main")
|
||||||
|
const result = await mainModule.runCodeServer(args)
|
||||||
|
|
||||||
|
// Verify that loadCustomStrings was called with the correct file path
|
||||||
|
expect(mockedLoadCustomStrings).toHaveBeenCalledWith(customStringsFile)
|
||||||
|
expect(mockedLoadCustomStrings).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await result.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not load custom strings when i18n flag is not provided", async () => {
|
||||||
|
// Create args without i18n flag
|
||||||
|
const cliArgs = parse([
|
||||||
|
`--config=${path.join(tempDir, "config.yaml")}`,
|
||||||
|
`--user-data-dir=${tempDir}`,
|
||||||
|
"--bind-addr=localhost:0",
|
||||||
|
"--log=warn",
|
||||||
|
"--auth=none",
|
||||||
|
])
|
||||||
|
const args = await setDefaults(cliArgs)
|
||||||
|
|
||||||
|
// Mock the app module
|
||||||
|
jest.doMock("../../../src/node/app", () => ({
|
||||||
|
createApp: jest.fn().mockResolvedValue(mockServer),
|
||||||
|
ensureAddress: jest.fn().mockReturnValue(new URL("http://localhost:8080")),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock routes module
|
||||||
|
jest.doMock("../../../src/node/routes", () => ({
|
||||||
|
register: jest.fn().mockResolvedValue(jest.fn()),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Import runCodeServer after mocking
|
||||||
|
const mainModule = await import("../../../src/node/main")
|
||||||
|
const result = await mainModule.runCodeServer(args)
|
||||||
|
|
||||||
|
// Verify that loadCustomStrings was NOT called
|
||||||
|
expect(mockedLoadCustomStrings).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await result.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle errors when loadCustomStrings fails", async () => {
|
||||||
|
// Create args with i18n flag pointing to non-existent file
|
||||||
|
const nonExistentFile = path.join(tempDir, "does-not-exist.json")
|
||||||
|
const cliArgs = parse([
|
||||||
|
`--config=${path.join(tempDir, "config.yaml")}`,
|
||||||
|
`--user-data-dir=${tempDir}`,
|
||||||
|
"--bind-addr=localhost:0",
|
||||||
|
"--log=warn",
|
||||||
|
"--auth=none",
|
||||||
|
`--i18n=${nonExistentFile}`,
|
||||||
|
])
|
||||||
|
const args = await setDefaults(cliArgs)
|
||||||
|
|
||||||
|
// Mock loadCustomStrings to throw an error
|
||||||
|
const mockError = new Error("Custom strings file not found")
|
||||||
|
mockedLoadCustomStrings.mockRejectedValue(mockError)
|
||||||
|
|
||||||
|
// Import runCodeServer after mocking
|
||||||
|
const mainModule = await import("../../../src/node/main")
|
||||||
|
|
||||||
|
// Verify that runCodeServer throws the error from loadCustomStrings
|
||||||
|
await expect(mainModule.runCodeServer(args)).rejects.toThrow("Custom strings file not found")
|
||||||
|
|
||||||
|
// Verify that loadCustomStrings was called
|
||||||
|
expect(mockedLoadCustomStrings).toHaveBeenCalledWith(nonExistentFile)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
Reference in New Issue
Block a user