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:
Alex Strick van Linschoten
2025-07-15 22:38:27 +02:00
committed by GitHub
parent 8b3d9b9e0a
commit 92fca0dcc3
8 changed files with 471 additions and 23 deletions

View File

@@ -75,6 +75,7 @@ describe("parser", () => {
"--verbose",
["--app-name", "custom instance name"],
["--welcome-text", "welcome to code"],
["--i18n", "path/to/custom-strings.json"],
"2",
["--locale", "ja"],
@@ -145,6 +146,7 @@ describe("parser", () => {
verbose: true,
"app-name": "custom instance name",
"welcome-text": "welcome to code",
i18n: path.resolve("path/to/custom-strings.json"),
version: true,
"bind-addr": "192.169.0.1:8080",
"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 () => {
process.env.GITHUB_TOKEN = "ga-foo"
const args = parse([])

154
test/unit/node/i18n.test.ts Normal file
View 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
View 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)
})
})
})