feat(i18n): complete localized screenshots and language ordering

Localize German, French, and Korean demo content and capture workflows. Preserve real software keyboards and invariant numeric parsing during captures. Sort language choices by code and update the published documentation and marketing asset submodules.
This commit is contained in:
Kaede Akino committed 2026-10-14 17:14:32 +08:00
1 parent 6befb61b1d
commit a5b269ee20
12 files changed
+210 -60

No files matched your search

Submodule AgentKit updated: 849062bd4c...ce6179f044.
@@ -3426,12 +3426,17 @@ extension WeakAgentUserInteraction: AgentUserInteractionHandling {}
extension ConversationModel {
public static func demo(language: AppLanguage = .english) -> ConversationModel {
func text(_ en: String, _ zhHans: String, _ zhHant: String, _ ja: String) -> String {
func text(
_ en: String, _ zhHans: String, _ zhHant: String, _ ja: String, _ ko: String, _ de: String, _ fr: String
) -> String {
switch language {
case .simplifiedChinese: zhHans
case .traditionalChinese: zhHant
case .japanese: ja
case .system, .english, .german, .french, .korean: en
case .korean: ko
case .german: de
case .french: fr
case .system, .english: en
}
}
let now = Date(timeIntervalSinceReferenceDate: 809_020_800)
@@ -3440,7 +3445,10 @@ extension ConversationModel {
text: text(
"Find the cause of the 502 responses. Inspect only—do not change configuration.",
"查明 502 响应的原因。仅检查,不要更改配置。",
"找出 502 回應的原因。僅進行檢查,不要變更設定。", "502応答の原因を調べてください。調査のみを行い、設定は変更しないでください。"),
"找出 502 回應的原因。僅進行檢查,不要變更設定。", "502応答の原因を調べてください。調査のみを行い、設定は変更しないでください。",
"502 응답의 원인을 찾아 주세요. 점검만 하고 설정은 변경하지 마세요.",
"Finde die Ursache der 502-Antworten. Nur prüfen, keine Konfiguration ändern.",
"Trouvez la cause des réponses 502. Analysez uniquement, sans modifier la configuration."),
createdAt: now.addingTimeInterval(-18)
)
let answer = ChatMessage(
@@ -3449,14 +3457,20 @@ extension ConversationModel {
"The upstream service is not listening on `127.0.0.1:8080`. Nginx itself is healthy, and no configuration was changed. Start the upstream service, then verify `/health` before considering a reload.",
"上游服务没有监听 `127.0.0.1:8080`。Nginx 本身运行正常,且没有更改任何配置。请先启动上游服务并验证 `/health`,再考虑重新加载。",
"上游服務未監聽 `127.0.0.1:8080`。Nginx 本身運作正常,且未變更任何設定。請先啟動上游服務並驗證 `/health`,再考慮重新載入。",
"上流サービスが`127.0.0.1:8080`で待ち受けていません。Nginx自体は正常で、設定は変更していません。まず上流サービスを起動して`/health`を確認し、その後で再読み込みを検討してください。"
"上流サービスが`127.0.0.1:8080`で待ち受けていません。Nginx自体は正常で、設定は変更していません。まず上流サービスを起動して`/health`を確認し、その後で再読み込みを検討してください。",
"업스트림 서비스가 `127.0.0.1:8080`에서 수신 대기하고 있지 않아요. Nginx는 정상이며 설정은 변경하지 않았어요. 먼저 업스트림 서비스를 시작하고 `/health`를 확인한 뒤 다시 로드를 검토하세요.",
"Der Upstream-Dienst lauscht nicht auf `127.0.0.1:8080`. Nginx läuft ordnungsgemäß; es wurde keine Konfiguration geändert. Starte den Upstream-Dienst und prüfe `/health`, bevor du ein Neuladen erwägst.",
"Le service en amont n’écoute pas sur `127.0.0.1:8080`. Nginx fonctionne normalement et aucune configuration n’a été modifiée. Démarrez le service en amont, puis vérifiez `/health` avant d’envisager un rechargement."
),
createdAt: now
)
let runID = UUID()
let descriptor = AgentToolDescriptor(
name: "ssh_exec",
summary: text("Run a command on a remote host.", "在远程主机上运行命令。", "在遠端主機上執行指令。", "リモートホストでコマンドを実行します。"),
summary: text(
"Run a command on a remote host.", "在远程主机上运行命令。", "在遠端主機上執行指令。", "リモートホストでコマンドを実行します。",
"원격 호스트에서 명령을 실행해요.", "Einen Befehl auf einem Remote-Host ausführen.",
"Exécuter une commande sur un hôte distant."),
inputSchema: .object([:]), target: .host, approvalPolicy: .approve,
concurrency: .parallel,
presentation: AgentToolDescriptor.Presentation(
@@ -3493,7 +3507,9 @@ extension ConversationModel {
)
]
)
conversation.customTitle = text("Diagnose 502 responses", "诊断 502 响应", "診斷 502 回應", "502応答の診断")
conversation.customTitle = text(
"Diagnose 502 responses", "诊断 502 响应", "診斷 502 回應", "502応答の診断", "502 응답 진단", "502-Antworten untersuchen",
"Diagnostiquer les réponses 502")
conversation.messages = [
user, answer,
]
@@ -43,8 +43,8 @@ public nonisolated enum AppLocalization {
public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendable {
case system
case english = "en"
case german = "de"
case english = "en"
case french = "fr"
case japanese = "ja"
case korean = "ko"
+17
View File
@@ -103,6 +103,23 @@ class ScreenshotsTests < Minitest::Test
assert ScreenshotFrames.new({ locales: "ja" }, root: @root).plan.all? { |job| job[:output].include?("/ja/") }
end
def test_additional_marketing_locales_configure_native_capture_and_frame_paths
{ "ko" => ["KR", "ko_KR"], "de" => ["DE", "de_DE"], "fr" => ["FR", "fr_FR"] }.each do |language, (region, locale)|
%i[mac ios].each do |platform|
capture = runner(platform, locales: language)
target = {}
key = platform == :mac ? "TFMTests" : "TFITests"
capture.configure_xctestrun({ key => target }, language, {}, [])
assert_equal language, target["TestLanguage"]
assert_equal region, target["TestRegion"]
assert_equal "C", target["EnvironmentVariables"]["LC_NUMERIC"]
assert_equal ["-AppleLanguages", "(#{language})", "-AppleLocale", locale], target["CommandLineArguments"]
end
jobs = ScreenshotFrames.new({ locales: language }, root: @root).plan
assert jobs.all? { |job| job[:input].include?("/#{language}/") && job[:output].include?("/#{language}/") }
end
end
def test_test_plan_format_updates_only_the_capture_target
capture = runner(:mac)
target = { "BlueprintName" => "TFMTests" }
+13 -1
View File
@@ -3,7 +3,7 @@
set -euo pipefail
usage() {
print -u2 "Usage: Scripts/run-screenshot-demo.sh <hosts|workspace|agent|command|metrics|metrics-detail|app-settings> <en|ja|zh-Hans|zh-Hant>"
print -u2 "Usage: Scripts/run-screenshot-demo.sh <hosts|workspace|agent|command|metrics|metrics-detail|app-settings> <en|ja|ko|de|fr|zh-Hans|zh-Hant>"
}
if (( $# != 2 )); then
@@ -32,6 +32,18 @@ case "$language" in
apple_language="ja"
apple_locale="ja_JP"
;;
ko)
apple_language="ko"
apple_locale="ko_KR"
;;
de)
apple_language="de"
apple_locale="de_DE"
;;
fr)
apple_language="fr"
apple_locale="fr_FR"
;;
zh-Hans)
apple_language="zh-Hans"
apple_locale="zh-Hans"
@@ -25,7 +25,7 @@ struct MobileScreenshotTests {
if let language = ProcessInfo.processInfo.environment["TERMIND_SCREENSHOT_LOCALE"] {
return [language]
}
return ["en", "zh-Hans", "zh-Hant", "ja"]
return ["en", "zh-Hans", "zh-Hant", "ja", "ko", "de", "fr"]
}
@Test func renderScenes() async throws {
@@ -48,7 +48,7 @@ struct MobileScreenshotTests {
}
if includes("ssh") {
try await withEnglishKeyboard {
try await withTerminalKeyboard {
try await captureWorkspace(.ssh, language: language)
}
}
@@ -83,8 +83,10 @@ struct MobileScreenshotTests {
language: String, connectionServices: MobileWorkspaceConnectionServices = .live
) throws -> MobileAppModel {
let app = try MobileAppModel(inMemory: true, workspaceConnectionServices: connectionServices)
let production = HostGroup(name: text(language, "Production", "生产环境", "正式環境", "本番環境"))
let edge = HostGroup(name: text(language, "Edge", "边缘节点", "邊緣節點", "エッジ"))
let production = HostGroup(
name: text(language, "Production", "生产环境", "正式環境", "本番環境", "운영 환경", "Produktion", "Production"))
let edge = HostGroup(
name: text(language, "Edge", "边缘节点", "邊緣節點", "エッジ", "에지", "Edge", "Périphérie"))
app.library.upsert(production)
app.library.upsert(edge)
let hosts = [
@@ -134,7 +136,10 @@ struct MobileScreenshotTests {
if kind == .sftp {
#expect(
app.testWorkspaceSession.renameWorkspace(
id, to: text(language, "Production files", "生产文件", "正式環境檔案", "本番環境のファイル")))
id,
to: text(
language, "Production files", "生产文件", "正式環境檔案", "本番環境のファイル",
"운영 파일", "Produktionsdateien", "Fichiers de production")))
try #require(workspace.sftpChannel.files.count == 6)
}
let scene = MobileSceneModel(app: app, sessionID: "test")
@@ -145,8 +150,12 @@ struct MobileScreenshotTests {
}
}
private func withEnglishKeyboard(_ capture: () async throws -> Void) async throws {
let mode = try #require(UITextInputMode.activeInputModes.first { $0.primaryLanguage?.hasPrefix("en") == true })
private func withTerminalKeyboard(_ capture: () async throws -> Void) async throws {
let modes = UITextInputMode.activeInputModes
let mode = try #require(
modes.first { $0.primaryLanguage?.hasPrefix("en") == true }
?? modes.first { $0.primaryLanguage != nil && $0.primaryLanguage != "emoji" },
"The capture simulator needs a text keyboard")
let selector = #selector(getter: UIResponder.textInputMode)
let method = try #require(class_getInstanceMethod(MobileTerminalView.self, selector))
let original = method_getImplementation(method)
@@ -160,7 +169,8 @@ struct MobileScreenshotTests {
let typeGetter: @convention(block) (AnyObject) -> Int = { _ in UIKeyboardType.asciiCapable.rawValue }
let typeReplacement = imp_implementationWithBlock(typeGetter)
// Scope the public input-mode getter override to this opt-in capture.
// UIKit still renders its real keyboard, independently of the app locale.
// XCTest may install only the test language's keyboard. Prefer English
// when available, otherwise keep the native localized text keyboard.
// ASCII mode also excludes the bilingual keyboard's Pinyin/Zhuyin toggle.
class_replaceMethod(MobileTerminalView.self, selector, replacement, encoding)
class_replaceMethod(MobileTerminalView.self, typeSelector, typeReplacement, typeEncoding)
@@ -272,7 +282,9 @@ struct MobileScreenshotTests {
try await Task.sleep(for: .milliseconds(50))
}
try #require(terminal.isFirstResponder)
try #require(terminal.textInputMode?.primaryLanguage?.hasPrefix("en") == true)
let keyboardLanguage = try #require(terminal.textInputMode?.primaryLanguage)
try #require(keyboardLanguage != "emoji")
try #require(terminal.keyboardType == .asciiCapable)
try #require(accessory.window != nil)
// An accessory-only hardware keyboard bar ends at the bottom safe area.
// A software keyboard has its key rows below the accessory instead.
@@ -345,18 +357,26 @@ struct MobileScreenshotTests {
private func screenshotBackButton(language: String) -> some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {}) {
Label(text(language, "Workspaces", "工作区", "工作區", "ワークスペース"), systemImage: "chevron.left")
Label(
text(
language, "Workspaces", "工作区", "工作區", "ワークスペース",
"작업 공간", "Arbeitsbereiche", "Espaces de travail"),
systemImage: "chevron.left")
}
}
}
private func text(
_ language: String, _ english: String, _ simplified: String, _ traditional: String, _ japanese: String
_ language: String, _ english: String, _ simplified: String, _ traditional: String, _ japanese: String,
_ korean: String, _ german: String, _ french: String
) -> String {
switch language {
case "zh-Hans": simplified
case "zh-Hant": traditional
case "ja": japanese
case "ko": korean
case "de": german
case "fr": french
default: english
}
}
+26 -10
View File
@@ -29,12 +29,12 @@ import TermindApplication
return "Pass --termind-demo only once."
case .invalidFormat(let value):
return "Invalid Demo argument \(value). Expected "
+ "--termind-demo=<hosts|workspace|agent|command|metrics|metrics-detail|app-settings>:<en|ja|zh-Hans|zh-Hant>."
+ "--termind-demo=<hosts|workspace|agent|command|metrics|metrics-detail|app-settings>:<en|ja|ko|de|fr|zh-Hans|zh-Hant>."
case .unknownScene(let value):
return
"Unknown Demo scene \(value). Expected hosts, workspace, agent, command, metrics, metrics-detail, or app-settings."
case .unknownLanguage(let value):
return "Unknown Demo language \(value). Expected en, ja, zh-Hans, or zh-Hant."
return "Unknown Demo language \(value). Expected en, ja, ko, de, fr, zh-Hans, or zh-Hant."
}
}
}
@@ -151,10 +151,13 @@ import TermindApplication
let session = SSHSessionModel.demo(host: host)
let pane = Pane(.sshSession(session))
pane.customTitle = text(en: "nginx diagnostics", zhHans: "Nginx 诊断", zhHant: "Nginx 診斷", ja: "nginxの診断")
pane.customTitle = text(
en: "nginx diagnostics", zhHans: "Nginx 诊断", zhHant: "Nginx 診斷", ja: "nginxの診断", ko: "nginx 진단",
de: "nginx-Diagnose", fr: "Diagnostic nginx")
let workspace = Workspace(pane: pane)
workspace.customTitle = text(
en: "Production diagnostics", zhHans: "生产环境诊断", zhHant: "正式環境診斷", ja: "本番環境の診断")
en: "Production diagnostics", zhHans: "生产环境诊断", zhHant: "正式環境診斷", ja: "本番環境の診断", ko: "운영 환경 진단",
de: "Produktionsdiagnose", fr: "Diagnostic de production")
workspace.focusedPaneID = pane.id
let conversation = ConversationModel.demo(language: language)
@@ -196,12 +199,16 @@ import TermindApplication
let first = Pane(.sshSession(firstSession))
first.customTitle =
showsCommandResult
? text(en: "nginx logs", zhHans: "Nginx 日志", zhHant: "Nginx 日誌", ja: "nginxのログ")
? text(
en: "nginx logs", zhHans: "Nginx 日志", zhHant: "Nginx 日誌", ja: "nginxのログ", ko: "nginx 로그",
de: "nginx-Protokolle", fr: "Journaux nginx")
: "api-01"
let sftpChannel = SFTPChannelModel.demo(hostID: saved[0].id)
sftpChannel.selection = ["/etc/nginx/nginx.conf"]
let sftp = Pane(.sftpChannel(sftpChannel))
sftp.customTitle = text(en: "nginx config", zhHans: "Nginx 配置", zhHant: "Nginx 設定", ja: "nginxの設定")
sftp.customTitle = text(
en: "nginx config", zhHans: "Nginx 配置", zhHant: "Nginx 設定", ja: "nginxの設定", ko: "nginx 설정",
de: "nginx-Konfiguration", fr: "Configuration nginx")
let secondSession =
showsCommandResult
? SSHSessionModel.demo(host: saved[1])
@@ -235,8 +242,12 @@ import TermindApplication
workspace.focusedPaneID = first.id
workspace.customTitle =
showsCommandResult
? text(en: "Log investigation", zhHans: "日志调查", zhHant: "日誌調查", ja: "ログの調査")
: text(en: "Production diagnostics", zhHans: "生产环境诊断", zhHant: "正式環境診斷", ja: "本番環境の診断")
? text(
en: "Log investigation", zhHans: "日志调查", zhHant: "日誌調查", ja: "ログの調査", ko: "로그 분석",
de: "Protokollanalyse", fr: "Analyse des journaux")
: text(
en: "Production diagnostics", zhHans: "生产环境诊断", zhHant: "正式環境診斷", ja: "本番環境の診断", ko: "운영 환경 진단",
de: "Produktionsdiagnose", fr: "Diagnostic de production")
workspace.broadcastsInput = !showsCommandResult
if showsCommandResult {
workspace.commandGeneratorState = .inserted(targetCount: 1)
@@ -281,12 +292,17 @@ import TermindApplication
]).joined(separator: "\r\n")
}
private func text(en: String, zhHans: String, zhHant: String, ja: String) -> String {
private func text(en: String, zhHans: String, zhHant: String, ja: String, ko: String, de: String, fr: String)
-> String
{
switch language {
case .simplifiedChinese: zhHans
case .traditionalChinese: zhHant
case .japanese: ja
case .system, .english, .german, .french, .korean: en
case .korean: ko
case .german: de
case .french: fr
case .system, .english: en
}
}
}
@@ -10,12 +10,17 @@ extension HostLibraryModel {
/// Seed data for P0, so the sidebar and workspace list can be exercised before any
/// persistence or networking exists.
static func demo(language: AppLanguage = .english, includesConnectionActivity: Bool = true) -> HostLibraryModel {
func text(_ en: String, _ zhHans: String, _ zhHant: String, _ ja: String) -> String {
func text(
_ en: String, _ zhHans: String, _ zhHant: String, _ ja: String, _ ko: String, _ de: String, _ fr: String
) -> String {
switch language {
case .simplifiedChinese: zhHans
case .traditionalChinese: zhHant
case .japanese: ja
case .system, .english, .german, .french, .korean: en
case .korean: ko
case .german: de
case .french: fr
case .system, .english: en
}
}
func distribution(_ id: String) -> DetectedDistribution {
@@ -25,22 +30,27 @@ extension HostLibraryModel {
return distribution
}
let production = HostGroup(name: text("Production", "生产环境", "正式環境", "本番環境"), sortIndex: 0)
let staging = HostGroup(name: text("Staging", "预发布环境", "測試環境", "ステージング"), sortIndex: 1)
let homelab = HostGroup(name: text("Homelab", "家庭实验室", "家庭實驗室", "ホームラボ"), sortIndex: 2)
let production = HostGroup(
name: text("Production", "生产环境", "正式環境", "本番環境", "운영 환경", "Produktion", "Production"), sortIndex: 0)
let staging = HostGroup(
name: text("Staging", "预发布环境", "測試環境", "ステージング", "스테이징", "Staging", "Préproduction"), sortIndex: 1)
let homelab = HostGroup(
name: text("Homelab", "家庭实验室", "家庭實驗室", "ホームラボ", "홈랩", "Heimlabor", "Labo personnel"), sortIndex: 2)
let edge = HostGroup(
name: text("Edge", "边缘节点", "邊緣節點", "エッジ"), parentID: production.id, sortIndex: 0
name: text("Edge", "边缘节点", "邊緣節點", "エッジ", "에지", "Edge", "Périphérie"), parentID: production.id, sortIndex: 0
)
let now = Date(timeIntervalSinceReferenceDate: 776_000_000)
let personalKey = SSHKeyRef(
label: text("Personal", "个人", "個人", "個人用"), algorithm: .ed25519,
label: text("Personal", "个人", "個人", "個人用", "개인용", "Privat", "Personnel"), algorithm: .ed25519,
fingerprint: "SHA256:9pKQ2vC0nJ7hR4mXbL1sT8yWfE3dA6uZgN5qOiVxHkM",
credentialRef: "termind.key.personal", colorTag: .blue
)
let workKey = SSHKeyRef(
label: text("Work (no sync)", "工作(不同步)", "工作(不同步)", "仕事用(同期なし)"), algorithm: .ecdsa256,
label: text(
"Work (no sync)", "工作(不同步)", "工作(不同步)", "仕事用(同期なし)", "업무용 (동기화 안 함)", "Arbeit (ohne Sync)",
"Travail (sans synchro)"), algorithm: .ecdsa256,
fingerprint: "SHA256:3tBn8xLpQ2wZ7yKvR1mHcE9dS4fJ6uA0gT5iNqOxVbY",
credentialRef: "termind.key.work",
storage: .thisDevice, colorTag: .purple
@@ -50,25 +60,39 @@ extension HostLibraryModel {
// One of each shape, so all four combinations of the two secrets are on
// screen at P0: key only, key *and* password, password only, neither.
let deploy = Identity(
name: text("Deploy", "部署账号", "部署帳號", "デプロイ"), username: "deploy", keyID: personalKey.id,
notes: text("Production web tier", "生产环境 Web 层", "正式環境 Web 層", "本番環境のWeb層"),
name: text("Deploy", "部署账号", "部署帳號", "デプロイ", "배포", "Deployment", "Déploiement"), username: "deploy",
keyID: personalKey.id,
notes: text(
"Production web tier", "生产环境 Web 层", "正式環境 Web 層", "本番環境のWeb層", "운영 웹 계층", "Produktive Webserver",
"Serveurs web de production"),
colorTag: .green
)
let postgres = Identity(
name: text("Postgres admin", "Postgres 管理员", "Postgres 管理員", "Postgres管理者"), username: "postgres",
name: text(
"Postgres admin", "Postgres 管理员", "Postgres 管理員", "Postgres管理者", "Postgres 관리자", "Postgres-Admin",
"Admin Postgres"), username: "postgres",
credentialRef: "termind.password.postgres", keyID: workKey.id,
notes: text(
"Key to get in, password for sudo", "使用密钥登录,密码用于 sudo", "使用金鑰登入,密碼用於 sudo", "ログインには鍵、sudoにはパスワード"),
"Key to get in, password for sudo", "使用密钥登录,密码用于 sudo", "使用金鑰登入,密碼用於 sudo", "ログインには鍵、sudoにはパスワード",
"키로 로그인, sudo에는 암호 사용", "Schlüssel zum Anmelden, Passwort für sudo",
"Clé pour la connexion, mot de passe pour sudo"),
colorTag: .orange
)
let consoleRoot = Identity(
name: text("Console root", "控制台 root", "主控台 root", "コンソールのroot"), username: "root",
name: text(
"Console root", "控制台 root", "主控台 root", "コンソールのroot", "콘솔 root", "Konsolen-root", "Root console"),
username: "root",
credentialRef: "termind.password.console-root",
notes: text("Out-of-band only", "仅限带外管理", "僅限頻外管理", "帯域外管理専用"), colorTag: .red
notes: text(
"Out-of-band only", "仅限带外管理", "僅限頻外管理", "帯域外管理専用", "대역 외 관리 전용", "Nur Out-of-Band",
"Gestion hors bande uniquement"), colorTag: .red
)
let unfinished = Identity(
name: text("New admin", "新管理员", "新增管理員", "新しい管理者"), username: "admin",
notes: text("No secret chosen yet", "尚未选择凭据", "尚未選擇認證資訊", "シークレットは未選択")
name: text("New admin", "新管理员", "新增管理員", "新しい管理者", "새 관리자", "Neuer Admin", "Nouvel admin"),
username: "admin",
notes: text(
"No secret chosen yet", "尚未选择凭据", "尚未選擇認證資訊", "シークレットは未選択", "아직 자격 증명을 선택하지 않았어요",
"Noch keine Zugangsdaten gewählt", "Aucun secret sélectionné")
)
// Half the fleet is left without an identity on purpose, so the "not set
@@ -127,35 +151,43 @@ extension HostLibraryModel {
]
let diagnostics = SnippetPackage(
name: text("Diagnostics", "诊断", "診斷", "診断"), sortIndex: 0
name: text("Diagnostics", "诊断", "診斷", "診断", "진단", "Diagnose", "Diagnostic"), sortIndex: 0
)
let webOperations = SnippetPackage(
name: text("Web Operations", "Web 运维", "Web 維運", "Web運用"),
name: text("Web Operations", "Web 运维", "Web 維運", "Web運用", "웹 운영", "Webbetrieb", "Exploitation web"),
parentID: diagnostics.id, sortIndex: 0
)
let snippets = [
Snippet(
title: text("Disk usage by directory", "按目录查看磁盘占用", "依目錄查看磁碟用量", "ディレクトリ別のディスク使用量"),
title: text(
"Disk usage by directory", "按目录查看磁盘占用", "依目錄查看磁碟用量", "ディレクトリ別のディスク使用量", "디렉터리별 디스크 사용량",
"Speicherplatz pro Verzeichnis", "Espace disque par dossier"),
body: "du -sh /* | sort -h",
packageID: diagnostics.id, targetHostIDs: [hosts[0].id],
sortIndex: 0
),
Snippet(
title: text("Top memory consumers", "内存占用最高的进程", "記憶體用量最高的程序", "メモリ使用量の多いプロセス"),
title: text(
"Top memory consumers", "内存占用最高的进程", "記憶體用量最高的程序", "メモリ使用量の多いプロセス", "메모리 사용량이 많은 프로세스",
"Größte RAM-Verbraucher", "Processus les plus gourmands en mémoire"),
body: "ps aux --sort=-%mem | head -20",
packageID: diagnostics.id,
targetHostIDs: [hosts[0].id, hosts[1].id],
sortIndex: 1
),
Snippet(
title: text("Tail nginx errors", "持续查看 Nginx 错误", "持續查看 Nginx 錯誤", "nginxのエラーを追跡"),
title: text(
"Tail nginx errors", "持续查看 Nginx 错误", "持續查看 Nginx 錯誤", "nginxのエラーを追跡", "nginx 오류 추적",
"nginx-Fehler verfolgen", "Suivre les erreurs nginx"),
body: "tail -f /var/log/nginx/error.log",
packageID: webOperations.id,
targetHostIDs: [hosts[0].id, hosts[1].id],
sortIndex: 0
),
Snippet(
title: text("Listening ports", "监听中的端口", "監聽中的連接埠", "待ち受けポート"),
title: text(
"Listening ports", "监听中的端口", "監聽中的連接埠", "待ち受けポート", "수신 대기 포트", "Lauschende Ports", "Ports en écoute"
),
body: "ss -tulpn", sortIndex: 3
),
]
@@ -189,11 +221,12 @@ extension HostLibraryModel {
PortForwardingRule(
hostID: db.id, kind: .local, bindPort: 5432,
targetHost: "localhost", targetPort: 5432,
label: text("Postgres", "Postgres 数据库", "Postgres 資料庫", "Postgres")
label: text("Postgres", "Postgres 数据库", "Postgres 資料庫", "Postgres", "Postgres", "Postgres", "Postgres")
),
PortForwardingRule(
hostID: web01.id, kind: .dynamic, bindPort: 1080,
label: text("SOCKS proxy", "SOCKS 代理", "SOCKS Proxy", "SOCKSプロキシ")
label: text(
"SOCKS proxy", "SOCKS 代理", "SOCKS Proxy", "SOCKSプロキシ", "SOCKS 프록시", "SOCKS-Proxy", "Proxy SOCKS")
),
]
@@ -216,7 +249,10 @@ extension HostLibraryModel {
hostID: web01.id, direction: .upload,
localPath: "~/Config/nginx.conf", remotePath: "/etc/nginx/nginx.conf",
byteCount: 8_192,
state: .failed(text("Permission denied", "权限被拒绝", "權限遭拒", "アクセスが拒否されました")),
state: .failed(
text(
"Permission denied", "权限被拒绝", "權限遭拒", "アクセスが拒否されました", "권한이 거부되었어요", "Zugriff verweigert",
"Permission refusée")),
queuedAt: now.addingTimeInterval(-3_600)
),
SFTPTransfer(
@@ -247,7 +283,10 @@ extension HostLibraryModel {
store.setStatus(.connecting, for: db.id)
if let staging = hosts.first(where: { $0.name == "staging-app" }) {
store.setStatus(
.failed(text("Connection refused", "连接被拒绝", "連線遭拒", "接続が拒否されました")), for: staging.id
.failed(
text(
"Connection refused", "连接被拒绝", "連線遭拒", "接続が拒否されました", "연결이 거부되었어요", "Verbindung abgelehnt",
"Connexion refusée")), for: staging.id
)
}
+23
View File
@@ -7,6 +7,29 @@ import Testing
@testable import Termind
struct AppLanguageTests {
@Test
func languagePickerKeepsSystemFirstThenSortsByLanguageCode() {
#expect(AppLanguage.allCases.first == .system)
let languageCodes = AppLanguage.allCases.dropFirst().map(\.rawValue)
#expect(languageCodes == languageCodes.sorted())
}
@Test(arguments: [AppLanguage.korean, .german, .french])
@MainActor
func screenshotConversationLocalizesProseWithoutTranslatingTechnicalReferences(language: AppLanguage) {
let english = ConversationModel.demo(language: .english)
let localized = ConversationModel.demo(language: language)
#expect(localized.customTitle != english.customTitle)
#expect(localized.messages.count == english.messages.count)
for (message, source) in zip(localized.messages, english.messages) {
#expect(message.text != source.text)
#expect(message.role == source.role)
}
#expect(localized.messages.last?.text.contains("`127.0.0.1:8080`") == true)
#expect(localized.messages.last?.text.contains("`/health`") == true)
#expect(localized.toolCards.map(\.invocation.call.name) == ["ssh_exec", "ssh_exec"])
}
@Test(arguments: [
("en", "SSH authentication failed: key detail; password detail"),
("zh-Hans", "SSH认证失败:key detail;password detail"),
@@ -100,7 +100,9 @@ import TermindPresentation
@Test
func vaultCaptureStatusesMatchItsTwoWorkspacesInEveryLanguage() throws {
for language in [AppLanguage.english, .japanese, .simplifiedChinese, .traditionalChinese] {
for language in [
AppLanguage.english, .japanese, .korean, .german, .french, .simplifiedChinese, .traditionalChinese,
] {
let configuration = ScreenshotDemoConfiguration(scene: .hosts, language: language)
let hosts = configuration.makeHostLibrary()
let window = configuration.makeWindow(hosts: hosts)
+7 -2
View File
@@ -22,7 +22,10 @@ class Screenshots
raise "Initialize the website and assets first: git submodule update --init com.akinokaede.termind"
end
LOCALES = { "en" => ["US", "en_US"], "zh-Hans" => ["CN", "zh_CN"], "zh-Hant" => ["TW", "zh_TW"], "ja" => ["JP", "ja_JP"] }.freeze
LOCALES = {
"en" => ["US", "en_US"], "zh-Hans" => ["CN", "zh_CN"], "zh-Hant" => ["TW", "zh_TW"],
"ja" => ["JP", "ja_JP"], "ko" => ["KR", "ko_KR"], "de" => ["DE", "de_DE"], "fr" => ["FR", "fr_FR"]
}.freeze
MAC_SCENES = {
"hosts" => ["vault", "mac"],
"workspace" => ["workspace", "mac"],
@@ -143,7 +146,9 @@ class Screenshots
targets = targets.select { |target| !target.key?("BlueprintName") || target["BlueprintName"] == "#{@scheme}Tests" }
raise "Missing #{@scheme}Tests in xctestrun" if targets.empty?
targets.each do |target|
target["EnvironmentVariables"] = target.fetch("EnvironmentVariables", {}).merge(environment)
# Ghostty initializes the C locale from the environment. Keep numeric parsing
# invariant for CoreUI glyph geometry, independently of the localized app UI.
target["EnvironmentVariables"] = target.fetch("EnvironmentVariables", {}).merge(environment).merge("LC_NUMERIC" => "C")
target["TestLanguage"] = language
target["TestRegion"] = LOCALES.fetch(language).first
target["CommandLineArguments"] = arguments + ["-AppleLanguages", "(#{language})", "-AppleLocale", LOCALES.fetch(language).last]