diff --git a/.github/workflows/ci-guards.yml b/.github/workflows/ci-guards.yml index 0ad8e6a2ab54..ad8fbbea9846 100644 --- a/.github/workflows/ci-guards.yml +++ b/.github/workflows/ci-guards.yml @@ -142,6 +142,10 @@ jobs: if: ${{ matrix.group == 'ci' && steps.fast-guard.outputs.skip != 'true' }} run: python3 tests/test_ci_guard_attribution.py + - name: Validate main-fix merge evidence + if: ${{ matrix.group == 'ci' && steps.fast-guard.outputs.skip != 'true' }} + run: python3 tests/test_gh_merge_green.py + - name: Validate main compile attribution if: ${{ matrix.group == 'ci' && steps.fast-guard.outputs.skip != 'true' }} run: python3 tests/test_main_compile_attribution.py diff --git a/CLI/CMUXCLI+BrowserRepl.swift b/CLI/CMUXCLI+BrowserRepl.swift new file mode 100644 index 000000000000..bbfe457bdee0 --- /dev/null +++ b/CLI/CMUXCLI+BrowserRepl.swift @@ -0,0 +1,613 @@ +import Darwin +import Foundation + +extension CMUXCLI { + /// `cmux browser repl`: evaluates Playwright-style JavaScript against cmux + /// browser panes in a persistent JavaScriptCore session inside the app. + func runBrowserRepl(_ arguments: [String], client: SocketClient, jsonOutput: Bool) throws { + if let first = arguments.first?.lowercased() { + switch first { + case "guide": + print(Self.browserReplGuideText()) + return + case "list": + let payload = try client.sendV2(method: "browser.repl.list", params: [:]) + if jsonOutput { + print(jsonString(payload)) + } else { + for session in payload["sessions"] as? [[String: Any]] ?? [] { + let name = session["session"] as? String ?? "" + let idle = session["idle_seconds"] as? Int ?? 0 + let cwd = session["cwd"] as? String ?? "" + print("\(name)\t\(idle)s\t\(cwd)") + } + } + return + case "mcp": + try runBrowserReplMCP(Array(arguments.dropFirst()), client: client) + return + case "reset": + let (sessionOption, rest) = parseOption(Array(arguments.dropFirst()), name: "--session") + guard let session = sessionOption ?? rest.first, !session.isEmpty else { + throw CLIError(message: String( + localized: "cli.browser.repl.error.sessionRequired", + defaultValue: "A session name is required" + )) + } + let payload = try client.sendV2(method: "browser.repl.reset", params: ["session": session]) + print(jsonOutput ? jsonString(payload) : "OK") + return + default: + break + } + } + + var remaining = arguments + let (sessionOption, afterSession) = parseOption(remaining, name: "--session") + remaining = afterSession + let (evalOption, afterEval) = parseOption(remaining, name: "--eval") + remaining = afterEval + let (baseParams, timeoutMilliseconds, afterBase) = try browserReplBaseParams(remaining, client: client) + remaining = afterBase + if let stray = remaining.first(where: { $0.hasPrefix("--") && $0 != "--" }) { + let prefix = String( + localized: "cli.browser.repl.error.unknownOption", + defaultValue: "browser repl does not support this option" + ) + throw CLIError(message: "\(prefix): \(stray)") + } + + let positional = remaining.filter { $0 != "--" } + let code: String? + if let evalOption { + code = evalOption == "-" ? try Self.readBrowserReplStandardInput() : evalOption + } else if !positional.isEmpty { + code = positional.joined(separator: " ") + } else if isatty(STDIN_FILENO) == 0 { + code = try Self.readBrowserReplStandardInput() + } else { + code = nil + } + + if let code { + var params = baseParams + params["code"] = code + if let sessionOption { params["session"] = sessionOption } + let ok = try evaluateBrowserRepl(params: params, client: client, jsonOutput: jsonOutput, timeoutMilliseconds: timeoutMilliseconds) + if !ok { + // The error is already printed; only the exit status remains. + fflush(stdout) + exit(1) + } + return + } + + // Interactive: one line per cell in a session that lives until EOF. + let session = sessionOption ?? "cli-\(getpid())" + defer { + if sessionOption == nil { + _ = try? client.sendV2(method: "browser.repl.reset", params: ["session": session]) + } + } + while true { + FileHandle.standardError.write(Data("> ".utf8)) + guard let line = readLine(strippingNewline: true) else { break } + if line.trimmingCharacters(in: .whitespaces).isEmpty { continue } + var params = baseParams + params["code"] = line + params["session"] = session + _ = try evaluateBrowserRepl(params: params, client: client, jsonOutput: jsonOutput, timeoutMilliseconds: timeoutMilliseconds) + } + } + + /// Parses `--timeout`, `--workspace` and `--max-output` into the params + /// every `browser.repl.eval` call carries. + /// - Returns: The params, the timeout in milliseconds and the arguments left. + private func browserReplBaseParams( + _ arguments: [String], + client: SocketClient + ) throws -> ([String: Any], Int, [String]) { + var remaining = arguments + let (timeoutOption, afterTimeout) = parseOption(remaining, name: "--timeout") + remaining = afterTimeout + let (workspaceOption, afterWorkspace) = parseOption(remaining, name: "--workspace") + remaining = afterWorkspace + let (maxOutputOption, afterMaxOutput) = parseOption(remaining, name: "--max-output") + remaining = afterMaxOutput + var timeoutMilliseconds = 120_000 + if let timeoutOption { + guard let value = Int(timeoutOption), value > 0 else { + throw CLIError(message: String( + localized: "cli.browser.repl.error.timeout", + defaultValue: "--timeout must be a positive number of milliseconds" + )) + } + timeoutMilliseconds = value + } + + var baseParams: [String: Any] = [ + "cwd": FileManager.default.currentDirectoryPath, + "timeout_ms": timeoutMilliseconds, + ] + // Characters one call prints before the rest goes to a file; 0 for + // no limit. The runtime's default applies without the option. + if let maxOutputOption { + guard let value = Int(maxOutputOption), value >= 0 else { + throw CLIError(message: String( + localized: "cli.browser.repl.error.maxOutput", + defaultValue: "--max-output must be a number of characters, or 0 for no limit" + )) + } + baseParams["max_output"] = value + } + // `--workspace` must exist. `CMUX_WORKSPACE_ID` is only a hint: it can + // come from another cmux instance, so the app falls back to the + // focused workspace when it does not know the id. + if let workspaceOption, !workspaceOption.isEmpty { + if let workspace = try normalizeWorkspaceHandle(workspaceOption, client: client) { + baseParams["workspace_id"] = workspace + } + } else if let caller = ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + UUID(uuidString: caller) != nil { + baseParams["caller_workspace_id"] = caller + } + + return (baseParams, timeoutMilliseconds, remaining) + } + + /// `cmux browser repl mcp`: a Model Context Protocol server on stdio whose + /// tools run in one REPL session through `browser.repl.eval` and + /// `browser.repl.reset`, the socket methods the other subcommands use. + private func runBrowserReplMCP(_ arguments: [String], client: SocketClient) throws { + let (sessionOption, afterSession) = parseOption(arguments, name: "--session") + let (sharedParams, timeoutMilliseconds, remaining) = try browserReplBaseParams(afterSession, client: client) + var baseParams = sharedParams + // MCP hosts often start servers in `/` or the home directory, which + // the app refuses as an fs root and the agent cannot change. Send no + // cwd then, so the session gets a temporary directory of its own. + if let cwd = baseParams["cwd"] as? String, Self.browserReplCwdIsTooBroad(cwd) { + baseParams.removeValue(forKey: "cwd") + } + if let stray = remaining.first { + let prefix = String( + localized: "cli.browser.repl.error.unknownOption", + defaultValue: "browser repl does not support this option" + ) + throw CLIError(message: "\(prefix): \(stray)") + } + // Without --session each server process gets its own session, so two + // MCP clients never share variables and tabs by accident; a named + // session is how clients share one on purpose. + let namedSession = sessionOption.flatMap { $0.isEmpty ? nil : $0 } + let session = namedSession + ?? "mcp-\(getpid())-\(String(UInt32.random(in: .min ... .max), radix: 36))" + let responseTimeout = TimeInterval(timeoutMilliseconds) / 1000 + 15 + let evaluate = { (code: String, maxOutput: Int?) throws -> [String: Any] in + var params = baseParams + params["code"] = code + params["session"] = session + if let maxOutput { params["max_output"] = maxOutput } + return try client.sendV2(method: "browser.repl.eval", params: params, responseTimeout: responseTimeout) + } + let server = BrowserReplMCPServer(version: resolvedVersionInfo()["CFBundleShortVersionString"] ?? "dev") { name, arguments in + switch name { + case "reset": + let payload = try client.sendV2(method: "browser.repl.reset", params: ["session": session]) + let existed = payload["existed"] as? Bool ?? false + return .text(existed ? "Session \(session) reset" : "Session \(session) had no state") + case "screenshot": + let marker = "cmux-mcp-image:" + let code = BrowserReplMCPServer.screenshotCode(arguments, marker: marker) + let payload = try evaluate(code, 0) + let lines = (payload["output"] as? [[String: Any]] ?? []).compactMap { $0["text"] as? String } + if let error = payload["error"] as? String { + return BrowserReplMCPServer.ToolResult(content: [["type": "text", "text": (lines + [error]).joined(separator: "\n")]], isError: true) + } + guard let line = lines.last(where: { $0.hasPrefix(marker) }) else { + return BrowserReplMCPServer.ToolResult(content: [["type": "text", "text": lines.joined(separator: "\n")]], isError: true) + } + return BrowserReplMCPServer.ToolResult( + content: [["type": "image", "data": String(line.dropFirst(marker.count)), "mimeType": "image/png"]], + isError: false + ) + default: + guard let code = BrowserReplMCPServer.code(forTool: name, arguments: arguments) else { + throw BrowserReplMCPServer.Failure.unknownTool(name) + } + return BrowserReplMCPServer.result(ofEval: try evaluate(code, nil)) + } + } + while let line = readLine(strippingNewline: true) { + guard let reply = server.handle(line: line) else { continue } + FileHandle.standardOutput.write(Data((reply + "\n").utf8)) + } + // No other client can name this server's own session, so its tabs + // and variables end with the server instead of idling for 30 minutes. + if namedSession == nil { + _ = try? client.sendV2(method: "browser.repl.reset", params: ["session": session]) + } + } + + /// Whether the app refuses `path` as a REPL fs root: `/`, the home + /// directory or a directory containing it (`BrowserReplFileSandbox.rootRejection`). + private static func browserReplCwdIsTooBroad(_ path: String) -> Bool { + let canonical = (path as NSString).resolvingSymlinksInPath + let home = (NSHomeDirectory() as NSString).resolvingSymlinksInPath + return canonical == "/" || canonical == home || home.hasPrefix(canonical + "/") + } + + /// Sends one cell and prints its output, then `[ok | Nms]` or `[error | Nms]`. + /// - Returns: Whether the cell finished without an uncaught error. + private func evaluateBrowserRepl( + params: [String: Any], + client: SocketClient, + jsonOutput: Bool, + timeoutMilliseconds: Int + ) throws -> Bool { + let responseTimeout = TimeInterval(timeoutMilliseconds) / 1000 + 15 + let payload = try client.sendV2(method: "browser.repl.eval", params: params, responseTimeout: responseTimeout) + let ok = payload["ok"] as? Bool ?? false + if jsonOutput { + print(jsonString(payload)) + return ok + } + for line in payload["output"] as? [[String: Any]] ?? [] { + print(Self.browserReplTerminalText(line["text"] as? String ?? "")) + } + let duration = payload["duration_ms"] as? Int ?? 0 + let color = ProcessInfo.processInfo.environment["NO_COLOR"] == nil && isatty(STDOUT_FILENO) != 0 + if let error = (payload["error"] as? String).map(Self.browserReplTerminalText) { + print(color ? "\u{1B}[31m\(error)\u{1B}[0m" : error) + print(color ? "\u{1B}[31m[error | \(duration)ms]\u{1B}[0m" : "[error | \(duration)ms]") + } else { + print(color ? "\u{1B}[2m[ok | \(duration)ms]\u{1B}[0m" : "[ok | \(duration)ms]") + } + fflush(stdout) + return ok + } + + /// `text` with every control character except newline and tab made + /// visible: C0 as its Unicode control picture (ESC as U+241B), DEL as + /// U+2421 and C1 as `\u{9B}`. Output carries page text (a title, a + /// dialog, whatever a cell prints), whose escape sequences would act on + /// the terminal showing it: set its title, write its clipboard (OSC 52), + /// clear or redraw it. `--json` keeps the exact text. + static func browserReplTerminalText(_ text: String) -> String { + func isControl(_ value: UInt32) -> Bool { + (value < 0x20 && value != 0x09 && value != 0x0A) || (0x7F...0x9F).contains(value) + } + guard text.unicodeScalars.contains(where: { isControl($0.value) }) else { return text } + var visible = String.UnicodeScalarView() + for scalar in text.unicodeScalars { + switch scalar.value { + case let value where !isControl(value): + visible.append(scalar) + case 0x7F: + visible.append("\u{2421}") + case let value where value < 0x20: + visible.append(Unicode.Scalar(0x2400 + value) ?? "?") + case let value: + visible.append(contentsOf: "\\u{\(String(value, radix: 16, uppercase: true))}".unicodeScalars) + } + } + return String(visible) + } + + /// Reads stdin in chunks and stops as soon as it passes + /// `maximumEncodedTextBytes`, so an endless stream is refused instead of + /// being buffered whole. + private static func readBrowserReplStandardInput() throws -> String { + var data = Data() + while let chunk = try FileHandle.standardInput.read(upToCount: 1 << 20), !chunk.isEmpty { + data.append(chunk) + guard data.count <= maximumEncodedTextBytes else { + throw CLIError(message: String( + localized: "cli.browser.repl.error.inputTooLarge", + defaultValue: "REPL input is too large" + )) + } + } + guard let text = String(data: data, encoding: .utf8) else { + throw CLIError(message: String( + localized: "cli.browser.repl.error.inputEncoding", + defaultValue: "REPL input is not valid UTF-8" + )) + } + return text + } + + /// Help line for `cmux browser --help`. + static var browserReplHelp: String { + let usage = "repl [--session ] [--workspace ] [--eval |-] [--timeout ] [--max-output ] []" + let description = String( + localized: "cli.browser.help.replDescription", + defaultValue: "Run Playwright-style JavaScript against this workspace's browser panes; see `browser repl guide`" + ) + let mcpUsage = "repl mcp [--session ] [--workspace ] [--timeout ]" + let mcpDescription = String( + localized: "cli.browser.help.replMCPDescription", + defaultValue: "Serve the REPL as an MCP server on stdio (tools: eval, snapshot, screenshot, tabs, reset; its own session unless --session names one)" + ) + return "\(usage)\n \(description)\n \(mcpUsage)\n \(mcpDescription)" + } + + /// The guide shipped with the runtime (`browser-repl/guide.md` in the + /// enclosing app, or `CMUX_BROWSER_REPL_RUNTIME_DIR`), else the built-in text. + static func browserReplGuideText() -> String { + var directories: [URL] = [] + if let override = ProcessInfo.processInfo.environment["CMUX_BROWSER_REPL_RUNTIME_DIR"], !override.isEmpty { + directories.append(URL(fileURLWithPath: override, isDirectory: true)) + } + if let app = CLIExecutableLocator.enclosingAppBundle(), let resources = app.resourceURL { + directories.append(resources.appendingPathComponent("browser-repl", isDirectory: true)) + } + for directory in directories { + let url = directory.appendingPathComponent("guide.md") + if let text = try? String(contentsOf: url, encoding: .utf8), + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return text.hasSuffix("\n") ? String(text.dropLast()) : text + } + } + return browserReplGuide + } + + /// Built-in agent-facing guide, used when the runtime ships no `guide.md`. + static let browserReplGuide = """ + # `cmux browser repl` + + Run JavaScript in a persistent, sandboxed JavaScriptCore session that + drives the browser panes of your cmux workspace. The API is Playwright: + `page`, locators, `keyboard`, `mouse`, events and waits behave as in + Playwright. Input is native: pages see trusted events. + + ## Usage + + cmux browser repl 'await page.goto("https://example.com"); snapshot()' + cmux browser repl --eval - < script.js + cmux browser repl --session work --eval 'const s1 = await snapshot()' + cmux browser repl list | reset | guide + + Without `--session` each call is one-shot: its tabs close at the end + unless `page.keep()` was called. With `--session NAME`, top-level + `const`/`let` bindings and tabs persist across calls. Idle sessions close + after 30 minutes. The session binds to your cmux workspace, or to the + focused workspace when you run outside cmux. + + ## Environment + + - ES2023+ JavaScript with top-level await. + - 120 second timeout per call (`--timeout ` to change it). + - A call prints at most 25,000 characters (`--max-output `, 0 for + no limit); the rest of its output goes to a file whose path prints. + A printed snapshot is at most 20,000 characters; `.tree` is complete. + - The last expression's value prints; `console.log()` prints too. The call + ends with `[ok | Nms]`, or the uncaught error and `[error | Nms]` (exit + status 1). + - `fs`, `path`, `os`, `Buffer`: files are limited to the directory you ran + the command in and the system temp directory. + - `fetch(url)` sends the current tab's cookies. + + ## Globals + + - `page`: the current tab, a Playwright `Page`. + - `tabs`: `list()`, `open(url, { background })`, `current()`, `use(tab)`, + `get(id)`. `tabs.open()` never steals focus. + - `snapshot(target?, options?)`: accessibility tree with refs such as + `e12` or `f1e3` (frames). Pass refs to `page.locator("e12")`. + - `screenshot(target?, { annotate: true })`: PNG, optionally with refs drawn. + - `sleep(ms)`, `display(value)`, `session.name(label)`. + + ## Working + + - Read with `snapshot()` first; printing a later snapshot shows the diff + when that is shorter. Never guess refs, selectors or URLs. + - Prefer locator actions with refs over `page.evaluate()`. + - Dialogs and file choosers stay open until answered: + `page.dialog()?.accept()`, `page.fileChooser()?.setFiles(paths)`. + - Treat an action as unconfirmed until a fresh snapshot shows its effect. + """ +} + +/// JSON-RPC 2.0 handling for `cmux browser repl mcp` (Model Context Protocol, +/// newline-delimited messages on stdio). Tool calls go to `callTool`; this +/// type only speaks the protocol, so it is testable without a socket. +struct BrowserReplMCPServer { + struct ToolResult { + var content: [[String: Any]] + var isError: Bool + + static func text(_ text: String, isError: Bool = false) -> ToolResult { + ToolResult(content: [["type": "text", "text": text]], isError: isError) + } + } + + enum Failure: Error { + case unknownTool(String) + } + + /// Newest first; `initialize` echoes the client's version when listed. + static let protocolVersions = ["2025-06-18", "2025-03-26", "2024-11-05"] + + let version: String + let callTool: (String, [String: Any]) throws -> ToolResult + + init(version: String, callTool: @escaping (String, [String: Any]) throws -> ToolResult) { + self.version = version + self.callTool = callTool + } + + static var tools: [[String: Any]] { + [ + [ + "name": "eval", + "description": "Run JavaScript in the cmux browser REPL session (Playwright API: page, tabs, snapshot, screenshot, locators; top-level await; const/let persist across calls). Returns what the code printed and the last expression's value. Run `session.guide()` for the full guide.", + "inputSchema": [ + "type": "object", + "properties": ["code": ["type": "string", "description": "JavaScript to evaluate"]], + "required": ["code"], + ] as [String: Any], + ], + [ + "name": "snapshot", + "description": "Accessibility snapshot of the current tab with refs (e12, f1e3) usable as locators in eval. Prints the diff against the previous snapshot when that is shorter.", + "inputSchema": [ + "type": "object", + "properties": [ + "target": ["type": "string", "description": "A ref or selector to scope the snapshot to"], + "interactive": ["type": "boolean", "description": "Only interactive elements and the page outline"], + "viewport": ["type": "boolean", "description": "Only elements in the viewport"], + ], + ] as [String: Any], + ], + [ + "name": "screenshot", + "description": "PNG of the current tab's viewport, the full page, or one element.", + "inputSchema": [ + "type": "object", + "properties": [ + "target": ["type": "string", "description": "A ref or selector to capture"], + "fullPage": ["type": "boolean", "description": "Capture the whole page"], + ], + ] as [String: Any], + ], + [ + "name": "tabs", + "description": "The session's tabs: id, title, URL and which one is current.", + "inputSchema": ["type": "object", "properties": [String: Any]()] as [String: Any], + ], + [ + "name": "reset", + "description": "End the REPL session: close its tabs and forget its variables.", + "inputSchema": ["type": "object", "properties": [String: Any]()] as [String: Any], + ], + ] + } + + /// A JavaScript string literal for `text`. + static func literal(_ text: String) -> String { + let data = (try? JSONSerialization.data(withJSONObject: [text], options: [])) ?? Data("[\"\"]".utf8) + let array = String(decoding: data, as: UTF8.self) + return String(array.dropFirst().dropLast()) + } + + /// REPL code for the tools that are one evaluation. + static func code(forTool name: String, arguments: [String: Any]) -> String? { + switch name { + case "eval": + return arguments["code"] as? String + case "snapshot": + let target = (arguments["target"] as? String).map(literal) ?? "undefined" + let interactive = arguments["interactive"] as? Bool ?? false + let viewport = arguments["viewport"] as? Bool ?? false + return "await snapshot(\(target), { interactive: \(interactive), viewport: \(viewport) })" + case "tabs": + return "await tabs.list()" + default: + return nil + } + } + + /// REPL code that prints the screenshot as `marker` + base64 on one line. + static func screenshotCode(_ arguments: [String: Any], marker: String) -> String { + let fullPage = arguments["fullPage"] as? Bool ?? false + let capture: String + if let target = arguments["target"] as? String, !target.isEmpty { + capture = "await page.locator(\(literal(target))).screenshot()" + } else { + capture = "await page.screenshot({ fullPage: \(fullPage) })" + } + return "console.log(\(literal(marker)) + (\(capture)).toString(\"base64\")); undefined" + } + + /// A `browser.repl.eval` result as tool content: the printed lines, then + /// the uncaught error, if any, as an error result. + static func result(ofEval payload: [String: Any]) -> ToolResult { + var lines = (payload["output"] as? [[String: Any]] ?? []).compactMap { $0["text"] as? String } + let duration = payload["duration_ms"] as? Int ?? 0 + if let error = payload["error"] as? String { + lines.append(error) + lines.append("[error | \(duration)ms]") + return .text(lines.joined(separator: "\n"), isError: true) + } + lines.append("[ok | \(duration)ms]") + return .text(lines.joined(separator: "\n")) + } + + /// Handles one line; returns the reply line, or nil for a notification. + func handle(line: String) -> String? { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard let object = try? JSONSerialization.jsonObject(with: Data(trimmed.utf8), options: []), + let message = object as? [String: Any] else { + return Self.encode(Self.error(id: NSNull(), code: -32700, message: "Parse error")) + } + return handle(message).map(Self.encode) + } + + /// Handles one message; returns the response, or nil for a notification + /// or a response from the client. + func handle(_ message: [String: Any]) -> [String: Any]? { + let id = message["id"] + guard let method = message["method"] as? String else { + guard let id else { return nil } + if message["result"] != nil || message["error"] != nil { return nil } + return Self.error(id: id, code: -32600, message: "Invalid Request") + } + guard let id, !(id is NSNull) else { return nil } + let params = message["params"] as? [String: Any] ?? [:] + switch method { + case "initialize": + let requested = params["protocolVersion"] as? String ?? "" + let version = Self.protocolVersions.contains(requested) ? requested : Self.protocolVersions[0] + return Self.reply(id: id, result: [ + "protocolVersion": version, + "capabilities": ["tools": ["listChanged": false]], + "serverInfo": ["name": "cmux-browser-repl", "version": self.version], + "instructions": "Drive cmux browser tabs with Playwright-style JavaScript through the eval tool; read pages with snapshot and act on its refs.", + ]) + case "ping": + return Self.reply(id: id, result: [:]) + case "tools/list": + return Self.reply(id: id, result: ["tools": Self.tools]) + case "tools/call": + guard let name = params["name"] as? String else { + return Self.error(id: id, code: -32602, message: "tools/call needs a tool name") + } + let arguments = params["arguments"] as? [String: Any] ?? [:] + guard Self.tools.contains(where: { $0["name"] as? String == name }) else { + return Self.error(id: id, code: -32602, message: "Unknown tool: \(name)") + } + if name == "eval", arguments["code"] as? String == nil { + return Self.error(id: id, code: -32602, message: "eval needs code, a string") + } + let result: ToolResult + do { + result = try callTool(name, arguments) + } catch let failure as CLIError { + result = .text(failure.message, isError: true) + } catch { + result = .text(String(describing: error), isError: true) + } + return Self.reply(id: id, result: ["content": result.content, "isError": result.isError]) + default: + return Self.error(id: id, code: -32601, message: "Method not found: \(method)") + } + } + + private static func reply(id: Any, result: [String: Any]) -> [String: Any] { + ["jsonrpc": "2.0", "id": id, "result": result] + } + + private static func error(id: Any, code: Int, message: String) -> [String: Any] { + ["jsonrpc": "2.0", "id": id, "error": ["code": code, "message": message] as [String: Any]] + } + + private static func encode(_ message: [String: Any]) -> String { + guard JSONSerialization.isValidJSONObject(message), + let data = try? JSONSerialization.data(withJSONObject: message, options: [.withoutEscapingSlashes]) else { + return "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32603,\"message\":\"Internal error\"}}" + } + return String(decoding: data, as: UTF8.self) + } +} diff --git a/CLI/CMUXCLI+TaskHelp.swift b/CLI/CMUXCLI+TaskHelp.swift index 45932e9ff921..f39ddea28b9c 100644 --- a/CLI/CMUXCLI+TaskHelp.swift +++ b/CLI/CMUXCLI+TaskHelp.swift @@ -361,6 +361,7 @@ extension CMUXCLI { browser url|get-url browser snapshot [--interactive|-i] [--cursor] [--compact] [--max-depth ] [--selector ] browser eval " + + @Test func textInAClosedShadowRootIsMasked() async throws { + let webView = await load(""" + +
+ + \(Self.post) + """, posting: ["main"]) + let main = try #require(frames.infos["main"]) + let during = try await mask.run(in: webView, frames: { [main] }) { + ( + field: try await security("document.getElementById('field')", in: webView), + inner: try await security("window.__closed.getElementById('inner')", in: webView) + ) + } + #expect(during.field == "disc") + #expect(during.inner == "disc", "the value in a closed shadow root rendered unmasked") + #expect(try await security("window.__closed.getElementById('inner')", in: webView) == "none") + } + + /// A text node that is a shadow root's own child (a Lit-style component + /// renders its text that way) has no parent element; its host is what + /// renders it, so the host is masked and checked. + @Test func textDirectlyInAShadowRootIsMasked() async throws { + let webView = await load(""" +
+ + \(Self.post) + """, posting: ["main"]) + let main = try #require(frames.infos["main"]) + let during = try await mask.run(in: webView, frames: { [main] }) { + try await security("document.getElementById('host')", in: webView) + } + #expect(during == "disc", "a shadow root's own text rendered unmasked") + #expect(try await security("document.getElementById('host')", in: webView) == "none") + } + + /// The page drops the mask from a shadow root's text while the capture + /// runs: the check after the capture finds it and refuses the capture. + @Test func aShadowRootTextThePageUnmasksRefusesTheCapture() async throws { + let webView = await load(""" +
+ + \(Self.post) + """, posting: ["main"]) + let main = try #require(frames.infos["main"]) + await #expect(throws: BrowserReplDriverError.self) { + try await mask.run(in: webView, frames: { [main] }) { + try await page("document.getElementById('host').style.setProperty('\(Self.prop)', 'none', 'important')", in: webView) + } + } + } + + /// The page drops the mask while the capture runs (a framework that + /// re-renders the field's style, or page script): the capture may have + /// drawn the value, so it is refused. + @Test func aMaskThePageRemovesDuringTheCaptureRefusesIt() async throws { + let webView = await load("\(Self.post)", posting: ["main"]) + let main = try #require(frames.infos["main"]) + var captured = false + await #expect(throws: BrowserReplDriverError.self) { + try await mask.run(in: webView, frames: { [main] }) { + try await page("document.getElementById('field').style.removeProperty('\(Self.prop)')", in: webView) + captured = true + } + } + #expect(captured) + #expect(try await security("document.getElementById('field')", in: webView) == "none") + } + + /// A value in an element that has no inline style (an element of another + /// namespace) is masked through its nearest styled ancestor, and does + /// not stop the scan from masking the elements after it. + @Test func aValueInAnElementWithoutStyleIsMasked() async throws { + let webView = await load(""" +

+

\(Self.value)

+ + \(Self.post) + """, posting: ["main"]) + let main = try #require(frames.infos["main"]) + let during = try await mask.run(in: webView, frames: { [main] }) { + ( + odd: try await security("document.getElementById('odd').firstChild", in: webView), + after: try await security("document.getElementById('after')", in: webView) + ) + } + #expect(during.odd == "disc") + #expect(during.after == "disc") + } + + /// A frame keeps its id when it navigates, so the frame list can name a + /// document of the secret's domain that the frame no longer shows. The + /// mask goes by the document the frame shows when it runs: a page of + /// another origin gets no value and no masking that would tell it + /// where the value is. + @Test func aFrameThatNavigatedSinceTheListGetsNoValue() async throws { + let webView = await load(""" + + \(Self.post) + """, posting: ["main", "child"]) + let main = try #require(frames.infos["main"]) + let stale = try #require(frames.infos["child"]) + try await page(""" + const child = document.getElementById('child'); + child.removeAttribute('srcdoc'); + child.src = 'data:text/html,

\(Self.value)

'; + """, in: webView) + let frames = frames + await frames.wait { frames.infos["moved"] != nil } + let moved = try #require(frames.infos["moved"]) + let during = try await mask.run(in: webView, frames: { [main, stale] }) { + try await webView.callAsyncJavaScript( + "return getComputedStyle(document.getElementById('p')).getPropertyValue('\(Self.prop)')", + arguments: [:], in: moved, contentWorld: .page + ) as? String + } + #expect(during == "none", "a page of another origin was masked with the secret's value") + } + + /// A frame shows a page of the secret's domain only while the capture + /// runs, then another page again: it was never masked, so the capture + /// may show the value, and it is refused. + @Test func aFrameThatShowsAnotherDocumentDuringTheCaptureRefusesIt() async throws { + let webView = await load(""" + + \(Self.post) + """, posting: ["main", "child"]) + let main = try #require(frames.infos["main"]) + let child = try #require(frames.infos["child"]) + let frames = frames + var captured = false + await #expect(throws: BrowserReplDriverError.self) { + try await mask.run(in: webView, frames: { [main, child] }) { + try await page(""" + document.getElementById('child').srcdoc = '

\(Self.value)

'; + """, in: webView) + await frames.wait { frames.infos["shown"] != nil } + try await page(""" + const child = document.getElementById('child'); + child.removeAttribute('srcdoc'); + child.src = 'data:text/html,'; + """, in: webView) + await frames.wait { frames.infos["back"] != nil } + captured = true + } + } + #expect(captured) + } + + /// The tab shows a page the domain policy blocks only while the capture + /// runs: the capture is refused, also with no secret to mask. + @Test func aBlockedPageShownDuringTheCaptureRefusesIt() async throws { + let webView = await load(Self.post, posting: ["main"]) + var policy = BrowserReplDomainPolicy() + policy.allowed = [try BrowserReplDomainPattern.parse("example.test", title: "test")] + let frames = frames + var captured = false + await #expect(throws: BrowserReplDriverError.self) { + try await BrowserReplCaptureMask(secretMasks: [], policy: policy).run(in: webView, frames: { [nil] }) { + frames.finished = false + webView.loadHTMLString( + "

blocked

", + baseURL: URL(string: "https://blocked.test/") + ) + await frames.wait { frames.finished && frames.infos["blocked"] != nil } + captured = true + } + } + #expect(captured) + } + + // MARK: Blocked child frames + + /// A frame keeps its id when it navigates, so a child frame can show a + /// page the policy blocks after the driver judged the tree and before + /// the mask marks it. A PDF cannot blank a frame, so a child frame whose + /// marked document is blocked refuses it. + @Test func aPDFOfAChildFrameWhoseDocumentIsBlockedIsRefused() async throws { + let page = try await FramePage.load() + let mask = BrowserReplCaptureMask(secretMasks: [], policy: Self.policy(prohibiting: "cmux-test://blocked.test"), blockedChildFrames: .refuse) + var captured = false + let error = await BrowserReplFrameGateTests.error { + try await mask.run(in: page.webView, frames: { page.frames.map(\.info) }) { captured = true } + } + #expect(error?.code == "blocked", "a PDF of a blocked child frame was allowed: \(String(describing: error))") + #expect(!captured) + } + + /// A screenshot blanks blocked frames itself: the mask hands it each + /// child frame whose marked document the policy blocks, so a frame that + /// navigated to a blocked page after the tree was judged is blanked too. + @Test func aScreenshotIsHandedTheChildFramesWhoseDocumentIsBlocked() async throws { + let page = try await FramePage.load() + let blocked = try #require(page.frame(host: "blocked.test")) + let allowed = try #require(page.frame(path: "/child")) + let mask = BrowserReplCaptureMask(secretMasks: [], policy: Self.policy(prohibiting: "cmux-test://blocked.test"), blockedChildFrames: .handToCapture) + let handed = try await mask.run(in: page.webView, frames: { page.frames.map(\.info) }) { blockedChildFrames in + blockedChildFrames + } + #expect(handed[blocked.frameID] != nil, "the blocked child frame was not handed to the screenshot: \(handed)") + #expect(handed[allowed.frameID] == nil, "an allowed child frame was handed to the screenshot as blocked") + } + + // MARK: Scripts that never answer + + /// WebKit drops a script's completion when a navigation replaces its + /// document, and a busy page answers late: the mask's scripts are + /// bounded, and one that does not answer refuses the capture with + /// `stale` instead of hanging it. + @Test func aMaskScriptThatNeverAnswersRefusesWithStale() async throws { + let page = try await FramePage.load() + let mask = BrowserReplCaptureMask(secretMasks: [], policy: Self.policy(prohibiting: "cmux-test://other.test")) + let infos = SendableBox(page.frames.map(\.info)) + let webView = SendableBox(page.webView) + // The page's web process runs no other script for 8 s, past the + // 5 s bound. + BrowserReplFrameGateTests.startBusyLoop(in: page.webView, seconds: 8) + let error = await browserReplWithDeadline(seconds: 20) { @MainActor in + await BrowserReplFrameGateTests.error { + try await mask.run(in: webView.value, frames: { infos.value }) { true } + } + } + #expect(error??.code == "stale", "a mask script that did not answer hung or passed: \(String(describing: error))") + } + + static func policy(prohibiting pattern: String) -> BrowserReplDomainPolicy { + var policy = BrowserReplDomainPolicy() + policy.prohibited = [try! BrowserReplDomainPattern.parse(pattern, title: "test")] + return policy + } + + /// The mask step fails in a frame on the secret's domain (here the frame + /// went away after the frame list was read): the capture is refused + /// rather than taken with that frame unmasked. + @Test func aMaskStepThatFailsRefusesTheCapture() async throws { + let webView = await load(""" +

\(Self.value)

+ + \(Self.post) + """, posting: ["main", "child"]) + let main = try #require(frames.infos["main"]) + let child = try #require(frames.infos["child"]) + try await page("document.getElementById('child').remove()", in: webView) + var captured = false + await #expect(throws: BrowserReplDriverError.self) { + try await mask.run(in: webView, frames: { [main, child] }) { captured = true } + } + #expect(!captured) + #expect(try await security("document.querySelector('p')", in: webView) == "none") + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplClipboardItemsTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplClipboardItemsTests.swift new file mode 100644 index 000000000000..77b117a36816 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplClipboardItemsTests.swift @@ -0,0 +1,131 @@ +import AppKit +import Testing +import UniformTypeIdentifiers + +@testable import CmuxBrowser + +/// A REPL tab's clipboard is filled by the agent (`clipboard.write`) and +/// then pasted into the page by WebKit's trusted Paste. A file reference +/// on that pasteboard (a file URL, a Finder filename list, an alias, a +/// file promise) would let WebKit hand a local file to the page as a +/// `File`, outside the session's file root, so none may reach it. Other +/// data, a web URL included, is pasted as given. +@MainActor +@Suite("Browser REPL clipboard items") +struct BrowserReplClipboardItemsTests { + private static func item(_ type: String, _ text: String) -> [String: Any] { + ["type": type, "base64": Data(text.utf8).base64EncodedString()] + } + + private static let secret = URL(fileURLWithPath: "/etc/hosts") + + /// Every way a file reference can be spelled on a pasteboard, by MIME + /// type or by raw pasteboard type. + nonisolated static let fileReferences: [String] = [ + "public.file-url", + "text/uri-list", + "NSFilenamesPboardType", + "com.apple.pasteboard.promised-file-url", + "com.apple.pasteboard.promised-file-content-type", + "NSPromiseContentsPboardType", + "com.apple.NSFilePromiseItemMetaData", + "Apple files promise pasteboard type", + "com.apple.alias-file", + "com.apple.finder.node", + "Apple URL pasteboard type", + "CorePasteboardFlavorType 0x6675726C", + "WebURLsWithTitlesPboardType", + ] + + @Test(arguments: fileReferences) + func aFileReferenceNeverReachesThePasteboard(type: String) { + let pasteboard = NSPasteboard.withUniqueName() + defer { pasteboard.releaseGlobally() } + let path = Self.secret.path + let value: String = switch type { + case "NSFilenamesPboardType", "WebURLsWithTitlesPboardType": + "\(Self.secret.absoluteString)\(path)" + case "Apple files promise pasteboard type", "com.apple.pasteboard.promised-file-content-type", "NSPromiseContentsPboardType": + "public.data" + default: + Self.secret.absoluteString + } + pasteboard.writeBrowserReplClipboardItems([Self.item("text/plain", "kept"), Self.item(type, value)]) + + let fileURLs = pasteboard.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) ?? [] + #expect(fileURLs.isEmpty, "a \(type) item put a file URL on the pasteboard WebKit pastes from") + // Only the text item may be on the pasteboard, under any name + // (a legacy type such as NSFilenamesPboardType is listed as a + // dynamic type). + let types = pasteboard.types ?? [] + #expect(types.contains(.string), "the item's text was dropped with the file reference") + let onlyText = types.allSatisfy { $0 == .string || $0.rawValue == "NSStringPboardType" } + #expect(onlyText, "a \(type) item reached the pasteboard WebKit pastes from") + } + + @Test func aWebURLIsPastedAsGiven() { + let pasteboard = NSPasteboard.withUniqueName() + defer { pasteboard.releaseGlobally() } + pasteboard.writeBrowserReplClipboardItems([Self.item("text/uri-list", "https://example.com/a")]) + #expect(pasteboard.string(forType: .URL) == "https://example.com/a") + } + + private static func item(_ type: String, data: Data) -> [String: Any] { + ["type": type, "base64": data.base64EncodedString()] + } + + /// A denylist of names and a UTF-8 `file:` scan misses encodings a page + /// or agent can choose: a URL type holding UTF-16, a filename list as a + /// binary property list, a raw type name, RTFD with attachments. Only + /// the types the virtual clipboard needs may reach the pasteboard. + nonisolated static let evasive: [(type: String, data: Data)] = { + let fileURL = URL(fileURLWithPath: "/etc/hosts").absoluteString + let binaryList = (try? PropertyListSerialization.data(fromPropertyList: ["/etc/hosts"], format: .binary, options: 0)) ?? Data() + return [ + ("public.url", fileURL.data(using: .utf16LittleEndian)!), + ("public.url", fileURL.data(using: .utf16)!), + ("Apple URL pasteboard type", binaryList), + ("NSFilenamesPboardType", binaryList), + ("com.apple.flat-rtfd", Data("rtfd".utf8)), + ("com.apple.webarchive", Data("archive".utf8)), + ("public.utf8-plain-text-but-not", Data("x".utf8)), + ("dyn.ah62d4rv4gu8yc6durvwwaznwmuuha2pxsvw0e55bsmwca7d3sbwu", Data(fileURL.utf8)), + ("text/uri-list", Data("javascript:alert(1)".utf8)), + ("text/uri-list", Data("https://example.com/a\nfile:///etc/hosts".utf8)), + ("text/uri-list", Data("data:text/html,x".utf8)), + ] + }() + + @Test(arguments: evasive.indices) + func onlyAllowedTypesReachThePasteboard(index: Int) { + let entry = Self.evasive[index] + let pasteboard = NSPasteboard.withUniqueName() + defer { pasteboard.releaseGlobally() } + pasteboard.writeBrowserReplClipboardItems([Self.item("text/plain", "kept"), Self.item(entry.type, data: entry.data)]) + let types = pasteboard.types ?? [] + #expect(types.contains(.string), "the item's text was dropped with the \(entry.type) item") + let onlyText = types.allSatisfy { $0 == .string || $0.rawValue == "NSStringPboardType" } + #expect(onlyText, "a \(entry.type) item (case \(index)) reached the pasteboard WebKit pastes from: \(types.map(\.rawValue))") + } + + /// What the virtual clipboard needs still reaches it: text, HTML, RTF, + /// PNG, TIFF, WebKit's custom web data and an http(s) URL. + @Test func theTypesTheTabClipboardNeedsArePasted() { + let pasteboard = NSPasteboard.withUniqueName() + defer { pasteboard.releaseGlobally() } + pasteboard.writeBrowserReplClipboardItems([ + Self.item("text/plain", "text"), + Self.item("text/html", "html"), + Self.item("text/rtf", "{\\rtf1 rtf}"), + Self.item("image/png", "png"), + Self.item("image/tiff", "tiff"), + Self.item("com.apple.WebKit.custom-pasteboard-data", "custom"), + Self.item("text/uri-list", "http://example.com/b"), + ]) + let types = Set(pasteboard.types ?? []) + for expected: NSPasteboard.PasteboardType in [.string, .html, .rtf, .png, .tiff, .URL, NSPasteboard.PasteboardType("com.apple.WebKit.custom-pasteboard-data")] { + #expect(types.contains(expected), "\(expected.rawValue) was dropped") + } + } +} + diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplContentRuleListsTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplContentRuleListsTests.swift new file mode 100644 index 000000000000..376cc07aee75 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplContentRuleListsTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing +import WebKit +@testable import CmuxBrowser + +/// A session's compiled domain-policy rule list lives in a persistent +/// WKContentRuleListStore; it must not outlive the session's rules. +@MainActor +@Suite struct BrowserReplContentRuleListsTests { + private let rules: [Any] = [ + ["trigger": ["url-filter": "blocked\\.example"], "action": ["type": "block"]], + ] + + private func temporaryStore() throws -> (WKContentRuleListStore, URL) { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-rule-lists-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let store = try #require(WKContentRuleListStore(url: directory)) + return (store, directory) + } + + private func identifiers(in store: WKContentRuleListStore) async -> [String] { + await withCheckedContinuation { continuation in + store.getAvailableContentRuleListIdentifiers { continuation.resume(returning: $0 ?? []) } + } + } + + @Test func compiledRulesAreStoredUnderTheSessionIdentifier() async throws { + let (store, directory) = try temporaryStore() + defer { try? FileManager.default.removeItem(at: directory) } + let lists = BrowserReplContentRuleLists(sessionID: "one shot/1", store: store) + #expect(lists.identifier == "cmux.browser-repl.one_shot_1") + let list = try await lists.update(rules: rules) + #expect(list != nil) + #expect(await identifiers(in: store) == [lists.identifier]) + } + + @Test func clearingTheRulesRemovesTheCompiledList() async throws { + let (store, directory) = try temporaryStore() + defer { try? FileManager.default.removeItem(at: directory) } + let lists = BrowserReplContentRuleLists(sessionID: "session", store: store) + _ = try await lists.update(rules: rules) + #expect(try await lists.update(rules: nil) == nil) + #expect(await identifiers(in: store).isEmpty) + } + + @Test func clearingNeverCompiledRulesIsQuiet() async throws { + let (store, directory) = try temporaryStore() + defer { try? FileManager.default.removeItem(at: directory) } + let lists = BrowserReplContentRuleLists(sessionID: "session", store: store) + #expect(try await lists.update(rules: []) == nil) + #expect(await identifiers(in: store).isEmpty) + } + + @Test func rulesThatDoNotCompileFailAsInvalid() async throws { + let (store, directory) = try temporaryStore() + defer { try? FileManager.default.removeItem(at: directory) } + let lists = BrowserReplContentRuleLists(sessionID: "session", store: store) + await #expect(throws: BrowserReplDriverError.self) { + _ = try await lists.update(rules: [["trigger": [:], "action": ["type": "block"]]]) + } + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplCookieClearScopeTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplCookieClearScopeTests.swift new file mode 100644 index 000000000000..e2709d291ca9 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplCookieClearScopeTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing + +@testable import CmuxBrowser + +@Suite("Browser REPL cookies.clear scope") +struct BrowserReplCookieClearScopeTests { + private struct Cookie: Sendable { + let name: String + let domain: String + let path: String + } + + private static let jar: [Cookie] = [ + Cookie(name: "sid", domain: ".example.com", path: "/"), + Cookie(name: "pref", domain: "www.example.com", path: "/"), + Cookie(name: "sid", domain: "api.example.com", path: "/v1"), + Cookie(name: "sid", domain: ".notexample.com", path: "/"), + Cookie(name: "sid", domain: "other.org", path: "/"), + Cookie(name: "sid", domain: "x.co.at", path: "/"), + Cookie(name: "sid", domain: ".y.co.at", path: "/"), + ] + + private static let suffixes = BrowserReplPublicSuffixList(isPublicSuffix: { ["com", "org", "at", "co.at"].contains($0) }) + + private func cleared(_ params: [String: Any], tab: String?, persistent: Bool = true) throws -> [String] { + let scope = try BrowserReplCookieClearScope( + params: params, + tabURL: tab.flatMap(URL.init(string:)), + storeIsPersistent: persistent, + publicSuffixes: Self.suffixes + ) + return Self.jar + .filter { scope.includes(name: $0.name, domain: $0.domain, path: $0.path) } + .map { "\($0.domain) \($0.name)" } + } + + @Test func theTabsSiteIsClearedWhateverSiteTheCallerNames() throws { + #expect(try cleared([:], tab: "https://www.example.com/a") == [".example.com sid", "www.example.com pref", "api.example.com sid"]) + #expect(try cleared(["site": "other.org"], tab: "https://WWW.Example.com/") == [".example.com sid", "www.example.com pref", "api.example.com sid"]) + } + + @Test func aSiteUnderAMultiLabelPublicSuffixStaysOnItsOwnDomain() throws { + #expect(try cleared([:], tab: "https://a.x.co.at/") == ["x.co.at sid"]) + } + + @Test func aTabWithNoSiteIsRefusedOnTheProfile() { + #expect(throws: BrowserReplCookieClearScope.Refusal.self) { try cleared([:], tab: "about:blank") } + #expect(throws: BrowserReplCookieClearScope.Refusal.self) { try cleared(["site": "example.com"], tab: nil) } + } + + @Test func allIsRefusedOnTheProfile() { + #expect(throws: BrowserReplCookieClearScope.Refusal.self) { try cleared(["all": true], tab: "https://example.com/") } + } + + @Test func filtersMatchExactlyInsideTheSite() throws { + #expect(try cleared(["name": "sid"], tab: "https://example.com/") == [".example.com sid", "api.example.com sid"]) + #expect(try cleared(["domain": "api.example.com"], tab: "https://example.com/") == ["api.example.com sid"]) + #expect(try cleared(["path": "/v1"], tab: "https://example.com/") == ["api.example.com sid"]) + #expect(try cleared(["domain": "other.org"], tab: "https://example.com/").isEmpty) + } + + @Test func aStoreThatIsNotPersistentMayBeClearedWhole() throws { + #expect(try cleared(["all": true], tab: "https://example.com/", persistent: false).count == Self.jar.count) + #expect(try cleared([:], tab: "about:blank", persistent: false).count == Self.jar.count) + #expect(try cleared(["name": "pref"], tab: nil, persistent: false) == ["www.example.com pref"]) + #expect(try cleared([:], tab: "https://example.com/", persistent: false).count == 3) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplCookieMatchTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplCookieMatchTests.swift new file mode 100644 index 000000000000..cce5aa0dbd2c --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplCookieMatchTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing + +@testable import CmuxBrowser + +@Suite("Browser REPL cookie URL match") +struct BrowserReplCookieMatchTests { + private func cookie(path: String, domain: String = "example.com", secure: Bool = false) throws -> HTTPCookie { + var properties: [HTTPCookiePropertyKey: Any] = [.name: "sid", .value: "1", .domain: domain, .path: path] + if secure { properties[.secure] = "TRUE" } + return try #require(HTTPCookie(properties: properties)) + } + + @Test( + "A cookie's path matches its own path and the paths below it (RFC 6265 path-match)", + arguments: [ + ("/account", "/account", true), + ("/account", "/account/", true), + ("/account", "/account/settings", true), + ("/account/", "/account/settings", true), + ("/account/", "/account/", true), + ("/", "/anything", true), + ("/account", "/accounting", false), + ("/account", "/account-admin/x", false), + ("/account/", "/account", false), + ("/account", "/", false), + ] + ) + func pathMatch(cookiePath: String, requestPath: String, matches: Bool) throws { + let url = try #require(URL(string: "https://example.com" + requestPath)) + #expect(try cookie(path: cookiePath).browserReplMatches(url) == matches) + } + + @Test("Domain and Secure rules still apply") + func domainAndSecure() throws { + let parent = try cookie(path: "/", domain: ".example.com") + #expect(parent.browserReplMatches(try #require(URL(string: "https://app.example.com/x")))) + #expect(!parent.browserReplMatches(try #require(URL(string: "https://example.org/x")))) + let secure = try cookie(path: "/", secure: true) + #expect(!secure.browserReplMatches(try #require(URL(string: "http://example.com/")))) + #expect(try cookie(path: "/", domain: "localhost", secure: true).browserReplMatches(try #require(URL(string: "http://localhost:3000/")))) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplDataStoreIDTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplDataStoreIDTests.swift new file mode 100644 index 000000000000..4b4986e8d66c --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplDataStoreIDTests.swift @@ -0,0 +1,42 @@ +import Testing +import WebKit + +@testable import CmuxBrowser + +/// `tabs.open({ dataStore })` opens a tab in the store a listed id names. +/// An id must never name a later store after its own is freed, or a +/// session holding an old id would open a tab in another profile's +/// cookies and storage. +@MainActor +@Suite("Browser REPL data store ids") +struct BrowserReplDataStoreIDTests { + @Test func aStoreKeepsItsIDAndOtherStoresHaveTheirOwn() { + let store = WKWebsiteDataStore.nonPersistent() + let other = WKWebsiteDataStore.nonPersistent() + #expect(store.browserReplID == store.browserReplID) + #expect(store.browserReplID != other.browserReplID) + #expect(store.browserReplID != WKWebsiteDataStore.default().browserReplID) + } + + /// WebKit frees a store a moment after its last reference goes, and a + /// later store often takes the same address. + @Test func aFreedStoresIDIsNotReused() async throws { + var seen = Set() + let count = 20 + for _ in 0.. BrowserReplDomainPolicy { + var policy = BrowserReplDomainPolicy() + policy.allowed = try allowed?.map { try BrowserReplDomainPattern.parse($0, title: "t") } + policy.prohibited = try prohibited.map { try BrowserReplDomainPattern.parse($0, title: "t") } + policy.blockIPAddresses = blockIPs + return policy + } + + @Test("Setting a cookie on a parent domain needs every subdomain allowed and none prohibited") + func cookieSetScope() throws { + let one = try policy(allowed: ["https://www.parent.test"]) + #expect(one.cookieSetBlockReason(domain: ".parent.test") != nil) + #expect(one.cookieSetBlockReason(domain: "www.parent.test") == nil) + #expect(one.cookieSetBlockReason(domain: "api.parent.test") != nil) + let all = try policy(allowed: ["*.parent.test"]) + #expect(all.cookieSetBlockReason(domain: ".parent.test") == nil) + let banned = try policy(prohibited: ["api.parent.test"]) + #expect(banned.cookieSetBlockReason(domain: ".parent.test") != nil) + #expect(banned.cookieSetBlockReason(domain: "www.parent.test") == nil) + #expect(BrowserReplDomainPolicy().cookieSetBlockReason(domain: ".anything.test") == nil) + } + + @Test("Hosts are normalized: case, trailing dots and internationalized names") + func hostNormalization() { + #expect(BrowserReplHostName.normalize("EXAMPLE.com.") == "example.com") + #expect(BrowserReplHostName.normalize("example.com..") == "example.com") + #expect(BrowserReplHostName.normalize("bücher.de") == "xn--bcher-kva.de") + #expect(BrowserReplHostName.normalize("ÜBER.example") == "xn--ber-goa.example") + #expect(BrowserReplHostName.normalize("::1") == "[::1]") + } + + @Test("Cookies are in reach by host: an allowed host's own and parent-domain cookies, never a prohibited host's") + func cookieReach() throws { + let open = BrowserReplDomainPolicy() + #expect(open.cookieBlockReason(domain: ".anything.example") == nil) + let allowed = try policy(allowed: ["https://www.example.com:8443"]) + #expect(allowed.cookieBlockReason(domain: "www.example.com") == nil) + #expect(allowed.cookieBlockReason(domain: ".example.com") == nil, "www.example.com receives example.com's cookies") + #expect(allowed.cookieBlockReason(domain: "api.example.com") != nil) + #expect(allowed.cookieBlockReason(domain: "other.org") != nil) + let prohibited = try policy(prohibited: ["http://127.0.0.1:9999", "*.evil.example"]) + #expect(prohibited.cookieBlockReason(domain: "127.0.0.1") != nil, "a port does not narrow a host's cookies") + #expect(prohibited.cookieBlockReason(domain: ".evil.example") != nil) + #expect(prohibited.cookieBlockReason(domain: "a.evil.example") != nil) + #expect(prohibited.cookieBlockReason(domain: "localhost") == nil) + #expect(try policy(blockIPs: true).cookieBlockReason(domain: "[::1]") != nil) + } + + @Test("IP hosts in any form a URL parser reads as one") + func ipHosts() { + for host in ["127.0.0.1", "127.1", "2130706433", "0x7f.0.0.1", "[::1]"] { + #expect(BrowserReplHostName.isIPAddress(BrowserReplHostName.normalize(host)), "\(host)") + } + #expect(!BrowserReplHostName.isIPAddress("example.com")) + } + + @Test("Prohibited and allowed domains match whatever the URL's spelling") + func blockReasons() throws { + let prohibited = try policy(prohibited: ["example.com", "bücher.de"]) + for url in ["https://example.com/", "https://example.com./", "https://EXAMPLE.COM../x", "https://www.example.com/", "https://xn--bcher-kva.de/"] { + #expect(prohibited.blockReason(url) != nil, "\(url)") + } + #expect(prohibited.blockReason("https://api.example.com/") == nil) + let allowed = try policy(allowed: ["*.example.com"], blockIPs: true) + #expect(allowed.blockReason("https://a.b.example.com./") == nil) + #expect(allowed.blockReason("https://example.org/") != nil) + #expect(allowed.blockReason("http://127.1/")?.contains("IP addresses") == true) + #expect(allowed.blockReason("data:text/plain,x") == nil) + } + + @Test("Content rules block a prohibited host with or without a trailing dot") + func contentRules() throws { + let rules = try policy(prohibited: ["example.com"]).contentRules + let filters = rules.compactMap { ($0["trigger"] as? [String: Any])?["url-filter"] as? String } + let matches = { (url: String) in filters.contains { url.range(of: $0, options: [.regularExpression, .caseInsensitive]) != nil } } + #expect(matches("https://example.com/a.js")) + #expect(matches("https://example.com./a.js")) + #expect(matches("wss://example.com:8443/socket")) + #expect(!matches("https://example.community/a.js")) + } + + @Test("Redaction masks a value in its encodings: percent-encoded (any case), JSON, HTML and Base64 Authorization") + func redactionForms() throws { + let store = BrowserReplSecretStore() + try store.set(name: "pw", value: "p@ss w/rd:1", domains: ["example.com"], totp: false, title: "t") + let basic = Data("ada:p@ss w/rd:1".utf8).base64EncodedString() + let samples = [ + "p@ss w/rd:1", + "p%40ss%20w%2Frd%3A1", + "p%40ss+w%2frd%3a1", + "p@ss%20w/rd:1", + #"{"v":"p@ss w\/rd:1"}"#, + "Authorization: Basic \(basic)", + "token=\(Data("p@ss w/rd:1".utf8).base64EncodedString())", + ] + for sample in samples { + let redacted = store.redact(sample) + #expect(redacted.contains(""), "\(sample) -> \(redacted)") + #expect(!redacted.contains("p@ss"), "\(sample) -> \(redacted)") + } + #expect(store.redact("nothing here") == "nothing here") + let json = store.redactJSON(#"{"a":["p@ss w/rd:1"],"b":1}"#) + #expect(json.contains("") && !json.contains("p@ss")) + } + + @Test("A TOTP secret types the RFC 6238 code and is never described with its value") + func totp() throws { + let store = BrowserReplSecretStore() + try store.set(name: "otp", value: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", domains: ["example.com"], totp: true, title: "t") + let typed = store.valueToType("otp", at: Date(timeIntervalSince1970: 59)) + #expect(typed?.text == "287082") + #expect(!(JSONSerialization.browserReplString(store.describe()) ?? "").contains("GEZDG")) + #expect(throws: BrowserReplDriverError.self) { + try store.set(name: "bad", value: "not base32!", domains: ["example.com"], totp: true, title: "t") + } + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplDownloadLedgerTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplDownloadLedgerTests.swift new file mode 100644 index 000000000000..58c14c30aede --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplDownloadLedgerTests.swift @@ -0,0 +1,41 @@ +import Testing +@testable import CmuxBrowser + +@MainActor +@Suite struct BrowserReplDownloadLedgerTests { + @Test func finishedBeforeWaitingReturnsAtOnce() async { + let ledger = BrowserReplDownloadLedger() + ledger.finish(id: "d1", path: "/tmp/a.txt", error: nil) + #expect(await ledger.wait(for: "d1") == .init(path: "/tmp/a.txt", error: nil)) + } + + @Test func finishWhileWaitingWakesTheWaiter() async { + let ledger = BrowserReplDownloadLedger() + let waiting = Task { await ledger.wait(for: "d2") } + await Task.yield() + ledger.finish(id: "d2", path: "/tmp/b.txt", error: nil) + #expect(await waiting.value == .init(path: "/tmp/b.txt", error: nil)) + } + + /// The race behind `download.path()` hanging in full real-app runs: the + /// completion arrives after the lookup missed and before the waiter is + /// registered. It must not be lost. + @Test func finishBetweenLookupAndRegistrationIsNotLost() async { + let ledger = BrowserReplDownloadLedger() + ledger.willWait = { id in ledger.finish(id: id, path: "/tmp/c.txt", error: nil) } + let waiting = Task { await ledger.wait(for: "d3") } + // Give the waiter a bounded chance, then release it so a lost + // completion fails the test instead of hanging it. + for _ in 0..<1000 { await Task.yield() } + ledger.releaseWaiters() + #expect(await waiting.value == .init(path: "/tmp/c.txt", error: nil)) + } + + @Test func releasingWaitersGivesUp() async { + let ledger = BrowserReplDownloadLedger() + let waiting = Task { await ledger.wait(for: "d4") } + await Task.yield() + ledger.releaseWaiters() + #expect(await waiting.value == nil) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplDragPasteboardTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplDragPasteboardTests.swift new file mode 100644 index 000000000000..ab4c265f2d13 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplDragPasteboardTests.swift @@ -0,0 +1,94 @@ +import AppKit +import Testing + +@testable import CmuxBrowser + +extension BrowserReplPasteboardRedirectTests { + /// An automated HTML5 drag carries the page's drag data from WebKit's + /// drag start to the drop the driver plays. That data must never sit on + /// the system's named drag pasteboard, which every process of the user + /// can read and overwrite while the driver waits for WebKit, and two + /// drags (two sessions) must never share a pasteboard. + /// + /// Nested in the redirect suite: the drag window uses the same + /// process-wide lookup hook. + @MainActor + @Suite("Automated drags", .serialized) + struct AutomatedDrags { + @Test func eachAutomatedDragHasItsOwnPrivatePasteboard() { + let first = BrowserAutomationDragCapture() + let second = BrowserAutomationDragCapture() + #expect(first.pasteboard.name != .drag, "an automated drag uses the system's drag pasteboard") + #expect(first.pasteboard.name != second.pasteboard.name, "two automated drags share a pasteboard") + } + + private static let drag = NSPasteboard.Name.drag.rawValue + + /// While a drag's window is open, WebKit's lookups of the drag + /// pasteboard get the drag's own; other code, and WebKit once the + /// drag started, get the system's. + @Test func webKitsDragPasteboardIsTheDragsOwnOnlyWhileItsWindowIsOpen() async { + let capture = BrowserAutomationDragCapture() + defer { capture.finish() } + let redirect = BrowserReplPasteboardRedirect.shared + #expect(redirect.redirectTarget(forLookupOf: Self.drag, fromWebKit: true) == nil) + #expect(await capture.openPasteboardWindow()) + #expect(redirect.redirectTarget(forLookupOf: Self.drag, fromWebKit: true) === capture.pasteboard) + #expect(redirect.redirectTarget(forLookupOf: Self.drag, fromWebKit: false) == nil, "a lookup by other code got the drag's pasteboard") + #expect(NSPasteboard(name: .drag) !== capture.pasteboard) + #expect( + redirect.redirectTarget(forLookupOf: NSPasteboard.Name.general.rawValue, fromWebKit: true) == nil, + "a drag's window redirected the general pasteboard" + ) + // WebKit writes the drag data, then asks AppKit for the session. + capture.begin() + #expect(capture.didBegin) + #expect(redirect.redirectTarget(forLookupOf: Self.drag, fromWebKit: true) == nil, "the drag's window outlived the drag's start") + } + + /// Two sessions' drags never share a window: the second waits for + /// the first, and fails if the first does not close in time. + @Test func aSecondDragWaitsForTheFirstDragsWindow() async { + let first = BrowserAutomationDragCapture() + let second = BrowserAutomationDragCapture() + defer { + first.finish() + second.finish() + } + let redirect = BrowserReplPasteboardRedirect.shared + let clock = ManualClock() + #expect(await redirect.openDragWindow(first.pasteboard, timeout: .seconds(5), clock: clock)) + let waiting = Task { @MainActor in + await redirect.openDragWindow(second.pasteboard, timeout: .seconds(5), clock: clock) + } + await clock.waitForSleepers(2) + #expect(redirect.redirectTarget(forLookupOf: Self.drag, fromWebKit: true) === first.pasteboard) + first.closePasteboardWindow() + #expect(await waiting.value, "the second drag did not get the window once the first closed it") + #expect(redirect.redirectTarget(forLookupOf: Self.drag, fromWebKit: true) === second.pasteboard) + second.closePasteboardWindow() + + #expect(await redirect.openDragWindow(first.pasteboard, timeout: .seconds(5), clock: clock)) + let refused = Task { @MainActor in + await redirect.openDragWindow(second.pasteboard, timeout: .seconds(5), clock: clock) + } + await clock.waitForSleepers(2) + clock.advance(by: .seconds(5)) + #expect(await refused.value == false, "a drag opened its window while another drag's was open") + #expect(redirect.redirectTarget(forLookupOf: Self.drag, fromWebKit: true) == nil, "a window outlived its bound") + } + + /// A finished capture's pasteboard is closed off, emptied and + /// released, and the capture cannot open its window again. + @Test func aFinishedCaptureReleasesItsPasteboard() async { + let capture = BrowserAutomationDragCapture() + #expect(await capture.openPasteboardWindow()) + capture.pasteboard.clearContents() + capture.pasteboard.setString("drag data", forType: .string) + capture.finish() + #expect(BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: Self.drag, fromWebKit: true) == nil) + #expect(capture.pasteboard.types?.isEmpty ?? true) + #expect(await !capture.openPasteboardWindow()) + } + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplFileSandboxTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplFileSandboxTests.swift new file mode 100644 index 000000000000..f8fcad69a69e --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplFileSandboxTests.swift @@ -0,0 +1,156 @@ +import Foundation +import Testing + +@testable import CmuxBrowser + +@Suite("Browser REPL fs sandbox") +struct BrowserReplFileSandboxTests { + /// A scratch directory with `work/` as the REPL root and `outside/` beside it. + struct Scratch { + let base: String + var root: String { base + "/work" } + var outside: String { base + "/outside" } + + init() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-repl-fs-\(UUID().uuidString)") + base = BrowserReplFileSandbox.canonicalize(url.path) + try FileManager.default.createDirectory(atPath: base + "/work", withIntermediateDirectories: true) + try FileManager.default.createDirectory(atPath: base + "/outside", withIntermediateDirectories: true) + try Data("secret".utf8).write(to: URL(fileURLWithPath: base + "/outside/secret.txt")) + } + + func remove() { + try? FileManager.default.removeItem(atPath: base) + } + } + + @Test("Relative paths resolve inside the root, including ones not created yet") + func relativePaths() throws { + let scratch = try Scratch() + defer { scratch.remove() } + let sandbox = BrowserReplFileSandbox(root: scratch.root) + + #expect(try sandbox.resolve("./artifacts/page.pdf", for: .write) == scratch.root + "/artifacts/page.pdf") + #expect(try sandbox.resolve("a/../b.txt", for: .read) == scratch.root + "/b.txt") + #expect(try sandbox.resolve(".", for: .read) == scratch.root) + #expect(try sandbox.resolve(scratch.root + "/x.txt", for: .write) == scratch.root + "/x.txt") + } + + @Test("Paths that leave the root are refused", arguments: ["../outside/secret.txt", "/etc/passwd", "a/../../outside", "~/../../etc/hosts/.."]) + func escapesAreRefused(path: String) throws { + let scratch = try Scratch() + defer { scratch.remove() } + let sandbox = BrowserReplFileSandbox(root: scratch.root) + + let resolved = try? sandbox.resolve(path, for: .read) + if let resolved { + #expect(resolved.hasPrefix(scratch.root + "/")) + } else { + #expect(throws: BrowserReplFileSystemError.self) { try sandbox.resolve(path, for: .read) } + } + #expect(throws: BrowserReplFileSystemError.self) { try sandbox.resolve("../outside/secret.txt", for: .write) } + } + + @Test("A symbolic link inside the root cannot reach outside it") + func symlinkEscapeIsRefused() throws { + let scratch = try Scratch() + defer { scratch.remove() } + try FileManager.default.createSymbolicLink(atPath: scratch.root + "/link", withDestinationPath: scratch.outside) + try FileManager.default.createSymbolicLink( + atPath: scratch.root + "/dangling", + withDestinationPath: scratch.outside + "/created-by-write.txt" + ) + let sandbox = BrowserReplFileSandbox(root: scratch.root) + + #expect(throws: BrowserReplFileSystemError.self) { try sandbox.resolve("link/secret.txt", for: .read) } + #expect(throws: BrowserReplFileSystemError.self) { try sandbox.resolve("link/new.txt", for: .write) } + #expect(throws: BrowserReplFileSystemError.self) { try sandbox.resolve("dangling", for: .write) } + } + + @Test("A reported download is readable but not writable") + func downloadedFileIsReadOnly() throws { + let scratch = try Scratch() + defer { scratch.remove() } + var sandbox = BrowserReplFileSandbox(root: scratch.root) + let download = scratch.outside + "/secret.txt" + + #expect(throws: BrowserReplFileSystemError.self) { try sandbox.resolve(download, for: .read) } + sandbox.allowReading(download) + #expect(try sandbox.resolve(download, for: .read) == download) + #expect(throws: BrowserReplFileSystemError.self) { try sandbox.resolve(download, for: .write) } + #expect(throws: BrowserReplFileSystemError.self) { try sandbox.resolve(scratch.outside + "/other.txt", for: .read) } + } + + @Test("fs operations round-trip files and report Node error codes") + func fileOperations() throws { + let scratch = try Scratch() + defer { scratch.remove() } + // The scratch tree lives in the real temporary directory, so point + // the temporary root elsewhere to test confinement to the root. + let fs = BrowserReplFileSystem( + sandbox: BrowserReplFileSandbox(root: scratch.root), + temporaryDirectory: scratch.base + "/tmp" + ) + let hello = Data("hello".utf8).base64EncodedString() + + #expect(throws: Never.self) { try fs.perform("mkdir", arguments: ["path": "artifacts/nested", "recursive": true]).get() } + #expect(throws: Never.self) { try fs.perform("writeFile", arguments: ["path": "artifacts/a.txt", "base64": hello]).get() } + #expect(throws: Never.self) { + try fs.perform("writeFile", arguments: ["path": "artifacts/a.txt", "base64": hello, "append": true]).get() + } + let read = try fs.perform("readFile", arguments: ["path": "artifacts/a.txt"]).get() as? String + #expect(read.flatMap { Data(base64Encoded: $0) }.map { String(decoding: $0, as: UTF8.self) } == "hellohello") + + let stat = try fs.perform("stat", arguments: ["path": "artifacts/a.txt"]).get() as? [String: Any] + #expect(stat?["size"] as? Int64 == 10) + #expect(stat?["type"] as? String == "file") + + let entries = try fs.perform("readdir", arguments: ["path": "artifacts"]).get() as? [[String: Any]] + #expect(entries?.compactMap { $0["name"] as? String } == ["a.txt", "nested"]) + + #expect(fs.perform("readFile", arguments: ["path": "missing.txt"]).failureCode == "ENOENT") + #expect(fs.perform("mkdir", arguments: ["path": "artifacts"]).failureCode == "EEXIST") + #expect(fs.perform("rm", arguments: ["path": "artifacts"]).failureCode == "ENOTEMPTY") + #expect(fs.perform("readFile", arguments: ["path": "../outside/secret.txt"]).failureCode == "EACCES") + #expect(fs.perform("writeFile", arguments: ["path": "no-such-dir/x.txt", "base64": hello]).failureCode == "ENOENT") + #expect(fs.perform("rm", arguments: ["path": "."]).failureCode == "EACCES") + + #expect(throws: Never.self) { try fs.perform("rm", arguments: ["path": "artifacts", "recursive": true]).get() } + #expect(fs.perform("exists", arguments: ["path": "artifacts"]).successValue as? Bool == false) + } +} + +extension BrowserReplFileSandboxTests { + @Test("fs also reaches the temporary directory, but never removes its root") + func temporaryDirectoryIsASecondRoot() throws { + let scratch = try Scratch() + defer { scratch.remove() } + // `outside/` plays the user's temporary directory. + let fs = BrowserReplFileSystem( + sandbox: BrowserReplFileSandbox(root: scratch.root), + temporaryDirectory: scratch.outside + ) + let secret = scratch.outside + "/secret.txt" + + let read = try fs.perform("readFile", arguments: ["path": secret]).get() as? String + #expect(read == Data("secret".utf8).base64EncodedString()) + #expect(throws: Never.self) { + try fs.perform("writeFile", arguments: ["path": scratch.outside + "/new.txt", "base64": ""]).get() + } + #expect(fs.perform("rm", arguments: ["path": scratch.outside, "recursive": true]).failureCode == "EACCES") + #expect(fs.perform("readFile", arguments: ["path": scratch.base + "/elsewhere.txt"]).failureCode == "EACCES") + } +} + +private extension Result where Success == Any, Failure == BrowserReplFileSystemError { + var failureCode: String? { + if case .failure(let error) = self { return error.code } + return nil + } + + var successValue: Any? { + if case .success(let value) = self { return value } + return nil + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplFileSystemTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplFileSystemTests.swift new file mode 100644 index 000000000000..6b06c1815f7a --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplFileSystemTests.swift @@ -0,0 +1,288 @@ +import Foundation +import Testing + +@testable import CmuxBrowser + +/// `fs` operations on symbolic links and on existing destinations, run +/// through `BrowserReplFileSystem.perform` on real temporary directories. +/// +/// Acting on a link (`rm`, `rename`, `lstat`) checks only the link's parent +/// directory, as Node does; reading or writing through a link checks where +/// the link points. +@Suite("Browser REPL fs operations") +struct BrowserReplFileSystemTests { + private typealias Scratch = BrowserReplFileSandboxTests.Scratch + + private let fileManager = FileManager.default + + /// An fs rooted at `work/`, with the temporary root moved off the real + /// temporary directory (the scratch tree lives there). + private func makeFileSystem(_ scratch: Scratch) -> BrowserReplFileSystem { + BrowserReplFileSystem( + sandbox: BrowserReplFileSandbox(root: scratch.root), + temporaryDirectory: scratch.base + "/tmp" + ) + } + + private func write(_ text: String, to path: String) throws { + try Data(text.utf8).write(to: URL(fileURLWithPath: path)) + } + + private func contents(_ path: String) -> String? { + fileManager.contents(atPath: path).map { String(decoding: $0, as: UTF8.self) } + } + + private func linkDestination(_ path: String) -> String? { + try? fileManager.destinationOfSymbolicLink(atPath: path) + } + + @Test("rm of a link to a file outside the root removes the link and keeps the file") + func removeLinkToOutsideFile() throws { + let scratch = try Scratch() + defer { scratch.remove() } + let secret = scratch.outside + "/secret.txt" + try fileManager.createSymbolicLink(atPath: scratch.root + "/link", withDestinationPath: secret) + let fs = makeFileSystem(scratch) + + let result = fs.perform("rm", arguments: ["path": "link"]) + + #expect(result.failure == nil) + #expect(linkDestination(scratch.root + "/link") == nil) + #expect(contents(secret) == "secret") + } + + @Test("rm -r of a link to a directory removes the link and keeps the directory's files") + func removeLinkToDirectoryRecursively() throws { + let scratch = try Scratch() + defer { scratch.remove() } + try fileManager.createDirectory(atPath: scratch.root + "/data", withIntermediateDirectories: true) + try write("keep", to: scratch.root + "/data/keep.txt") + try fileManager.createSymbolicLink(atPath: scratch.root + "/alias", withDestinationPath: scratch.root + "/data") + let fs = makeFileSystem(scratch) + + let result = fs.perform("rm", arguments: ["path": "alias", "recursive": true]) + + #expect(result.failure == nil) + #expect(linkDestination(scratch.root + "/alias") == nil) + #expect(contents(scratch.root + "/data/keep.txt") == "keep") + } + + @Test("rm of a dangling link removes the link") + func removeDanglingLink() throws { + let scratch = try Scratch() + defer { scratch.remove() } + try fileManager.createSymbolicLink( + atPath: scratch.root + "/dangling", + withDestinationPath: scratch.outside + "/missing.txt" + ) + let fs = makeFileSystem(scratch) + + let result = fs.perform("rm", arguments: ["path": "dangling"]) + + #expect(result.failure == nil) + #expect(linkDestination(scratch.root + "/dangling") == nil) + #expect(!fileManager.fileExists(atPath: scratch.outside + "/missing.txt")) + } + + @Test("rename of a link moves the link itself, whether it points inside or outside the root") + func renameMovesTheLink() throws { + let scratch = try Scratch() + defer { scratch.remove() } + let secret = scratch.outside + "/secret.txt" + try write("inside", to: scratch.root + "/target.txt") + try fileManager.createSymbolicLink(atPath: scratch.root + "/out-link", withDestinationPath: secret) + try fileManager.createSymbolicLink(atPath: scratch.root + "/in-link", withDestinationPath: scratch.root + "/target.txt") + let fs = makeFileSystem(scratch) + + let outside = fs.perform("rename", arguments: ["from": "out-link", "to": "out-moved"]) + let inside = fs.perform("rename", arguments: ["from": "in-link", "to": "in-moved"]) + + #expect(outside.failure == nil) + #expect(linkDestination(scratch.root + "/out-link") == nil) + #expect(linkDestination(scratch.root + "/out-moved") == secret) + #expect(contents(secret) == "secret") + #expect(inside.failure == nil) + #expect(linkDestination(scratch.root + "/in-link") == nil) + #expect(linkDestination(scratch.root + "/in-moved") == scratch.root + "/target.txt") + #expect(contents(scratch.root + "/target.txt") == "inside") + } + + @Test("rename from a missing source fails with ENOENT and keeps the destination") + func renameOfMissingSourceKeepsDestination() throws { + let scratch = try Scratch() + defer { scratch.remove() } + try write("old", to: scratch.root + "/dest.txt") + let fs = makeFileSystem(scratch) + + let result = fs.perform("rename", arguments: ["from": "missing.txt", "to": "dest.txt"]) + + #expect(result.failure?.code == "ENOENT") + #expect(contents(scratch.root + "/dest.txt") == "old") + } + + @Test("rename replaces an existing destination file") + func renameReplacesDestination() throws { + let scratch = try Scratch() + defer { scratch.remove() } + try write("new", to: scratch.root + "/src.txt") + try write("old", to: scratch.root + "/dest.txt") + let fs = makeFileSystem(scratch) + + let result = fs.perform("rename", arguments: ["from": "src.txt", "to": "dest.txt"]) + + #expect(result.failure == nil) + #expect(contents(scratch.root + "/dest.txt") == "new") + #expect(!fileManager.fileExists(atPath: scratch.root + "/src.txt")) + } + + @Test("copyFile that fails keeps the existing destination and leaves no temporary file") + func failedCopyKeepsDestination() throws { + let scratch = try Scratch() + defer { + try? fileManager.setAttributes([.posixPermissions: 0o644], ofItemAtPath: scratch.root + "/unreadable.txt") + scratch.remove() + } + try write("new", to: scratch.root + "/unreadable.txt") + try fileManager.setAttributes([.posixPermissions: 0o000], ofItemAtPath: scratch.root + "/unreadable.txt") + try write("old", to: scratch.root + "/dest.txt") + let fs = makeFileSystem(scratch) + + let result = fs.perform("copyFile", arguments: ["from": "unreadable.txt", "to": "dest.txt"]) + + #expect(result.failure?.code == "EACCES") + #expect(contents(scratch.root + "/dest.txt") == "old") + #expect(try fileManager.contentsOfDirectory(atPath: scratch.root).sorted() == ["dest.txt", "unreadable.txt"]) + } + + @Test("copyFile replaces an existing destination") + func copyReplacesDestination() throws { + let scratch = try Scratch() + defer { scratch.remove() } + try write("new", to: scratch.root + "/src.txt") + try write("old", to: scratch.root + "/dest.txt") + let fs = makeFileSystem(scratch) + + let result = fs.perform("copyFile", arguments: ["from": "src.txt", "to": "dest.txt"]) + + #expect(result.failure == nil) + #expect(contents(scratch.root + "/dest.txt") == "new") + #expect(contents(scratch.root + "/src.txt") == "new") + } + + @Test("lstat and readdir describe a link as a link; stat describes its target") + func linkTypes() throws { + let scratch = try Scratch() + defer { scratch.remove() } + try write("inside", to: scratch.root + "/target.txt") + try fileManager.createSymbolicLink(atPath: scratch.root + "/in-link", withDestinationPath: scratch.root + "/target.txt") + try fileManager.createSymbolicLink(atPath: scratch.root + "/out-link", withDestinationPath: scratch.outside + "/secret.txt") + let fs = makeFileSystem(scratch) + + #expect(fs.perform("lstat", arguments: ["path": "in-link"]).type == "symlink") + #expect(fs.perform("lstat", arguments: ["path": "out-link"]).type == "symlink") + #expect(fs.perform("stat", arguments: ["path": "in-link"]).type == "file") + let entries = try fs.perform("readdir", arguments: ["path": "."]).get() as? [[String: Any]] + let types = Dictionary(uniqueKeysWithValues: (entries ?? []).compactMap { entry -> (String, String)? in + guard let name = entry["name"] as? String, let type = entry["type"] as? String else { return nil } + return (name, type) + }) + #expect(types == ["in-link": "symlink", "out-link": "symlink", "target.txt": "file"]) + } + + @Test("Reading or writing through a link to outside the root is still refused") + func throughLinkStaysConfined() throws { + let scratch = try Scratch() + defer { scratch.remove() } + let secret = scratch.outside + "/secret.txt" + try fileManager.createSymbolicLink(atPath: scratch.root + "/out-link", withDestinationPath: secret) + try fileManager.createSymbolicLink(atPath: scratch.root + "/out-dir", withDestinationPath: scratch.outside) + try write("mine", to: scratch.root + "/mine.txt") + let fs = makeFileSystem(scratch) + let data = Data("pwned".utf8).base64EncodedString() + + #expect(fs.perform("readFile", arguments: ["path": "out-link"]).failure?.code == "EACCES") + #expect(fs.perform("stat", arguments: ["path": "out-link"]).failure?.code == "EACCES") + #expect(fs.perform("writeFile", arguments: ["path": "out-link", "base64": data]).failure?.code == "EACCES") + #expect(fs.perform("copyFile", arguments: ["from": "out-link", "to": "copy.txt"]).failure?.code == "EACCES") + #expect(fs.perform("copyFile", arguments: ["from": "mine.txt", "to": "out-link"]).failure?.code == "EACCES") + #expect(fs.perform("rename", arguments: ["from": "mine.txt", "to": "out-dir/mine.txt"]).failure?.code == "EACCES") + #expect(fs.perform("rm", arguments: ["path": "out-dir/secret.txt"]).failure?.code == "EACCES") + #expect(contents(secret) == "secret") + #expect(contents(scratch.root + "/mine.txt") == "mine") + } + + @Test("rename refuses to move or replace the working directory and the temporary root") + func renameRefusesRoots() throws { + let scratch = try Scratch() + defer { scratch.remove() } + try fileManager.createDirectory(atPath: scratch.base + "/tmp", withIntermediateDirectories: true) + let fs = makeFileSystem(scratch) + try write("mine", to: scratch.root + "/mine.txt") + try fileManager.createDirectory(atPath: scratch.root + "/sub", withIntermediateDirectories: true) + #expect(fs.perform("rename", arguments: ["from": scratch.root, "to": scratch.base + "/tmp/moved"]).failure?.code == "EACCES") + #expect(fs.perform("rename", arguments: ["from": scratch.base + "/tmp", "to": scratch.root + "/sub/tmp"]).failure?.code == "EACCES") + #expect(fs.perform("rename", arguments: ["from": "sub", "to": scratch.base + "/tmp"]).failure?.code == "EACCES") + #expect(fileManager.fileExists(atPath: scratch.root + "/mine.txt")) + #expect(fileManager.fileExists(atPath: scratch.base + "/tmp")) + } + + /// Agent code cannot create a link, but it can move one that is already + /// in the root, and two sessions on the same root run their fs calls on + /// two threads. One session swapping such a link in for a directory must + /// never let the other's write, checked against the directory, land + /// through the link. + @Test("A link swapped in by another session between the check and the write is never written through") + func concurrentLinkSwapNeverEscapes() async throws { + let scratch = try Scratch() + defer { scratch.remove() } + try fileManager.createDirectory(atPath: scratch.root + "/sub", withIntermediateDirectories: true) + try fileManager.createSymbolicLink(atPath: scratch.root + "/escape", withDestinationPath: scratch.outside) + let swapper = makeFileSystem(scratch) + let writer = makeFileSystem(scratch) + let escaped = scratch.outside + "/written.txt" + let done = BrowserReplRaceFlag() + + let swapping = Task.detached { + while !done.isSet { + _ = swapper.perform("rename", arguments: ["from": "sub", "to": "held"]) + _ = swapper.perform("rename", arguments: ["from": "escape", "to": "sub"]) + _ = swapper.perform("rename", arguments: ["from": "sub", "to": "escape"]) + _ = swapper.perform("rename", arguments: ["from": "held", "to": "sub"]) + } + } + let writing = Task.detached { + let payload = Data("x".utf8).base64EncodedString() + for _ in 0..<5_000 where !FileManager.default.fileExists(atPath: escaped) { + _ = writer.perform("writeFile", arguments: ["path": "sub/written.txt", "base64": payload]) + } + done.set() + } + await writing.value + await swapping.value + + #expect(!fileManager.fileExists(atPath: escaped)) + } +} + +/// A flag one task sets and another polls. +private final class BrowserReplRaceFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + var isSet: Bool { lock.withLock { value } } + + func set() { lock.withLock { value = true } } +} + +private extension Result where Success == Any, Failure == BrowserReplFileSystemError { + var failure: BrowserReplFileSystemError? { + if case .failure(let error) = self { return error } + return nil + } + + /// The `type` of a `stat`/`lstat` result. + var type: String? { + guard case .success(let value) = self else { return nil } + return (value as? [String: Any])?["type"] as? String + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplFocusContainmentTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplFocusContainmentTests.swift new file mode 100644 index 000000000000..a4c5f0a47901 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplFocusContainmentTests.swift @@ -0,0 +1,119 @@ +import AppKit +import Testing +import WebKit + +@testable import CmuxBrowser + +/// WebKit asks its UI delegate to move AppKit focus out of the web view when +/// Tab or Shift+Tab passes the page's last or first control +/// (`_webView:takeFocus:`). A REPL session's keys must never move the +/// user's focus that way: in a pane next to the user's terminal, an agent's +/// Tab moved the window's first responder off the terminal. +@MainActor +@Suite("Browser REPL focus containment") +struct BrowserReplFocusContainmentTests { + private struct Window { + let window: NSWindow + let before: NSTextField + let webView: WKWebView + let after: NSTextField + } + + /// A window (never shown) with a field, a web view and a field in its key view loop. + private func makeWindow() -> Window { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 400), styleMask: [.titled], backing: .buffered, defer: true) + let content = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = content + let before = NSTextField(frame: NSRect(x: 0, y: 0, width: 100, height: 20)) + let webView = WKWebView(frame: NSRect(x: 0, y: 30, width: 400, height: 300)) + let after = NSTextField(frame: NSRect(x: 0, y: 340, width: 100, height: 20)) + for view in [before, webView, after] as [NSView] { content.addSubview(view) } + before.nextKeyView = webView + webView.nextKeyView = after + after.nextKeyView = before + window.initialFirstResponder = before + return Window(window: window, before: before, webView: webView, after: after) + } + + private func isFirstResponder(_ view: NSView, in window: NSWindow) -> Bool { + guard let responder = window.firstResponder else { return false } + if responder === view { return true } + // A focused text field's first responder is its field editor. + if let editor = responder as? NSText, editor.delegate === view as AnyObject { return true } + return false + } + + @Test func automatedFocusNavigationOutOfThePageLeavesTheUsersFocusAlone() async { + let setup = makeWindow() + #expect(setup.window.makeFirstResponder(setup.before)) + #expect(isFirstResponder(setup.before, in: setup.window)) + await setup.webView.withAutomationFocusContainment { + setup.webView.browserTakeFocus(forward: true) + setup.webView.browserTakeFocus(forward: false) + } + #expect(isFirstResponder(setup.before, in: setup.window)) + } + + @Test func automatedFocusNavigationKeepsFocusInADrivenPage() async { + let setup = makeWindow() + #expect(setup.window.makeFirstResponder(setup.webView)) + await setup.webView.withAutomationFocusContainment { + setup.webView.browserTakeFocus(forward: true) + } + #expect(setup.window.firstResponder === setup.webView) + } + + // WebKit can handle a key after the driver's round trip (a Tab queued + // behind Shift's flagsChanged), so the takeFocus may come after the + // automated input ended. Seen live: Shift+Tab from a driven page moved the + // first responder off the user's terminal. Focus that is not in the web + // view cannot leave it, whoever asks. + @Test func focusTheWebViewDoesNotHoldNeverMoves() { + let setup = makeWindow() + // The user's focus is in the view after the web view; Shift+Tab out + // of the page would select the view before it. + #expect(setup.window.makeFirstResponder(setup.after)) + setup.webView.browserTakeFocus(forward: false) + #expect(isFirstResponder(setup.after, in: setup.window)) + setup.webView.browserTakeFocus(forward: true) + #expect(isFirstResponder(setup.after, in: setup.window)) + } + + @Test func aRenderWindowNeverHandsFocusOn() { + let panel = BrowserOffscreenRenderPanel(contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), styleMask: [.borderless], backing: .buffered, defer: true) + let content = NSView(frame: NSRect(x: 0, y: 0, width: 400, height: 300)) + panel.contentView = content + let webView = WKWebView(frame: content.bounds) + let other = NSTextField(frame: NSRect(x: 0, y: 0, width: 50, height: 20)) + content.addSubview(webView) + content.addSubview(other) + webView.nextKeyView = other + other.nextKeyView = webView + #expect(panel.makeFirstResponder(webView)) + webView.browserTakeFocus(forward: true) + #expect(panel.firstResponder === webView) + } + + @Test func theUsersOwnTabLeavesThePageAsBefore() { + let setup = makeWindow() + #expect(setup.window.makeFirstResponder(setup.webView)) + setup.webView.browserTakeFocus(forward: true) + #expect(isFirstResponder(setup.after, in: setup.window)) + #expect(setup.window.makeFirstResponder(setup.webView)) + setup.webView.browserTakeFocus(forward: false) + #expect(isFirstResponder(setup.before, in: setup.window)) + } + + @Test func containmentEndsWithTheInputThatStartedIt() async throws { + let setup = makeWindow() + struct Failed: Error {} + await #expect(throws: Failed.self) { + try await setup.webView.withAutomationFocusContainment { throw Failed() } + } + #expect(!setup.webView.isContainingAutomationFocus) + await setup.webView.withAutomationFocusContainment { + #expect(setup.webView.isContainingAutomationFocus) + } + #expect(!setup.webView.isContainingAutomationFocus) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplFrameGateTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplFrameGateTests.swift new file mode 100644 index 000000000000..ee8305412038 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplFrameGateTests.swift @@ -0,0 +1,417 @@ +import AppKit +import WebKit +import Testing + +@testable import CmuxBrowser + +/// The domain policy applies to every frame of a tab, not only the main +/// frame: a page the policy allows can embed a frame that shows a page it +/// blocks (a user's tab has no content rules, and a frame can load before +/// the policy is set). These tests load real pages in WebKit, served by a +/// scheme handler for `cmux-test://allowed.test` and `cmux-test://blocked.test`. +@MainActor +@Suite("Frame gate", .serialized) +struct BrowserReplFrameGateTests { + // MARK: auth.request + + /// `auth.request` fills credentials into one frame; the gate must refuse + /// a frame that shows a blocked page, whatever the main frame shows. + @Test func authorizeRefusesAFrameThatShowsABlockedPage() async throws { + let page = try await FramePage.load() + let gate = Self.gate() + let blocked = try #require(page.frame(host: "blocked.test")) + let error = await Self.error { try await gate.authorize(blocked, in: page.webView) } + #expect(error?.code == "blocked", "a frame on a blocked domain was authorized") + let allowed = try #require(page.frame(path: "/child")) + #expect(await Self.error { try await gate.authorize(allowed, in: page.webView) } == nil) + #expect(await Self.error { try await gate.authorize(page.main, in: page.webView) } == nil) + } + + @Test func authorizeRefusesAMainFrameThatShowsABlockedPage() async throws { + let page = try await FramePage.load(url: "cmux-test://blocked.test/") + let gate = Self.gate() + let error = await Self.error { try await gate.authorize(page.main, in: page.webView) } + #expect(error?.code == "blocked", "a main frame on a blocked domain was authorized") + } + + // MARK: Reads, input and captures + + /// `frame.evaluate` and every other script the driver runs for the + /// session in a frame: a frame that shows a blocked page is not read. + @Test func aFrameThatShowsABlockedPageIsNotEvaluated() async throws { + let page = try await FramePage.load() + let gate = Self.gate() + let blocked = try #require(page.frame(host: "blocked.test")) + let read = "return document.body.innerText" + let error = await Self.error { + try await gate.callAsyncJavaScript(read, arguments: [:], in: page.webView, frame: blocked, contentWorld: .page) + } + #expect(error?.code == "blocked", "a frame on a blocked domain was read: \(String(describing: error))") + let allowed = try #require(page.frame(path: "/child")) + let text = try await gate.callAsyncJavaScript(read, arguments: [:], in: page.webView, frame: allowed, contentWorld: .page) + #expect((text as? String)?.contains("allowed.test/child") == true) + } + + /// A frame keeps its id when it navigates, and the driver looks frames up + /// by id from an earlier tree read: a frame that moved to a blocked page + /// since must not be read through the old record, and the script must + /// not run there at all. + @Test func aFrameThatNavigatedToABlockedPageIsNotEvaluatedThroughItsOldRecord() async throws { + let page = try await FramePage.load() + let gate = Self.gate() + let child = try #require(page.frame(path: "/child")) + _ = try await gate.callAsyncJavaScript("return 1", arguments: [:], in: page.webView, frame: child, contentWorld: .page) + _ = try await page.run("document.getElementById('a').src = 'cmux-test://blocked.test/moved'; return true", in: page.main) + _ = try await FramePage.settle(page.webView) { frames in + frames.contains { $0.url == "cmux-test://blocked.test/moved" } + } + let error = await Self.error { + try await gate.callAsyncJavaScript( + "window.__ranByGate = true; return document.body.innerText", + arguments: [:], + in: page.webView, + frame: child, + contentWorld: .page + ) + } + #expect(error?.code == "blocked", "the moved frame was read: \(String(describing: error))") + let ran = try await page.run("return window.__ranByGate === true", in: child) + #expect(ran as? Bool == false, "the script ran in the blocked document") + } + + /// Mouse input at a point over a blocked frame would reach it; points + /// elsewhere on the page are fine. + @Test func pointerInputOverAFrameThatShowsABlockedPageIsRefused() async throws { + let page = try await FramePage.load() + let gate = Self.gate() + let over = await Self.error { try await gate.checkPointer(at: [CGPoint(x: 250, y: 50)], in: page.webView, frames: page.frames) } + #expect(over?.code == "blocked", "a point over the blocked frame was allowed") + let path = await Self.error { + try await gate.checkPointer(at: [CGPoint(x: 50, y: 50), CGPoint(x: 240, y: 30)], in: page.webView, frames: page.frames) + } + #expect(path?.code == "blocked", "a drag ending over the blocked frame was allowed") + #expect(await Self.error { try await gate.checkPointer(at: [CGPoint(x: 50, y: 50)], in: page.webView, frames: page.frames) } == nil) + #expect(await Self.error { try await gate.checkPointer(at: [CGPoint(x: 350, y: 250)], in: page.webView, frames: page.frames) } == nil) + } + + /// Keys and inserted text go to the focused frame. + @Test func keyboardInputWhileABlockedFrameHasTheFocusIsRefused() async throws { + let page = try await FramePage.load() + let gate = Self.gate() + let blocked = try #require(page.frame(host: "blocked.test")) + let allowed = try #require(page.frame(path: "/child")) + #expect(await Self.error { try await gate.checkFocus(in: page.webView, frames: page.frames) } == nil) + _ = try await page.run("document.getElementById('f').focus(); return document.activeElement.id", in: blocked) + let error = await Self.error { try await gate.checkFocus(in: page.webView, frames: page.frames) } + #expect(error?.code == "blocked", "typing into the blocked frame's field was allowed") + _ = try await page.run("document.getElementById('f').focus(); return document.activeElement.id", in: allowed) + #expect(await Self.error { try await gate.checkFocus(in: page.webView, frames: page.frames) } == nil, + "focus moved to the allowed frame, yet typing was refused") + } + + /// A screenshot or PDF of the tab would show the blocked frame. + @Test func capturesOfATabThatShowsABlockedFrameAreRefused() async throws { + let page = try await FramePage.load() + let error = await Self.error { try Self.gate().checkCapture(in: page.webView, frames: page.frames) } + #expect(error?.code == "blocked", "a capture of the blocked frame was allowed") + let unrelated = Self.gate(prohibiting: "cmux-test://other.test") + #expect(await Self.error { try unrelated.checkCapture(in: page.webView, frames: page.frames) } == nil) + } + + /// A blocked frame (an ad or tracker iframe under `allowedDomains`) must + /// not refuse every capture of the tab: the capture blanks that frame's + /// box and shows the rest of the page. + @Test func aScreenshotBlanksABlockedFrameAndShowsTheRest() async throws { + let page = try await FramePage.load() + let blocked = try #require(page.frame(host: "blocked.test")) + let allowed = try #require(page.frame(path: "/child")) + _ = try await page.run("document.body.style.background = 'rgb(255, 0, 0)'; return true", in: blocked) + _ = try await page.run("document.body.style.background = 'rgb(0, 255, 0)'; return true", in: allowed) + let gate = Self.gate() + let image = try await gate.coverBlockedFrames(in: page.webView, frames: { await BrowserReplFrame.readTree(of: page.webView) }) { + (try await FramePage.viewportImage(of: page.webView), CGRect(x: 0, y: 0, width: 400, height: 300)) + } + let blockedPixel = try #require(FramePage.pixel(image, x: 250, y: 50)) + #expect(!(blockedPixel.red > 0.8 && blockedPixel.green < 0.2), "the blocked frame's content is in the capture") + let allowedPixel = try #require(FramePage.pixel(image, x: 60, y: 50)) + #expect(allowedPixel.green > 0.8 && allowedPixel.red < 0.2, "the allowed frame was blanked too") + } + + /// The capture mask found a child frame whose marked document is + /// blocked although the tree's record of it is not (it navigated after + /// the tree was read): the screenshot blanks it too. + @Test func aScreenshotBlanksAFrameTheMaskFoundBlocked() async throws { + let page = try await FramePage.load() + let allowed = try #require(page.frame(path: "/child")) + _ = try await page.run("document.body.style.background = 'rgb(0, 255, 0)'; return true", in: allowed) + let gate = Self.gate(prohibiting: "cmux-test://other.test") + let image = try await gate.coverBlockedFrames( + in: page.webView, + frames: { await BrowserReplFrame.readTree(of: page.webView) }, + blockedChildFrames: [allowed.frameID: "blocked by test"] + ) { + (try await FramePage.viewportImage(of: page.webView), CGRect(x: 0, y: 0, width: 400, height: 300)) + } + let pixel = try #require(FramePage.pixel(image, x: 60, y: 50)) + #expect(!(pixel.green > 0.8 && pixel.red < 0.2), "the frame the mask found blocked is in the capture") + } + + /// A frame the capture mask found blocked that the screenshot cannot + /// find in the tree cannot be blanked, so the screenshot is refused. + @Test func aFrameTheMaskFoundBlockedThatTheTreeLacksRefusesTheScreenshot() async throws { + let page = try await FramePage.load() + let gate = Self.gate(prohibiting: "cmux-test://other.test") + let error = await Self.error { + try await gate.coverBlockedFrames( + in: page.webView, + frames: { await BrowserReplFrame.readTree(of: page.webView) }, + blockedChildFrames: ["999999999": "blocked by test"] + ) { + (try await FramePage.viewportImage(of: page.webView), CGRect(x: 0, y: 0, width: 400, height: 300)) + } + } + #expect(error?.code == "blocked", "a capture with a blocked frame it could not find was allowed") + } + + /// Blanking a box hides a frame only when nothing draws its content + /// elsewhere; a reflection does, so the capture is refused. + @Test func aScreenshotOfABlockedFrameDrawnOutsideItsBoxIsRefused() async throws { + let page = try await FramePage.load() + _ = try await page.run("document.getElementById('b').style.webkitBoxReflect = 'below 0px'; return true", in: page.main) + let gate = Self.gate() + let error = await Self.error { + try await gate.coverBlockedFrames(in: page.webView, frames: { await BrowserReplFrame.readTree(of: page.webView) }) { + (try await FramePage.viewportImage(of: page.webView), CGRect(x: 0, y: 0, width: 400, height: 300)) + } + } + #expect(error?.code == "blocked", "a capture of a reflected blocked frame was allowed") + } + + /// A file chooser is answered with files only when its own frame is + /// allowed; another frame of the tab that shows a blocked page does not + /// matter. + @Test func aFileChooserIsJudgedByItsOwnFrame() async throws { + let page = try await FramePage.load() + let gate = Self.gate() + let blocked = try #require(page.frame(host: "blocked.test")?.info) + let allowed = try #require(page.frame(path: "/child")?.info) + #expect(await Self.error { try await gate.checkFileChooser(frame: allowed, in: page.webView, frames: page.frames) } == nil, + "a chooser in an allowed frame was refused because another frame is blocked") + let error = await Self.error { try await gate.checkFileChooser(frame: blocked, in: page.webView, frames: page.frames) } + #expect(error?.code == "blocked", "files were given to a chooser in a blocked frame") + } + + /// `window.frames` leaves out frames in shadow trees, so a child's index + /// in WebKit's frame tree is not its index there. A blocked frame in a + /// shadow tree must still refuse a point over it. + @Test func pointerInputOverABlockedFrameInAShadowTreeIsRefused() async throws { + let html = """ + +
+ + + """ + let page = try await FramePage.load(html: html) { frames in frames.count >= 4 && frames.allSatisfy { !$0.url.isEmpty } } + let gate = Self.gate() + let over = await Self.error { try await gate.checkPointer(at: [CGPoint(x: 250, y: 50)], in: page.webView, frames: page.frames) } + #expect(over?.code == "blocked", "a point over a blocked frame in a shadow tree was allowed") + } + + /// In a tab the session created the policy's content rules stop a + /// blocked frame from loading; the empty frame left behind is the + /// parent's and does not refuse the tab. + @Test func aFrameTheContentRulesKeptFromLoadingDoesNotRefuseTheTab() async throws { + let gate = Self.gate() + let rules = String(decoding: try JSONSerialization.data(withJSONObject: gate.policy.contentRules), as: UTF8.self) + let store = try #require(WKContentRuleListStore.default()) + let identifier = "cmux-frame-gate-tests-\(UUID().uuidString)" + let list = try #require(try await store.compileContentRuleList(forIdentifier: identifier, encodedContentRuleList: rules)) + defer { store.removeContentRuleList(forIdentifier: identifier) { _ in } } + // The blocked frame never gets a URL; the allowed one loads. + let page = try await FramePage.load(loaded: { frames in frames.contains { $0.url.hasSuffix("/child") } }) { configuration in + configuration.userContentController.add(list) + } + #expect(page.frame(host: "blocked.test") == nil, "the content rules let the blocked frame load") + #expect(await Self.error { try gate.checkCapture(in: page.webView, frames: page.frames) } == nil) + #expect(await Self.error { try await gate.checkPointer(at: [CGPoint(x: 250, y: 50)], in: page.webView, frames: page.frames) } == nil) + } + + // MARK: Probes that never answer + + /// The gate's own probes (the focus probe, the frame boxes, a frame's + /// document) are bounded: one that does not answer refuses the call + /// with `stale` instead of hanging every later input call. + @Test func aProbeThatNeverAnswersRefusesWithStale() async throws { + let page = try await FramePage.load() + // The page's web process runs no other script for 25 s, so the + // gate's probes of any frame in it do not answer (as when a + // navigation replaces the document and WebKit drops the completion). + Self.startBusyLoop(in: page.webView, seconds: 25) + let gate = Self.gate() + let blocked = try #require(page.frame(host: "blocked.test")) + let webView = SendableBox(page.webView) + let frames = SendableBox(page.frames) + let focus = await browserReplWithDeadline(seconds: 30) { @MainActor in + await Self.error { try await gate.checkFocus(in: webView.value, frames: frames.value) } + } + #expect(focus??.code == "stale", "the focus probe of a frame that does not answer hung or passed: \(String(describing: focus))") + let pointer = await browserReplWithDeadline(seconds: 30) { @MainActor in + await Self.error { try await gate.checkPointer(at: [CGPoint(x: 50, y: 50)], in: webView.value, frames: frames.value) } + } + #expect(pointer??.code == "stale", "the frame-box probe of a page that does not answer hung or passed: \(String(describing: pointer))") + let frame = SendableBox(blocked) + let document = await browserReplWithDeadline(seconds: 30) { @MainActor in + await Self.error { try await gate.authorize(frame.value, in: webView.value) } + } + #expect(document??.code == "stale", "the document probe of a frame that does not answer hung or passed: \(String(describing: document))") + } + + // MARK: Support + + /// Starts a loop of `seconds` in `webView`'s page before this returns + /// (the request is sent now; nothing waits for its answer). + static func startBusyLoop(in webView: WKWebView, seconds: Int) { + webView.evaluateJavaScript("{ const end = Date.now() + \(seconds * 1000); while (Date.now() < end) {} }", completionHandler: nil) + } + + static let world = WKContentWorld.world(name: "cmux-frame-gate-tests") + + static func gate(prohibiting pattern: String = "cmux-test://blocked.test") -> BrowserReplFrameGate { + let gate = BrowserReplFrameGate(world: world) + var policy = BrowserReplDomainPolicy() + policy.prohibited = [try! BrowserReplDomainPattern.parse(pattern, title: "test")] + gate.policy = policy + return gate + } + + static func error(_ body: () async throws -> Any?) async -> BrowserReplDriverError? { + do { + _ = try await body() + return nil + } catch let error as BrowserReplDriverError { + return error + } catch { + return BrowserReplDriverError(code: "unexpected", message: "\(error)") + } + } +} + +/// A page with two child frames: `allowed.test/child` at (10, 10) and +/// `blocked.test/x` at (200, 10), each 100 x 80 and holding a text field. +@MainActor +struct FramePage { + let webView: WKWebView + let frames: [BrowserReplFrame] + + /// The main frame as the driver names it without a tree read. + var main: BrowserReplFrame { + BrowserReplFrame(frameID: "main", parentFrameID: nil, indexInParent: 0, info: nil, + url: webView.url?.absoluteString ?? "", name: "", crossOrigin: false) + } + + func frame(host: String) -> BrowserReplFrame? { + frames.dropFirst().first { URL(string: $0.url)?.host == host } + } + + func frame(path: String) -> BrowserReplFrame? { + frames.dropFirst().first { URL(string: $0.url)?.path == path } + } + + static let mainPage = """ +

main

+ + + """ + + /// - Parameter loaded: when the page counts as loaded; by default once + /// every frame has a URL. + static func load( + url: String = "cmux-test://allowed.test/", + html: String = mainPage, + loaded: (([BrowserReplFrame]) -> Bool)? = nil, + configure: (WKWebViewConfiguration) async throws -> Void = { _ in } + ) async throws -> FramePage { + let configuration = WKWebViewConfiguration() + configuration.setURLSchemeHandler(FramePageSchemeHandler(mainPage: html), forURLScheme: "cmux-test") + try await configure(configuration) + let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 400, height: 300), configuration: configuration) + let expected = html == mainPage && url == "cmux-test://allowed.test/" ? 3 : 1 + webView.load(URLRequest(url: URL(string: url)!)) + let frames = try await settle(webView) { frames in + frames.count >= expected && (loaded?(frames) ?? frames.allSatisfy { !$0.url.isEmpty }) + } + return FramePage(webView: webView, frames: frames) + } + + /// The frame tree once `done` holds for it (child frames load after the + /// main frame). + static func settle(_ webView: WKWebView, _ done: ([BrowserReplFrame]) -> Bool) async throws -> [BrowserReplFrame] { + let deadline = ContinuousClock.now.advanced(by: .seconds(30)) + while ContinuousClock.now < deadline { + let frames = await BrowserReplFrame.readTree(of: webView) + if done(frames) { return frames } + try await Task.sleep(for: .milliseconds(50)) + } + throw BrowserReplDriverError(code: "timeout", message: "the test page did not settle") + } + + /// The viewport as an image of one pixel per CSS pixel. + static func viewportImage(of webView: WKWebView) async throws -> CGImage { + let configuration = WKSnapshotConfiguration() + configuration.afterScreenUpdates = true + configuration.snapshotWidth = NSNumber(value: Double(webView.bounds.width)) + let image = try await webView.takeSnapshot(configuration: configuration) + var rect = CGRect(origin: .zero, size: image.size) + guard let cgImage = image.cgImage(forProposedRect: &rect, context: nil, hints: nil) else { + throw BrowserReplDriverError(code: "invalid", message: "the snapshot had no bitmap") + } + return cgImage + } + + /// The color at CSS pixel (`x`, `y`) from the top-left of `image`, which + /// shows a 400-point-wide viewport. + static func pixel(_ image: CGImage, x: Int, y: Int) -> (red: CGFloat, green: CGFloat, blue: CGFloat)? { + let bitmap = NSBitmapImageRep(cgImage: image) + let scale = CGFloat(bitmap.pixelsWide) / 400 + guard let color = bitmap.colorAt(x: Int(CGFloat(x) * scale), y: Int(CGFloat(y) * scale))?.usingColorSpace(.deviceRGB) else { + return nil + } + return (color.redComponent, color.greenComponent, color.blueComponent) + } + + /// Runs `source` in `frame`'s page world without the gate. + func run(_ source: String, in frame: BrowserReplFrame) async throws -> Any? { + try await webView.callAsyncJavaScript(source, arguments: [:], in: frame.info, contentWorld: .page) + } +} + +final class FramePageSchemeHandler: NSObject, WKURLSchemeHandler { + let mainPage: String + + init(mainPage: String) { + self.mainPage = mainPage + } + + func webView(_ webView: WKWebView, start task: any WKURLSchemeTask) { + guard let url = task.request.url else { return } + let host = url.host ?? "" + let html = url.path == "/" || url.path.isEmpty + ? mainPage + : "

\(host)\(url.path)

" + task.didReceive(URLResponse(url: url, mimeType: "text/html", expectedContentLength: -1, textEncodingName: "utf-8")) + task.didReceive(Data(html.utf8)) + task.didFinish() + } + + func webView(_ webView: WKWebView, stop task: any WKURLSchemeTask) {} +} + +/// Hands main-actor test values to a `@Sendable` closure that runs on the +/// main actor. +struct SendableBox: @unchecked Sendable { + let value: T + init(_ value: T) { self.value = value } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplFrameRegistryTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplFrameRegistryTests.swift new file mode 100644 index 000000000000..325c74c7c2e7 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplFrameRegistryTests.swift @@ -0,0 +1,105 @@ +import Foundation +import Testing + +@testable import CmuxBrowser + +/// A frame tree source that counts reads and can hold a read open. +@MainActor +private final class FakeFrameTree { + var ids: [String] + var reads = 0 + private var gate: CheckedContinuation? + var holdNextRead = false + + init(_ ids: [String]) { self.ids = ids } + + func read() async -> [String] { + reads += 1 + let snapshot = ids + if holdNextRead { + holdNextRead = false + await withCheckedContinuation { gate = $0 } + } + return snapshot + } + + func release() { + gate?.resume() + gate = nil + } +} + +@MainActor +@Suite("Browser REPL frame registry") +struct BrowserReplFrameRegistryTests { + private func registry(_ tree: FakeFrameTree) -> BrowserReplFrameRegistry { + BrowserReplFrameRegistry(id: { $0 }, read: { await tree.read() }) + } + + @Test("Hundreds of concurrent lookups share one tree read") + func concurrentLookupsShareOneRead() async { + let tree = FakeFrameTree((0..<401).map { "f\($0)" }) + let registry = registry(tree) + tree.holdNextRead = true + let lookups = (0..<400).map { i in + Task { @MainActor in await registry.frame("f\(i)") != nil } + } + while tree.reads == 0 { await Task.yield() } + for _ in 0..<50 { await Task.yield() } + tree.release() + var found = 0 + for lookup in lookups where await lookup.value { found += 1 } + #expect(found == 400) + #expect(tree.reads == 1) + } + + @Test("Known frames need no read; an unknown frame reads once") + func knownAndUnknownFrames() async { + let tree = FakeFrameTree(["main", "a"]) + let registry = registry(tree) + #expect(await registry.frame("a") == "a") + #expect(await registry.frame("main") == "main") + #expect(tree.reads == 1) + #expect(await registry.frame("gone") == nil) + #expect(tree.reads == 2) + tree.ids.append("b") + #expect(await registry.frame("b") == "b") + #expect(tree.reads == 3) + #expect(await registry.frame("b") == "b") + #expect(tree.reads == 3) + } + + @Test("A fresh read starts after the request; a burst shares one more read") + func freshReadsAfterRequest() async { + let tree = FakeFrameTree(["main"]) + let registry = registry(tree) + tree.holdNextRead = true + let first = Task { @MainActor in await registry.frames(refresh: true) } + while tree.reads == 0 { await Task.yield() } + // The page adds a frame while the first read is in flight. + tree.ids = ["main", "late"] + let burst = (0..<300).map { _ in Task { @MainActor in await registry.frames(refresh: true) } } + for _ in 0..<50 { await Task.yield() } + tree.release() + // The first caller gets the newest read too. + #expect(await first.value == ["main", "late"]) + for request in burst { #expect(await request.value == ["main", "late"]) } + #expect(tree.reads == 2) + } + + @Test("A refresh or an invalidation reads the tree again") + func refreshAndInvalidate() async { + let tree = FakeFrameTree(["main"]) + let registry = registry(tree) + #expect(await registry.frames() == ["main"]) + #expect(await registry.frames() == ["main"]) + #expect(tree.reads == 1) + tree.ids = ["main", "x"] + #expect(await registry.frames(refresh: true) == ["main", "x"]) + #expect(tree.reads == 2) + tree.ids = ["main"] + registry.invalidate() + #expect(await registry.frame("x") == nil) + #expect(tree.reads == 3) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplHeldKeysTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplHeldKeysTests.swift new file mode 100644 index 000000000000..15c57be8b573 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplHeldKeysTests.swift @@ -0,0 +1,36 @@ +import Testing +@testable import CmuxBrowser + +@Suite struct BrowserReplHeldKeysTests { + private func stroke(_ key: String, _ code: String, _ modifiers: [String] = []) throws -> BrowserReplKeyStroke { + try #require(BrowserReplKeyStroke.resolve(key: key, code: code, text: nil, modifiers: modifiers)) + } + + @Test func heldKeysReleaseLastPressedFirst() throws { + var held = BrowserReplHeldKeys() + let shift = try stroke("Shift", "ShiftLeft") + let a = try stroke("A", "KeyA", ["Shift"]) + held.record(shift, keyDown: true) + held.record(a, keyDown: true) + #expect(held.releaseAll() == [a, shift]) + #expect(held.strokes.isEmpty) + } + + @Test func releasedKeysAreNotReleasedAgain() throws { + var held = BrowserReplHeldKeys() + let a = try stroke("a", "KeyA") + let b = try stroke("b", "KeyB") + held.record(a, keyDown: true) + held.record(b, keyDown: true) + held.record(a, keyDown: false) + #expect(held.releaseAll() == [b]) + } + + @Test func autoRepeatKeepsOneEntry() throws { + var held = BrowserReplHeldKeys() + let a = try stroke("a", "KeyA") + held.record(a, keyDown: true) + held.record(a, keyDown: true) + #expect(held.strokes == [a]) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplInputMappingTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplInputMappingTests.swift new file mode 100644 index 000000000000..95480f0e0361 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplInputMappingTests.swift @@ -0,0 +1,116 @@ +import AppKit +import Testing + +@testable import CmuxBrowser + +@Suite("Browser REPL key mapping") +struct BrowserReplKeyStrokeTests { + struct Case: Sendable, CustomStringConvertible { + let key: String + let code: String + let text: String? + let modifiers: [String] + let keyCode: UInt16 + let characters: String + let ignoring: String + let flags: NSEvent.ModifierFlags + let command: String? + + var description: String { "\(modifiers.joined(separator: "+"))+\(key)/\(code)" } + } + + static let cases: [Case] = [ + Case(key: "a", code: "KeyA", text: "a", modifiers: [], keyCode: 0, characters: "a", ignoring: "a", flags: [], command: nil), + Case(key: "C", code: "KeyC", text: "C", modifiers: ["Shift"], keyCode: 8, characters: "C", ignoring: "c", flags: [.shift], command: nil), + Case(key: "a", code: "KeyA", text: "", modifiers: ["Meta"], keyCode: 0, characters: "a", ignoring: "a", flags: [.command], command: "selectAll:"), + Case(key: "z", code: "KeyZ", text: "", modifiers: ["Meta", "Shift"], keyCode: 6, characters: "z", ignoring: "z", flags: [.command, .shift], command: "redo:"), + Case(key: "Enter", code: "Enter", text: "\r", modifiers: [], keyCode: 36, characters: "\r", ignoring: "\r", flags: [], command: nil), + Case(key: "Backspace", code: "Backspace", text: "", modifiers: [], keyCode: 51, characters: "\u{8}", ignoring: "\u{8}", flags: [], command: nil), + Case(key: "End", code: "End", text: "", modifiers: [], keyCode: 119, characters: "\u{F72B}", ignoring: "\u{F72B}", flags: [], command: nil), + Case(key: "ArrowLeft", code: "ArrowLeft", text: "", modifiers: ["Alt"], keyCode: 123, characters: "\u{F702}", ignoring: "\u{F702}", flags: [.option], command: nil), + Case(key: "!", code: "Digit1", text: "!", modifiers: ["Shift"], keyCode: 18, characters: "!", ignoring: "1", flags: [.shift], command: nil), + Case(key: "c", code: "KeyC", text: "", modifiers: ["Control"], keyCode: 8, characters: "\u{3}", ignoring: "c", flags: [.control], command: nil), + Case(key: "Tab", code: "", text: nil, modifiers: [], keyCode: 48, characters: "\t", ignoring: "\t", flags: [], command: nil), + Case(key: "b", code: "", text: nil, modifiers: [], keyCode: 11, characters: "b", ignoring: "b", flags: [], command: nil), + Case(key: "b", code: "KeyB", text: "", modifiers: ["Meta"], keyCode: 11, characters: "b", ignoring: "b", flags: [.command], command: "bold"), + Case(key: "i", code: "KeyI", text: "", modifiers: ["Meta"], keyCode: 34, characters: "i", ignoring: "i", flags: [.command], command: "italic"), + Case(key: "u", code: "KeyU", text: "", modifiers: ["Meta"], keyCode: 32, characters: "u", ignoring: "u", flags: [.command], command: "underline"), + Case(key: "K", code: "KeyK", text: nil, modifiers: ["Alt", "Shift"], keyCode: 40, characters: "", ignoring: "k", flags: [.option, .shift], command: nil), + ] + + @Test("Playwright keys resolve to the AppKit event WebKit receives", arguments: cases) + func resolvesKey(_ testCase: Case) throws { + let stroke = try #require(BrowserReplKeyStroke.resolve( + key: testCase.key, + code: testCase.code, + text: testCase.text, + modifiers: testCase.modifiers + )) + #expect(stroke.keyCode == testCase.keyCode) + #expect(stroke.characters == testCase.characters) + #expect(stroke.charactersIgnoringModifiers == testCase.ignoring) + #expect(stroke.modifierFlags.intersection([.shift, .control, .option, .command]) == testCase.flags) + #expect(stroke.editingCommand == testCase.command) + #expect(stroke.modifierKey == nil) + } + + @Test("Modifier keys carry their own flag and no characters") + func modifierKey() throws { + let stroke = try #require(BrowserReplKeyStroke.resolve(key: "Shift", code: "ShiftLeft", text: nil, modifiers: ["Shift"])) + #expect(stroke.keyCode == 56) + #expect(stroke.modifierKey == .shift) + #expect(stroke.characters.isEmpty) + #expect(stroke.location == 1) + } + + @Test("Keys with no macOS virtual key fall back to text insertion") + func unmappedKey() { + #expect(BrowserReplKeyStroke.resolve(key: "é", code: "", text: "é", modifiers: []) == nil) + } + + @Test("Unknown modifier names are ignored") + func unknownModifier() { + #expect(BrowserReplKeyStroke.modifierFlags(named: ["Hyper", "Meta"]) == .command) + } +} + +@Suite("Browser REPL mouse mapping") +struct BrowserReplMouseStateTests { + @Test("A move while a button is held is a drag of that button") + func moveWhileHeldIsDrag() { + var state = BrowserReplMouseState() + #expect(state.eventType(forType: "move", button: .left) == .mouseMoved) + #expect(state.eventType(forType: "down", button: .left) == .leftMouseDown) + #expect(state.eventType(forType: "move", button: .left) == .leftMouseDragged) + #expect(state.eventType(forType: "up", button: .left) == .leftMouseUp) + #expect(state.eventType(forType: "move", button: .left) == .mouseMoved) + } + + @Test("Right and middle buttons map to their own AppKit events") + func otherButtons() { + var state = BrowserReplMouseState() + #expect(state.eventType(forType: "down", button: .right) == .rightMouseDown) + #expect(state.eventType(forType: "move", button: .right) == .rightMouseDragged) + #expect(state.eventType(forType: "up", button: .right) == .rightMouseUp) + #expect(state.eventType(forType: "down", button: .middle) == .otherMouseDown) + #expect(state.eventType(forType: "up", button: .middle) == .otherMouseUp) + #expect(state.eventType(forType: "wheel", button: .left) == nil) + #expect(state.pressedButtons.isEmpty) + } + + @Test("CSS pixels convert to view points through zoom and flipping") + func coordinateConversion() { + let flipped = CGPoint(x: 100, y: 40).browserReplViewPoint( + cssPerPoint: 1, + viewIsFlipped: true, + viewHeight: 600 + ) + #expect(flipped == CGPoint(x: 100, y: 40)) + let zoomed = CGPoint(x: 100, y: 40).browserReplViewPoint( + cssPerPoint: 0.5, + viewIsFlipped: false, + viewHeight: 600 + ) + #expect(zoomed == CGPoint(x: 200, y: 520)) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplKeyResendTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplKeyResendTests.swift new file mode 100644 index 000000000000..cd51a1649f1d --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplKeyResendTests.swift @@ -0,0 +1,106 @@ +import AppKit +import Testing +import WebKit + +@testable import CmuxBrowser + +/// WebKit sends a key-down no page handled back through `NSApp.sendEvent` +/// (WebViewImpl::doneWithKeyEvent), which routes it to the key window. For a +/// key an agent typed into a tab, that is the user's window: seen live, an +/// agent's `q` in a hidden tab of a background workspace was typed into the +/// user's focused terminal, and an agent's Command key would run cmux menu +/// shortcuts. Keys the REPL and `cmux browser press` send carry a mark, and +/// the app drops a marked key event that reaches it outside the web view's +/// own delivery. The mobile browser stream's keys (a person on a phone) keep +/// the resend. +@MainActor +@Suite("Browser REPL unhandled key resend", .serialized) +struct BrowserReplKeyResendTests { + /// Records the key events WebKit's responder methods receive. + private final class RecordingWebView: WKWebView { + var keyDowns: [NSEvent] = [] + var selectAllCount = 0 + override func selectAll(_ sender: Any?) { + selectAllCount += 1 + } + override func keyDown(with event: NSEvent) { + keyDowns.append(event) + } + override func keyUp(with event: NSEvent) {} + } + + private let qKey = SyntheticKeySpecification( + storedKey: "q", + keyCode: 12, + modifierFlags: [], + characters: "q", + charactersIgnoringModifiers: "q" + ) + + @Test func keysTheReplTypesAreMarkedAsAutomation() throws { + let webView = RecordingWebView(frame: NSRect(x: 0, y: 0, width: 200, height: 200)) + let stroke = try #require(BrowserReplKeyStroke.resolve(key: "q", code: "KeyQ", text: "q", modifiers: [])) + #expect(webView.replayBrowserReplKeyStroke(stroke, keyDown: true) == .delivered) + let delivered = try #require(webView.keyDowns.first) + #expect(delivered.isBrowserAutomationKeyEvent) + } + + @Test func keysCmuxBrowserPressSendsAreMarkedAsAutomation() throws { + let webView = RecordingWebView(frame: NSRect(x: 0, y: 0, width: 200, height: 200)) + let event = try #require(BrowserKeyboardEvent(rawKey: "q")) + #expect(webView.replayBrowserKeyboardEvent(event, action: .press) == .delivered) + let delivered = try #require(webView.keyDowns.first) + #expect(delivered.isBrowserAutomationKeyEvent) + } + + // cmux browser press Meta+a selected all through WebKit's resend to the + // Edit menu; with the resend dropped, the web view runs the editing + // command itself, as the REPL does. + @Test func cmuxBrowserPressRunsEditingShortcutsOnTheWebView() throws { + let webView = RecordingWebView(frame: NSRect(x: 0, y: 0, width: 200, height: 200)) + let meta = try #require(BrowserKeyboardEvent(rawKey: "Meta")) + let a = try #require(BrowserKeyboardEvent(rawKey: "a")) + #expect(webView.replayBrowserKeyboardEvent(meta, action: .keyDown) == .delivered) + #expect(webView.replayBrowserKeyboardEvent(a, action: .press) == .delivered) + #expect(webView.replayBrowserKeyboardEvent(meta, action: .keyUp) == .delivered) + #expect(webView.selectAllCount == 1) + // Without Command, a is just a key. + #expect(webView.replayBrowserKeyboardEvent(a, action: .press) == .delivered) + #expect(webView.selectAllCount == 1) + } + + // The mobile browser stream replays a person's keys from their phone + // through the specification entry point; WebKit's resend of a key no page + // handled keeps reaching the Mac's menus there, as before. + @Test func keysTheMobileStreamReplaysKeepWebKitsResend() throws { + let webView = RecordingWebView(frame: NSRect(x: 0, y: 0, width: 200, height: 200)) + #expect(webView.replayBrowserKeyboardSpecification(qKey, action: .press, characters: "q") == .delivered) + let delivered = try #require(webView.keyDowns.first) + #expect(!delivered.isBrowserAutomationKeyEvent) + #expect(!delivered.isResentBrowserAutomationKeyEvent) + } + + @Test func aMarkedKeyOutsideTheWebViewsDeliveryIsAResendToDrop() throws { + let webView = RecordingWebView(frame: NSRect(x: 0, y: 0, width: 200, height: 200)) + let stroke = try #require(BrowserReplKeyStroke.resolve(key: "q", code: "KeyQ", text: "q", modifiers: [])) + _ = webView.replayBrowserReplKeyStroke(stroke, keyDown: true) + let delivered = try #require(webView.keyDowns.first) + // WebKit's resend arrives on a later turn, outside any delivery. + #expect(delivered.isResentBrowserAutomationKeyEvent) + // The web view's own delivery (arrow keys go through its window) is not a resend. + webView.withBrowserWebKitKeyDownDispatch { + #expect(!delivered.isResentBrowserAutomationKeyEvent) + } + } + + @Test func theUsersKeysAndShortcutSimulationAreNotAutomation() throws { + let simulated = try #require(SyntheticKeyEventFactory.keyEvent(specification: qKey, keyDown: true, timestamp: 0, characters: "q")) + #expect(!simulated.isBrowserAutomationKeyEvent) + #expect(!simulated.isResentBrowserAutomationKeyEvent) + let typed = try #require(NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: 0, + context: nil, characters: "q", charactersIgnoringModifiers: "q", isARepeat: false, keyCode: 12 + )) + #expect(!typed.isBrowserAutomationKeyEvent) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplPDFPrinterTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplPDFPrinterTests.swift new file mode 100644 index 000000000000..768c2237ae01 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplPDFPrinterTests.swift @@ -0,0 +1,73 @@ +import AppKit +import Testing +import WebKit + +@testable import CmuxBrowser + +@MainActor +@Suite("Browser REPL PDF printing", .serialized) +struct BrowserReplPDFPrinterTests { + private final class Loaded: NSObject, WKNavigationDelegate { + var continuation: CheckedContinuation? + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + continuation?.resume() + continuation = nil + } + } + + private func longPage() async -> WKWebView { + let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) + let loaded = Loaded() + webView.navigationDelegate = loaded + let body = (0..<200).map { "

line \($0) of a page longer than one sheet

" }.joined() + await withCheckedContinuation { continuation in + loaded.continuation = continuation + webView.loadHTMLString("\(body)", baseURL: nil) + } + webView.navigationDelegate = nil + return webView + } + + private func pageCount(_ data: Data) -> Int { + guard let provider = CGDataProvider(data: data as CFData), let document = CGPDFDocument(provider) else { return 0 } + return document.numberOfPages + } + + @Test func printsAPaginatedPDFOfAWebViewInNoWindow() async throws { + let webView = await longPage() + #expect(webView.window == nil) + let data = try await BrowserReplPDFPrinter( + paper: CGSize(width: 612, height: 792), + margins: NSEdgeInsets(top: 36, left: 36, bottom: 36, right: 36), + printBackground: false + ).pdf(of: webView) + #expect(pageCount(data) > 1) + } + + @Test func leavesTheWebViewsWindowAlone() async throws { + let webView = await longPage() + let window = NSWindow( + contentRect: NSRect(x: -32_000, y: -32_000, width: 800, height: 600), + styleMask: [.titled], + backing: .buffered, + defer: true + ) + window.isReleasedWhenClosed = false + window.contentView = webView + defer { window.close() } + var sheets: [NSWindow] = [] + let observer = NotificationCenter.default.addObserver(forName: NSWindow.willBeginSheetNotification, object: window, queue: nil) { note in + MainActor.assumeIsolated { sheets.append(window) } + } + defer { NotificationCenter.default.removeObserver(observer) } + let data = try await BrowserReplPDFPrinter( + paper: CGSize(width: 612, height: 792), + margins: NSEdgeInsets(), + printBackground: true + ).pdf(of: webView) + #expect(pageCount(data) > 1) + #expect(sheets.isEmpty) + #expect(window.attachedSheet == nil) + #expect(webView.window === window) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplPageClipboardTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplPageClipboardTests.swift new file mode 100644 index 000000000000..7417c2fcef2c --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplPageClipboardTests.swift @@ -0,0 +1,325 @@ +import AppKit +import ObjectiveC +import WebKit +import Testing + +@testable import CmuxBrowser + +extension BrowserReplPasteboardRedirectTests { + /// Page scripts in a tab a REPL session created (the Clipboard API, a + /// `ClipboardItem` whose data arrives later, `execCommand("copy")`) must + /// never write the system clipboard, at any time: an agent's click gives + /// the page a user gesture, and a hostile page could plant a shell command + /// for the person's next paste in the terminal. Their writes land on the + /// tab's own clipboard instead. A tab no session created keeps the + /// browser's behavior. + /// + /// The system pasteboard is a stand-in for every lookup WebKit makes of + /// it (by name, and `+generalPasteboard`, which the asynchronous Clipboard + /// API uses, also off the main thread), so a leaking build fills the + /// stand-in and the person's clipboard stays untouched. The real one is + /// only read (its change count). + /// + /// Nested in the redirect suite: the stand-in replaces process-wide + /// lookups, so these tests must not run alongside the redirect's. + @MainActor + @Suite("Page scripts", .serialized) + struct PageScripts { + /// Each page action runs from a click handler: the click runs in a + /// user gesture (WebKit grants one to a script the app evaluates, as + /// it does to an agent's real click). + static let page = """ + + + + + + + """ + + struct Case: CustomTestStringConvertible, Sendable { + let button: String + let done: String + let text: String + var testDescription: String { button } + } + + nonisolated static let cases: [Case] = [ + Case(button: "write-text", done: "ok", text: "planted by writeText"), + Case(button: "write-item", done: "ok", text: "planted by a late item"), + Case(button: "exec-copy", done: "true", text: "copied by execCommand"), + Case(button: "exec-copy-handler", done: "true", text: "planted by a copy handler"), + ] + + @Test(arguments: cases) + func aPageScriptInASessionTabWritesOnlyTheTabClipboard(_ action: Case) async throws { + let shim = try Self.shim() + let system = NSPasteboard.general + let systemBefore = system.changeCount + var routed: [[[String: Any]]] = [] + var outcome: (done: String?, standInChanged: Bool, standInKept: Bool)? + try await Self.withStandInSystemPasteboard { standIn in + let standInBefore = standIn.changeCount + let webView = try await Self.load(Self.page) { webView in + BrowserReplPageClipboard(shim: shim).install(on: webView) { _, items in + routed.append(items) + return true + } + } + try await Self.click(action.button, in: webView) + let done = try await Self.waitForDone(in: webView) + // Let a write that would land late (WebKit's or the routed + // one) arrive before judging. + try await Self.settle { !routed.isEmpty || standIn.changeCount != standInBefore } + outcome = ( + done, + standIn.changeCount != standInBefore, + standIn.string(forType: .string) == Self.personsClipboard + ) + } + let result = try #require(outcome) + #expect(!result.standInChanged, "the page's \(action.button) wrote the system pasteboard") + #expect(result.standInKept, "the page's \(action.button) replaced the system pasteboard's contents") + #expect(system.changeCount == systemBefore, "the page's \(action.button) wrote the real system pasteboard") + #expect(result.done == action.done) + let texts = routed.last?.compactMap { item -> String? in + guard item["type"] as? String == "text/plain", + let base64 = item["base64"] as? String, + let data = Data(base64Encoded: base64) else { return nil } + return String(decoding: data, as: UTF8.self) + } + #expect(routed.count == 1, "the tab's clipboard did not get exactly one write") + #expect(texts == [action.text]) + } + + /// The guard's first half is WebKit's own switch for the asynchronous + /// Clipboard API; the page script cannot reach a frame's initial empty + /// document, so without the switch such a document keeps a native + /// `navigator.clipboard` that writes the system clipboard. A WebKit + /// without the switch has no guard at all: the driver opens no + /// session tab there (`tabs.open` fails with `unsupported`). + @Test func theGuardIsSupportedOnlyWhereWebKitCanTurnTheClipboardAPIOff() { + #expect(BrowserReplPageClipboard.isSupported, "this WebKit can turn its asynchronous Clipboard API off") + #expect( + !BrowserReplPageClipboard.isSupported(featureKey: "CmuxNoSuchWebKitFeature"), + "a WebKit without the switch was reported as guarded" + ) + let preferences = WKPreferences() + #expect(!BrowserReplPageClipboard.disableAsyncClipboardAPI(in: preferences, featureKey: "CmuxNoSuchWebKitFeature")) + } + + /// In the app an agent's click reaches the page's click handler after + /// WebKit has reset the page's transient activation (it does after + /// each script the driver evaluates between the press and the + /// release), so `navigator.userActivation.isActive` is false there + /// although the click is a user gesture. The page's write still lands + /// on the tab's clipboard. Here the click runs from a script WebKit + /// evaluates without a user gesture, which leaves the page in the same + /// state. + @Test(arguments: [cases[0], cases[2]]) + func aPageScriptWithoutTransientActivationStillWritesTheTabClipboard(_ action: Case) async throws { + let shim = try Self.shim() + var routed: [[[String: Any]]] = [] + var outcome: (done: String?, active: String?, standInChanged: Bool)? + try await Self.withStandInSystemPasteboard { standIn in + let standInBefore = standIn.changeCount + let webView = try await Self.load(Self.page) { webView in + BrowserReplPageClipboard(shim: shim).install(on: webView) { _, items in + routed.append(items) + return true + } + } + let selector = NSSelectorFromString("_evaluateJavaScriptWithoutUserGesture:completionHandler:") + try #require(webView.responds(to: selector)) + typealias Evaluate = @convention(c) (AnyObject, Selector, NSString, (@convention(block) (Any?, (any Error)?) -> Void)?) -> Void + let evaluate = unsafeBitCast(webView.method(for: selector), to: Evaluate.self) + var active: String? + await withCheckedContinuation { (continuation: CheckedContinuation) in + let script = "window.__active = String(navigator.userActivation.isActive); document.getElementById('\(action.button)').click(); window.__active" + evaluate(webView, selector, script as NSString) { value, _ in + active = value as? String + continuation.resume() + } + } + let done = try await Self.waitForDone(in: webView) + try await Self.settle { !routed.isEmpty || standIn.changeCount != standInBefore } + outcome = (done, active, standIn.changeCount != standInBefore) + } + let result = try #require(outcome) + #expect(result.active == "false", "the click ran with transient activation, so this tests nothing") + #expect(!result.standInChanged, "the page's \(action.button) wrote the system pasteboard") + #expect(result.done == action.done) + let texts = routed.last?.compactMap { item -> String? in + guard let base64 = item["base64"] as? String, let data = Data(base64Encoded: base64) else { return nil } + return String(decoding: data, as: UTF8.self) + } + #expect(texts == [action.text], "the page's write did not reach the tab's clipboard") + } + + /// A tab no session created keeps the browser's behavior: the page's + /// writes reach the (stand-in) system pasteboard, also after a session + /// tab in this process installed the guard. + @Test func aPageScriptInATabNoSessionCreatedStillWritesTheSystemPasteboard() async throws { + let shim = try Self.shim() + let system = NSPasteboard.general + let systemBefore = system.changeCount + var written: [String?] = [] + try await Self.withStandInSystemPasteboard { standIn in + _ = try await Self.load(Self.page) { webView in + BrowserReplPageClipboard(shim: shim).install(on: webView) { _, _ in true } + } + let webView = try await Self.load(Self.page) { _ in } + for button in ["write-text", "exec-copy"] { + try await Self.click(button, in: webView) + _ = try await Self.waitForDone(in: webView) + try await Self.settle { standIn.string(forType: .string) != Self.personsClipboard } + written.append(standIn.string(forType: .string)) + standIn.clearContents() + standIn.setString(Self.personsClipboard, forType: .string) + _ = try await webView.callAsyncJavaScript("delete window.__done", arguments: [:], in: nil, contentWorld: .page) + } + } + #expect(written == ["planted by writeText", "copied by execCommand"]) + #expect(system.changeCount == systemBefore, "the stand-in let a write reach the real system pasteboard") + } + + // MARK: - Helpers + + static let personsClipboard = "the person's clipboard" + + /// `Resources/browser-repl/page-clipboard.js`, the script the app + /// installs in a session's tabs. + static func shim() throws -> String { + var url = URL(fileURLWithPath: #filePath) + while url.path != "/" && !FileManager.default.fileExists(atPath: url.appendingPathComponent("Resources/browser-repl").path) { + url.deleteLastPathComponent() + } + return try String(contentsOf: url.appendingPathComponent("Resources/browser-repl/page-clipboard.js"), encoding: .utf8) + } + + private final class Loaded: NSObject, WKNavigationDelegate { + var continuation: CheckedContinuation? + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + continuation?.resume() + continuation = nil + } + } + + static func load(_ html: String, prepare: (WKWebView) -> Void) async throws -> WKWebView { + let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 400, height: 300), configuration: WKWebViewConfiguration()) + prepare(webView) + let loaded = Loaded() + webView.navigationDelegate = loaded + await withCheckedContinuation { continuation in + loaded.continuation = continuation + webView.loadHTMLString(html, baseURL: URL(string: "https://example.com/")) + } + webView.navigationDelegate = nil + return webView + } + + static func click(_ id: String, in webView: WKWebView) async throws { + _ = try await webView.callAsyncJavaScript( + "document.getElementById(id).click(); return true", + arguments: ["id": id], + in: nil, + contentWorld: .page + ) + } + + /// What the page's handler reported once its write settled. + static func waitForDone(in webView: WKWebView) async throws -> String? { + var done: String? + try await settle { + false + } poll: { + done = try await webView.callAsyncJavaScript( + "return window.__done ?? null", + arguments: [:], + in: nil, + contentWorld: .page + ) as? String + return done != nil + } + return done + } + + /// Waits until `condition`, or `poll`, holds, for at most 30 s. The + /// bound only turns a broken build into a failure instead of a hang; + /// on a loaded machine a 300 ms page timer has taken seconds. + static func settle( + _ condition: @MainActor () -> Bool, + poll: @MainActor () async throws -> Bool = { false } + ) async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(30)) + while ContinuousClock.now < deadline { + if condition() { return } + if try await poll() { return } + try await Task.sleep(for: .milliseconds(50)) + } + } + + /// Runs `body` with a stand-in for the system pasteboard: every lookup + /// that would return the system's (`+pasteboardWithName:` with its + /// name, and `+generalPasteboard`) returns the stand-in, which starts + /// with ``personsClipboard``. + static func withStandInSystemPasteboard(_ body: (NSPasteboard) async throws -> Void) async throws { + let standIn = NSPasteboard.withUniqueName() + defer { standIn.releaseGlobally() } + standIn.clearContents() + standIn.setString(personsClipboard, forType: .string) + let pasteboards = StandIn(system: NSPasteboard(name: .general), standIn: standIn) + + let byName = NSSelectorFromString("pasteboardWithName:") + let byNameMethod = try #require(class_getClassMethod(NSPasteboard.self, byName)) + typealias Lookup = @convention(c) (AnyObject, Selector, NSString) -> NSPasteboard + let previousByName = method_getImplementation(byNameMethod) + let lookUp = unsafeBitCast(previousByName, to: Lookup.self) + let byNameReplacement: @convention(block) @Sendable (AnyObject, NSString) -> NSPasteboard = { cls, name in + let found = lookUp(cls, byName, name) + return found === pasteboards.system ? pasteboards.standIn : found + } + + let general = NSSelectorFromString("generalPasteboard") + let generalMethod = try #require(class_getClassMethod(NSPasteboard.self, general)) + let previousGeneral = method_getImplementation(generalMethod) + let generalReplacement: @convention(block) @Sendable (AnyObject) -> NSPasteboard = { _ in pasteboards.standIn } + + method_setImplementation(byNameMethod, imp_implementationWithBlock(byNameReplacement)) + method_setImplementation(generalMethod, imp_implementationWithBlock(generalReplacement)) + defer { + method_setImplementation(generalMethod, previousGeneral) + method_setImplementation(byNameMethod, previousByName) + } + try await body(standIn) + } + + private struct StandIn: @unchecked Sendable { + let system: NSPasteboard + let standIn: NSPasteboard + } + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplPasteboardRedirectTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplPasteboardRedirectTests.swift new file mode 100644 index 000000000000..e6096dea9f7a --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplPasteboardRedirectTests.swift @@ -0,0 +1,860 @@ +import AppKit +import ObjectiveC +import WebKit +import Testing + +@testable import CmuxBrowser + +/// The tab pasteboard a REPL Copy, Cut or Paste runs against must reach +/// WebKit's command and nothing else, only until the command's timeout, and +/// WebKit's command must never reach the system pasteboard. These tests +/// compare pasteboard identities and change counts; they never read or write +/// the system pasteboard's contents, and they never print a page value that +/// a broken redirect could have filled from it. +/// +/// The redirect is process-wide, so every test runs in one serialized suite: +/// the nested suites must not run alongside each other. +@MainActor +@Suite("Browser REPL pasteboard redirect", .serialized) +struct BrowserReplPasteboardRedirectTests { + private static let general = NSPasteboard.Name.general.rawValue + + @MainActor + @Suite("Lookups", .serialized) + struct Lookups { + @Test func otherCodeGetsTheSystemPasteboardDuringAndAfterACommand() async throws { + let system = NSPasteboard(name: .general) + #expect(BrowserReplPasteboardRedirect.shared.install()) + let tab = NSPasteboard.withUniqueName() + defer { tab.releaseGlobally() } + + var during: [NSPasteboard] = [] + let outcome = await BrowserReplPasteboardRedirect.shared.run(on: tab, timeout: .seconds(5), endWebContent: { true }) { done in + // The terminal or any other cmux code looking up the general + // pasteboard while WebKit's command is in flight. + during.append(NSPasteboard(name: .general)) + during.append(NSPasteboard.general) + done() + } + #expect(outcome == .completed) + #expect(during.count == 2) + #expect(during.allSatisfy { $0 === system }, "a lookup by other code during a REPL command got the tab's pasteboard") + #expect(NSPasteboard(name: .general) === system) + #expect(BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: false) == nil) + } + + /// At the timeout the command's web content is ended and the + /// redirect ends in the same turn: a person pasting or copying in + /// another browser pane afterwards reaches their own clipboard, and + /// the page that outlived the timeout can no longer write anything. + @Test func atTheTimeoutTheWebContentIsEndedAndTheRedirectEnds() async throws { + #expect(BrowserReplPasteboardRedirect.shared.install()) + let tab = NSPasteboard.withUniqueName() + defer { tab.releaseGlobally() } + var finish: (@MainActor () -> Void)? + var ended = 0 + var finished = 0 + var redirectedWhenEnded: NSPasteboard? + let outcome = await BrowserReplPasteboardRedirect.shared.run( + on: tab, + timeout: .milliseconds(50), + endWebContent: { + ended += 1 + redirectedWhenEnded = BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: true) + return true + }, + whenFinished: { finished += 1 } + ) { done in + finish = done + } + #expect(outcome == .timedOut) + #expect(ended == 1) + #expect(redirectedWhenEnded === tab, "the redirect ended before the web content did, so a late write could reach the system pasteboard") + #expect( + BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: true) == nil, + "the redirect outlived its command's timeout" + ) + #expect(finished == 1, "ending the web content finishes the command") + try #require(finish != nil) + finish?() + #expect(finished == 1) + #expect(BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: true) == nil) + } + + /// When the web content can no longer be ended (its process came to + /// hold a tab no session created), WebKit's lookups keep getting the + /// command's pasteboard until WebKit finishes within the grace (60 s + /// here, so WebKit finishes first), so a late write never reaches the + /// system pasteboard. Later commands, from any tab, wait and name the + /// tab they waited for. + @Test func whenTheWebContentCannotBeEndedTheRedirectLastsUntilWebKitFinishesWithinTheGrace() async throws { + #expect(BrowserReplPasteboardRedirect.shared.install()) + let first = NSPasteboard.withUniqueName() + let second = NSPasteboard.withUniqueName() + defer { + first.releaseGlobally() + second.releaseGlobally() + } + var finishFirst: (@MainActor () -> Void)? + var firstFinished = 0 + let firstOutcome = await BrowserReplPasteboardRedirect.shared.run( + on: first, + tab: "tab A", + timeout: .milliseconds(50), + grace: .seconds(60), + endWebContent: { false }, + whenFinished: { firstFinished += 1 } + ) { done in + finishFirst = done + } + #expect(firstOutcome == .timedOutStillRunning) + #expect(firstFinished == 0) + #expect( + BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: true) === first, + "a late write of a command whose web content still runs could reach the system pasteboard" + ) + #expect(BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: false) == nil) + + var secondStarted = false + let secondOutcome = await BrowserReplPasteboardRedirect.shared.run(on: second, tab: "tab B", timeout: .milliseconds(50), endWebContent: { true }) { done in + secondStarted = true + done() + } + #expect(secondOutcome == .busy(tab: "tab A")) + #expect(!secondStarted, "a second command ran while WebKit could still write the first one's late copy into its pasteboard") + + // WebKit finishes the first command late: what it wrote meanwhile + // stayed on the first command's pasteboard, which is emptied and + // released now. + first.clearContents() + first.setString("late write", forType: .string) + try #require(finishFirst != nil) + finishFirst?() + #expect(firstFinished == 1) + #expect(BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: true) == nil) + #expect(first.types?.isEmpty ?? true, "the abandoned command's pasteboard kept its late write") + + let thirdOutcome = await BrowserReplPasteboardRedirect.shared.run(on: second, timeout: .seconds(5), endWebContent: { true }) { done in + secondStarted = true + done() + } + #expect(thirdOutcome == .completed) + #expect(secondStarted) + } + + /// A command that waited for an earlier one gets its own timeout once + /// it starts, so its tab is not ended for the time the earlier one + /// took. Time is a manual clock, so the order never depends on load. + @Test func aCommandThatWaitedGetsItsOwnTimeout() async throws { + #expect(BrowserReplPasteboardRedirect.shared.install()) + let first = NSPasteboard.withUniqueName() + let second = NSPasteboard.withUniqueName() + defer { + first.releaseGlobally() + second.releaseGlobally() + } + let clock = ManualClock() + let firstRun = Task { @MainActor in + await BrowserReplPasteboardRedirect.shared.run(on: first, timeout: .seconds(5), clock: clock, endWebContent: { true }) { _ in } + } + try await settle { BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: true) === first } + + clock.advance(by: .seconds(1)) + var secondEnded = false + var finishSecond: (@MainActor () -> Void)? + let secondRun = Task { @MainActor in + await BrowserReplPasteboardRedirect.shared.run( + on: second, + timeout: .seconds(5), + clock: clock, + endWebContent: { + secondEnded = true + return true + } + ) { done in + finishSecond = done + } + } + await clock.waitForSleepers(2) + + // The first command reaches its timeout at 5 s and is ended; the + // second starts then, with its own 5 s. + clock.advance(by: .seconds(4)) + #expect(await firstRun.value == .timedOut) + try await settle { finishSecond != nil } + #expect(BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: true) === second) + + // 7 s: past a deadline shared with the wait, inside its own. + clock.advance(by: .seconds(2)) + await settleTurns() + #expect(!secondEnded, "a command that waited was ended for the earlier command's time") + finishSecond?() + #expect(await secondRun.value == .completed) + } + + /// A caller that stops waiting (its REPL call was cancelled) does not + /// shorten the command: the web content is ended at the timeout, not + /// at the cancellation, so the timeout the error names is the one + /// that happened. + @Test func aCancelledCallerDoesNotEndTheWebContentBeforeTheTimeout() async throws { + #expect(BrowserReplPasteboardRedirect.shared.install()) + let tab = NSPasteboard.withUniqueName() + defer { tab.releaseGlobally() } + let clock = ManualClock() + var ended = false + let run = Task { @MainActor in + await BrowserReplPasteboardRedirect.shared.run( + on: tab, + timeout: .seconds(5), + clock: clock, + endWebContent: { + ended = true + return true + } + ) { _ in } + } + try await settle { BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: true) === tab } + run.cancel() + await settleTurns() + #expect(!ended, "a cancelled caller ended the web content before the timeout") + #expect(BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: true) === tab) + + clock.advance(by: .seconds(5)) + #expect(await run.value == .timedOut) + #expect(ended) + #expect(BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: true) == nil) + } + + /// A command whose web content could not be ended at the timeout + /// (another tab no session created shares its process) keeps the + /// redirect for at most one more timeout: then the web content is + /// ended regardless and the redirect ends, so another web view's + /// copies and pastes reach the system pasteboard again and nothing + /// the page writes later does. + @Test func aCommandStillRunningAfterItsTimeoutIsEndedOneTimeoutLater() async throws { + #expect(BrowserReplPasteboardRedirect.shared.install()) + let tab = NSPasteboard.withUniqueName() + defer { tab.releaseGlobally() } + let clock = ManualClock() + var asked = 0 + var finished = 0 + let run = Task { @MainActor in + await BrowserReplPasteboardRedirect.shared.run( + on: tab, + tab: "tab A", + timeout: .seconds(5), + clock: clock, + endWebContent: { + asked += 1 + return asked > 1 + }, + whenFinished: { finished += 1 } + ) { _ in } + } + await clock.waitForSleepers(1) + clock.advance(by: .seconds(5)) + #expect(await run.value == .timedOutStillRunning) + #expect(asked == 1) + #expect(BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: true) === tab) + + await clock.waitForSleepers(1) + clock.advance(by: .seconds(4)) + await settleTurns() + #expect(asked == 1, "the web content was ended before its second timeout") + clock.advance(by: .seconds(1)) + try await settle { asked == 2 } + #expect( + BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: true) == nil, + "the redirect outlived the command's second timeout" + ) + #expect(finished == 1) + #expect(tab.types?.isEmpty ?? true) + } + + /// Lets main-actor work queued by the test run, until `condition`. + private func settle(_ condition: @MainActor () -> Bool) async throws { + for _ in 0..<1_000 where !condition() { await Task.yield() } + try #require(condition()) + } + + private func settleTurns() async { + for _ in 0..<200 { await Task.yield() } + } + + @Test func whenFinishedRunsOnceWhenWebKitFinishesOrAtOnceWhenTheCommandDoesNotStart() async throws { + #expect(BrowserReplPasteboardRedirect.shared.install()) + let tab = NSPasteboard.withUniqueName() + let other = NSPasteboard.withUniqueName() + defer { + tab.releaseGlobally() + other.releaseGlobally() + } + var finished = 0 + var finish: (@MainActor () -> Void)? + let outcome = await BrowserReplPasteboardRedirect.shared.run( + on: tab, + timeout: .milliseconds(50), + grace: .seconds(60), + endWebContent: { false }, + whenFinished: { finished += 1 } + ) { done in + finish = done + } + #expect(outcome == .timedOutStillRunning) + #expect(finished == 0, "a timeout is not WebKit finishing the command") + + var busyFinished = 0 + let busy = await BrowserReplPasteboardRedirect.shared.run( + on: other, + timeout: .milliseconds(50), + endWebContent: { true }, + whenFinished: { busyFinished += 1 } + ) { done in + done() + } + #expect(busy == .busy(tab: "")) + #expect(busyFinished == 1) + + try #require(finish != nil) + finish?() + finish?() + #expect(finished == 1) + } + } + + /// WebKit's real commands, end to end: its pasteboard reads and writes + /// arrive through IPC after the command starts, from WebCore. + @MainActor + @Suite("WebKit", .serialized) + struct InWebKit { + private final class Loaded: NSObject, WKNavigationDelegate { + var continuation: CheckedContinuation? + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + continuation?.resume() + continuation = nil + } + } + + private func load(_ html: String) async -> WKWebView { + let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 400, height: 300)) + let loaded = Loaded() + webView.navigationDelegate = loaded + await withCheckedContinuation { continuation in + loaded.continuation = continuation + webView.loadHTMLString(html, baseURL: URL(string: "https://example.com/")) + } + webView.navigationDelegate = nil + return webView + } + + /// The system pasteboard is a stand-in, so a broken redirect pastes + /// the stand-in's text, never the person's clipboard. The system's + /// change count is given above the tab's, so the paste always runs + /// through WebKit (``aPasteWhoseLateReadsWebKitCouldAllowDoesNotStart`` + /// covers the other side of that check). + @Test func webKitsPasteReadsTheTabPasteboard() async throws { + let standIn = Self.makeStandIn() + defer { standIn.releaseGlobally() } + let tab = NSPasteboard.withUniqueName() + defer { tab.releaseGlobally() } + tab.clearContents() + tab.setString("tab text", forType: .string) + var outcome: BrowserReplPasteboardRedirect.Outcome? + var value: String? + var event: String? + try await Self.withStandInSystemPasteboard(standIn) { + let webView = await load( + "" + ) + _ = try await webView.evaluateJavaScript("document.getElementById('i').focus(); true") + outcome = await BrowserReplPasteboardRedirect.shared.perform( + "Paste", + in: webView, + pasteboard: tab, + timeout: .seconds(10), + systemChangeCount: tab.changeCount + 1 + ) + value = try await webView.evaluateJavaScript("document.getElementById('i').value") as? String + event = try await webView.evaluateJavaScript("window.pasted || ''") as? String + } + #expect(outcome == .completed) + // Bools, so a failure never prints the values. + let pastedTabText = value == "tab text" + let trustedEvent = event == "true:tab text" + #expect(pastedTabText, "WebKit's paste did not read the tab's pasteboard") + #expect(trustedEvent, "the page did not get a trusted paste event with the tab's data") + #expect(BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: true) == nil) + } + + /// WebKit's late-read refusal compares change counts; a Paste whose + /// tab pasteboard is not below the system's could pass it. + @Test func aPasteWhoseLateReadsWebKitCouldAllowDoesNotStart() async throws { + let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)) + let tab = NSPasteboard.withUniqueName() + defer { tab.releaseGlobally() } + tab.clearContents() + tab.setString("tab text", forType: .string) + var finished = false + let outcome = await BrowserReplPasteboardRedirect.shared.perform( + "Paste", + in: webView, + pasteboard: tab, + timeout: .seconds(1), + systemChangeCount: tab.changeCount, + whenWebKitFinishes: { finished = true } + ) + #expect(outcome == .unavailable) + #expect(finished) + #expect(BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: true) == nil) + } + + private final class Terminations: NSObject, WKNavigationDelegate { + var count = 0 + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { count += 1 } + } + + private static let slowPastePage = """ + + """ + + /// A page that keeps its paste handler running past the timeout has + /// its web content process ended at the timeout, so the handler + /// never reads anything late and WebKit's default paste never runs. + @Test func aPasteThatOutlivesTheTimeoutHasItsWebContentEnded() async throws { + let standIn = Self.makeStandIn() + defer { standIn.releaseGlobally() } + let tab = NSPasteboard.withUniqueName() + defer { tab.releaseGlobally() } + tab.clearContents() + tab.setString("tab text", forType: .string) + let terminations = Terminations() + var outcome: BrowserReplPasteboardRedirect.Outcome? + try await Self.withStandInSystemPasteboard(standIn) { + let webView = await load(Self.slowPastePage) + _ = try await webView.evaluateJavaScript("document.getElementById('i').focus(); true") + webView.navigationDelegate = terminations + outcome = await BrowserReplPasteboardRedirect.shared.perform( + "Paste", + in: webView, + pasteboard: tab, + timeout: .milliseconds(300), + systemChangeCount: tab.changeCount + 1 + ) + } + #expect(outcome == .timedOut) + #expect(terminations.count == 1, "the page that outlived the paste's timeout kept its web content process") + #expect( + BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: true) == nil, + "the redirect outlived the paste's timeout" + ) + } + + /// When the web content can no longer be ended at the timeout, the + /// redirect stays until WebKit finishes: the handler's late read gets + /// at most the tab's own clipboard, never the person's. + @Test func aPasteWhoseWebContentCannotBeEndedReadsOnlyTheTabPasteboardLate() async throws { + let standIn = NSPasteboard.withUniqueName() + defer { standIn.releaseGlobally() } + standIn.clearContents() + standIn.setString("the person's clipboard", forType: .string) + let tab = NSPasteboard.withUniqueName() + defer { tab.releaseGlobally() } + tab.clearContents() + tab.setString("tab text", forType: .string) + let systemBefore = NSPasteboard.general.changeCount + var outcome: BrowserReplPasteboardRedirect.Outcome? + var late: String? + try await Self.withStandInSystemPasteboard(standIn) { + let webView = await load(Self.slowPastePage) + _ = try await webView.evaluateJavaScript("document.getElementById('i').focus(); true") + var asked = 0 + let finished = BrowserReplLatch() + outcome = await BrowserReplPasteboardRedirect.shared.perform( + "Paste", + in: webView, + pasteboard: tab, + timeout: .milliseconds(300), + grace: .seconds(600), + systemChangeCount: tab.changeCount + 1, + mayEndWebContent: { + asked += 1 + return asked == 1 + }, + whenWebKitFinishes: { finished.signal() } + ) + #expect(BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: true) === tab) + // The handler loops 1.5 s, but a loaded machine has run it + // past 10 s; the bound only turns a hang into a failure. + let clock = ContinuousClock() + let webKitFinished = await finished.wait(until: clock.now.advanced(by: .seconds(300)), clock: clock) + try #require(webKitFinished, "WebKit did not finish the paste within 300 s") + late = try await webView.evaluateJavaScript("window.late ?? 'unset'") as? String + } + #expect(outcome == .timedOutStillRunning) + #expect(BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: true) == nil) + // Bools, so a failure never prints the values. + let readThePersonsClipboard = late == "the person's clipboard" + #expect(!readThePersonsClipboard, "a paste handler that outlived the timeout read the system pasteboard") + #expect(NSPasteboard.general.changeCount == systemBefore) + } + + /// A command whose web content could not be ended at the timeout is + /// ended one grace later regardless: the page's handler, still + /// looping, never finishes, and the redirect ends. + @Test func aPasteStillRunningAfterItsGraceHasItsWebContentEnded() async throws { + let webView = await load(Self.slowPastePage) + _ = try await webView.evaluateJavaScript("document.getElementById('i').focus(); true") + let terminations = Terminations() + webView.navigationDelegate = terminations + let tab = NSPasteboard.withUniqueName() + defer { tab.releaseGlobally() } + tab.clearContents() + tab.setString("tab text", forType: .string) + var asked = 0 + let finished = BrowserReplLatch() + let outcome = await BrowserReplPasteboardRedirect.shared.perform( + "Paste", + in: webView, + pasteboard: tab, + timeout: .milliseconds(200), + grace: .milliseconds(200), + systemChangeCount: tab.changeCount + 1, + mayEndWebContent: { + asked += 1 + return asked == 1 + }, + whenWebKitFinishes: { finished.signal() } + ) + #expect(outcome == .timedOutStillRunning) + let clock = ContinuousClock() + let ended = await finished.wait(until: clock.now.advanced(by: .seconds(60)), clock: clock) + try #require(ended, "the command was not ended within 60 s") + #expect(terminations.count == 1, "the web content outlived the command's grace") + #expect(asked == 2, "ending the web content after the grace asked the caller again") + #expect(BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: true) == nil) + } + + /// Proof for WebKit's Copy and Cut of rich content (formatting, a + /// link, an image): every write lands on the tab's pasteboard and + /// none reaches the system pasteboard by any route, including + /// `+[NSPasteboard generalPasteboard]`, which does not go through the + /// `+pasteboardWithName:` lookup the redirect hooks. + @Test(arguments: ["Copy", "Cut"]) + func webKitsCopyAndCutWriteOnlyTheTabPasteboard(command: String) async throws { + let standIn = Self.makeStandIn() + defer { standIn.releaseGlobally() } + let standInBefore = standIn.changeCount + let tab = NSPasteboard.withUniqueName() + defer { tab.releaseGlobally() } + tab.clearContents() + let systemBefore = NSPasteboard.general.changeCount + var outcome: BrowserReplPasteboardRedirect.Outcome? + try await Self.withStandInSystemPasteboard(standIn) { + let webView = await load( + """ +
bold link \ + text
+ """ + ) + _ = try await webView.evaluateJavaScript( + "const ed = document.getElementById('ed'); ed.focus(); const r = document.createRange(); r.selectNodeContents(ed); getSelection().removeAllRanges(); getSelection().addRange(r); true" + ) + outcome = await BrowserReplPasteboardRedirect.shared.perform(command, in: webView, pasteboard: tab, timeout: .seconds(10)) + } + #expect(outcome == .completed) + #expect(standIn.changeCount == standInBefore, "WebKit's \(command) wrote the system pasteboard") + #expect(NSPasteboard.general.changeCount == systemBefore, "WebKit's \(command) wrote the real system pasteboard") + let types = Set(tab.types ?? []) + #expect(types.contains(.html), "WebKit's \(command) did not write its HTML to the tab's pasteboard") + #expect(types.contains(.string), "WebKit's \(command) did not write its text to the tab's pasteboard") + let copiedTheSelection = tab.string(forType: .string)?.contains("bold link") == true + #expect(copiedTheSelection) + } + + /// Editors (Google Docs, Notion, Monaco, CodeMirror) handle Copy and + /// Cut themselves: their handler sets the event's data and cancels + /// it. WebKit then writes the pasteboard from that data, which can be + /// more than one change of the pasteboard. The command must still + /// complete with the handler's data on the tab's pasteboard, and + /// nothing may reach the system pasteboard. + @Test(arguments: ["Copy", "Cut"]) + func aCopyOrCutWhoseHandlerSetsDataAndCancelsCompletesWithTheHandlersData(command: String) async throws { + #expect(BrowserReplPasteboardRedirect.shared.install()) + let standIn = NSPasteboard.withUniqueName() + defer { standIn.releaseGlobally() } + standIn.clearContents() + standIn.setString("the person's clipboard", forType: .string) + let standInBefore = standIn.changeCount + let systemBefore = NSPasteboard.general.changeCount + var outcome: BrowserReplPasteboardRedirect.Outcome? + var plain: String? + var html: String? + var custom: String? + try await Self.withStandInSystemPasteboard(standIn) { + let webView = await load( + """ +
editor text
+ """ + ) + _ = try await webView.evaluateJavaScript( + "const ed = document.getElementById('ed'); ed.focus(); const r = document.createRange(); r.selectNodeContents(ed); getSelection().removeAllRanges(); getSelection().addRange(r); true" + ) + let tab = NSPasteboard.withUniqueName() + defer { tab.releaseGlobally() } + tab.clearContents() + outcome = await BrowserReplPasteboardRedirect.shared.perform(command, in: webView, pasteboard: tab, timeout: .seconds(10)) + plain = tab.string(forType: .string) + html = tab.string(forType: .html) + // WebKit keeps a page's custom types in its own blob type. + custom = tab.data(forType: NSPasteboard.PasteboardType("com.apple.WebKit.custom-pasteboard-data")) + .map { String(decoding: $0, as: UTF8.self) } + } + #expect(outcome == .completed, "WebKit's \(command) with a handler that sets data and cancels did not complete") + #expect(plain == "from the \(command.lowercased()) handler") + #expect(html?.contains("from the \(command.lowercased()) handler") == true) + #expect(custom?.contains("editor model") == true, "the handler's custom type did not reach the tab's clipboard") + #expect(standIn.changeCount == standInBefore, "WebKit's \(command) wrote the system pasteboard") + #expect(NSPasteboard.general.changeCount == systemBefore) + } + + /// A page whose copy or cut handler runs past the timeout and only + /// then sets its data (a hostile page planting a shell command for + /// the person's next paste in the terminal). Nothing it writes may + /// reach the system pasteboard, at any time. + /// + /// The system pasteboard is a stand-in here: every lookup of the + /// general pasteboard by name that would reach the system's gets the + /// stand-in, so a broken redirect fills the stand-in and the person's + /// clipboard stays untouched. The real one is only read (its change + /// count), which catches a write that bypasses the lookup. + @Test(arguments: ["Copy", "Cut"]) + func aCopyOrCutThePageFinishesAfterTheTimeoutNeverReachesTheSystemPasteboard(command: String) async throws { + #expect(BrowserReplPasteboardRedirect.shared.install()) + let standIn = NSPasteboard.withUniqueName() + defer { standIn.releaseGlobally() } + standIn.clearContents() + standIn.setString("the person's clipboard", forType: .string) + let standInBefore = standIn.changeCount + let systemBefore = NSPasteboard.general.changeCount + + try await Self.withStandInSystemPasteboard(standIn) { + let webView = await load( + """ + + """ + ) + _ = try await webView.evaluateJavaScript("const i = document.getElementById('i'); i.focus(); i.select(); true") + let tab = NSPasteboard.withUniqueName() + defer { tab.releaseGlobally() } + tab.clearContents() + let finished = BrowserReplLatch() + let outcome = await BrowserReplPasteboardRedirect.shared.perform( + command, + in: webView, + pasteboard: tab, + timeout: .milliseconds(300), + whenWebKitFinishes: { finished.signal() } + ) + #expect(outcome == .timedOut) + // WebKit reports the command done (or its process ended) after + // the page's writes on the same connection; one more round trip + // to the page flushes anything after that. + let clock = ContinuousClock() + let webKitFinished = await finished.wait(until: clock.now.advanced(by: .seconds(300)), clock: clock) + try #require(webKitFinished, "WebKit did not finish the command within 300 s") + _ = try? await webView.evaluateJavaScript("0") + let tabUntouched = tab.types?.isEmpty ?? true + #expect(tabUntouched, "a write after the timeout landed on the tab's pasteboard") + } + + #expect(standIn.changeCount == standInBefore, "the page's late \(command) wrote the system pasteboard") + let kept = standIn.string(forType: .string) == "the person's clipboard" + #expect(kept, "the page's late \(command) replaced the system pasteboard's contents") + #expect(NSPasteboard.general.changeCount == systemBefore, "the page's late \(command) wrote the real system pasteboard") + } + + /// WebKit's pasteboard requests do not say which web view they serve, + /// so while a Copy runs, a copy in another web view (a person's, or a + /// page's in a user's tab) also lands on the command's pasteboard. + /// The tab's clipboard must not take that: WebKit's own Copy writes + /// the pasteboard at most once, so a second write means another web + /// view wrote it, and the command does not complete. + @Test func aCopyInAnotherWebViewDuringACommandDoesNotReachTheTabClipboard() async throws { + #expect(BrowserReplPasteboardRedirect.shared.install()) + let standIn = NSPasteboard.withUniqueName() + defer { standIn.releaseGlobally() } + standIn.clearContents() + standIn.setString("the person's clipboard", forType: .string) + let systemBefore = NSPasteboard.general.changeCount + var outcome: BrowserReplPasteboardRedirect.Outcome? + try await Self.withStandInSystemPasteboard(standIn) { + let tabView = await load( + """ + + """ + ) + _ = try await tabView.evaluateJavaScript("const i = document.getElementById('i'); i.focus(); i.select(); true") + let otherView = await load("") + _ = try await otherView.evaluateJavaScript("const o = document.getElementById('o'); o.focus(); o.select(); true") + let tabProcess = tabView.value(forKey: "_webProcessIdentifier") as? Int + let otherProcess = otherView.value(forKey: "_webProcessIdentifier") as? Int + try #require(tabProcess != otherProcess, "the two web views share a web content process, so the other copy cannot run during the command") + + let tab = NSPasteboard.withUniqueName() + defer { tab.releaseGlobally() } + tab.clearContents() + let command = Task { @MainActor in + await BrowserReplPasteboardRedirect.shared.perform("Copy", in: tabView, pasteboard: tab, timeout: .seconds(30)) + } + while BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: true) !== tab { + await Task.yield() + } + // The other web view copies while the tab's copy handler runs. + // An evaluated script holds a user gesture, as a click would. + let copied = try await otherView.evaluateJavaScript("document.execCommand('copy')") as? Bool + try #require(copied == true) + outcome = await command.value + } + #expect(outcome != .completed, "the tab's clipboard took a copy another web view made during the command") + #expect(BrowserReplPasteboardRedirect.shared.redirectTarget(forLookupOf: general, fromWebKit: true) == nil) + #expect(NSPasteboard.general.changeCount == systemBefore) + } + + /// Runs `body` with `standIn` in place of the system pasteboard for + /// every lookup of the general pasteboard by name, the hook WebKit's + /// pasteboard code uses. The redirect's own hook stays underneath and + /// still answers first. + /// A private pasteboard holding "the person's clipboard", to stand in + /// for the system pasteboard. + private static func makeStandIn() -> NSPasteboard { + let standIn = NSPasteboard.withUniqueName() + standIn.clearContents() + standIn.setString("the person's clipboard", forType: .string) + return standIn + } + + /// Runs `body` with `standIn` for the system pasteboard: every lookup + /// that would return the system's (`+pasteboardWithName:` with its + /// name, past the redirect's hook, and `+generalPasteboard`) returns + /// the stand-in, so a broken redirect never reads or writes the + /// person's clipboard. + private static func withStandInSystemPasteboard( + _ standIn: NSPasteboard, + _ body: () async throws -> Void + ) async throws { + let selector = NSSelectorFromString("pasteboardWithName:") + let method = try #require(class_getClassMethod(NSPasteboard.self, selector)) + typealias Lookup = @convention(c) (AnyObject, Selector, NSString) -> NSPasteboard + let previousImplementation = method_getImplementation(method) + let previous = unsafeBitCast(previousImplementation, to: Lookup.self) + let pasteboards = StandIn(system: NSPasteboard(name: .general), standIn: standIn) + let replacement: @convention(block) @Sendable (AnyObject, NSString) -> NSPasteboard = { cls, name in + let found = previous(cls, selector, name) + return found === pasteboards.system ? pasteboards.standIn : found + } + let generalSelector = NSSelectorFromString("generalPasteboard") + let generalMethod = try #require(class_getClassMethod(NSPasteboard.self, generalSelector)) + let previousGeneral = method_getImplementation(generalMethod) + let generalReplacement: @convention(block) @Sendable (AnyObject) -> NSPasteboard = { _ in pasteboards.standIn } + method_setImplementation(method, imp_implementationWithBlock(replacement)) + method_setImplementation(generalMethod, imp_implementationWithBlock(generalReplacement)) + defer { + method_setImplementation(generalMethod, previousGeneral) + method_setImplementation(method, previousImplementation) + } + try await body() + } + + private struct StandIn: @unchecked Sendable { + let system: NSPasteboard + let standIn: NSPasteboard + } + } +} + +/// A clock that moves only when a test advances it. +final class ManualClock: Clock, @unchecked Sendable { + struct Instant: InstantProtocol { + var offset: Duration + func advanced(by duration: Duration) -> Instant { Instant(offset: offset + duration) } + func duration(to other: Instant) -> Duration { other.offset - offset } + static func < (lhs: Instant, rhs: Instant) -> Bool { lhs.offset < rhs.offset } + } + + private struct Sleeper { + let deadline: Instant + let continuation: CheckedContinuation + } + + private let lock = NSLock() + private var current = Instant(offset: .zero) + private var sleepers: [Int: Sleeper] = [:] + private var nextSleeper = 0 + + var now: Instant { lock.withLock { current } } + var minimumResolution: Duration { .zero } + + func sleep(until deadline: Instant, tolerance: Duration?) async throws { + let id = lock.withLock { () -> Int in + nextSleeper += 1 + return nextSleeper + } + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let resumeNow: Bool = lock.withLock { + if deadline <= current { return true } + sleepers[id] = Sleeper(deadline: deadline, continuation: continuation) + return false + } + if resumeNow { continuation.resume() } + } + } onCancel: { + let sleeper = lock.withLock { sleepers.removeValue(forKey: id) } + sleeper?.continuation.resume(throwing: CancellationError()) + } + } + + func advance(by duration: Duration) { + let due: [Sleeper] = lock.withLock { + current = current.advanced(by: duration) + let ids = sleepers.filter { $0.value.deadline <= current }.map(\.key) + return ids.compactMap { sleepers.removeValue(forKey: $0) } + } + for sleeper in due { sleeper.continuation.resume() } + } + + /// Waits until `count` sleeps are pending, for at most 30 s of real + /// time, so code that never sleeps fails the test instead of hanging it. + func waitForSleepers(_ count: Int) async { + let deadline = ContinuousClock.now.advanced(by: .seconds(30)) + while ContinuousClock.now < deadline { + if lock.withLock({ sleepers.count }) >= count { return } + await Task.yield() + } + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplPointerOwnerTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplPointerOwnerTests.swift new file mode 100644 index 000000000000..aaacdb0215ee --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplPointerOwnerTests.swift @@ -0,0 +1,95 @@ +import Testing + +@testable import CmuxBrowser + +@MainActor +@Suite("Browser REPL pointer owner") +struct BrowserReplPointerOwnerTests { + @Test func theOwnerAndFreePointerDoNotWait() async throws { + let pointer = BrowserReplPointerOwner(timeout: .seconds(30)) + try await pointer.waitForPointer(sessionID: "a") + pointer.pressed(sessionID: "a") + try await pointer.waitForPointer(sessionID: "a") + #expect(pointer.owner == "a") + } + + @Test func anotherSessionFailsNamingTheHolderAfterTheTimeout() async throws { + let pointer = BrowserReplPointerOwner(timeout: .milliseconds(50)) + pointer.pressed(sessionID: "holder") + let clock = ContinuousClock() + let started = clock.now + await #expect(throws: BrowserReplPointerOwner.Held(owner: "holder", timeout: .milliseconds(50))) { + try await pointer.waitForPointer(sessionID: "other", clock: clock) + } + #expect(clock.now - started < .seconds(5)) + #expect(pointer.owner == "holder") + } + + @Test func aReleaseWakesTheWaitingSession() async throws { + let pointer = BrowserReplPointerOwner(timeout: .seconds(30)) + pointer.pressed(sessionID: "holder") + let waiter = Task { @MainActor in + try await pointer.waitForPointer(sessionID: "other") + return pointer.owner + } + await Task.yield() + pointer.released(sessionID: "other") + #expect(pointer.owner == "holder", "only the owner releases") + pointer.released(sessionID: "holder") + #expect(try await waiter.value == nil) + } + + @Test func cancellingTheWaitEndsIt() async throws { + let pointer = BrowserReplPointerOwner(timeout: .seconds(30)) + pointer.pressed(sessionID: "holder") + let waiter = Task { @MainActor in + try await pointer.waitForPointer(sessionID: "other") + } + await Task.yield() + waiter.cancel() + await #expect(throws: CancellationError.self) { + try await waiter.value + } + } + + // A whole drag holds the pointer: another session's mouse input waits + // until the drag ends, so trusted drag events never interleave. + @Test func aGestureHoldsThePointerUntilItEnds() async throws { + let pointer = BrowserReplPointerOwner(timeout: .seconds(30)) + let gate = BrowserReplLatch() + let clock = ContinuousClock() + let drag = Task { @MainActor in + try await pointer.performGesture(sessionID: "dragger") { + _ = await gate.wait(until: clock.now.advanced(by: .seconds(30)), clock: clock) + return pointer.owner + } + } + while pointer.owner == nil { await Task.yield() } + #expect(pointer.owner == "dragger") + var otherReached = false + let other = Task { @MainActor in + try await pointer.waitForPointer(sessionID: "other") + otherReached = true + return pointer.owner + } + for _ in 0..<20 { await Task.yield() } + #expect(!otherReached, "another session's input waits while the drag runs") + gate.signal() + #expect(try await drag.value == "dragger") + #expect(try await other.value == nil, "the other session ran after the drag released") + } + + @Test func aGestureWaitsForAnotherSessionsPressAndReleasesWhenItFails() async throws { + let pointer = BrowserReplPointerOwner(timeout: .milliseconds(50)) + pointer.pressed(sessionID: "holder") + await #expect(throws: BrowserReplPointerOwner.Held(owner: "holder", timeout: .milliseconds(50))) { + try await pointer.performGesture(sessionID: "dragger") { Issue.record("the gesture ran while another session held the mouse") } + } + pointer.released(sessionID: "holder") + struct Failed: Error {} + await #expect(throws: Failed.self) { + try await pointer.performGesture(sessionID: "dragger") { throw Failed() } + } + #expect(pointer.owner == nil, "a failed gesture releases the pointer") + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplPopupPolicyTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplPopupPolicyTests.swift new file mode 100644 index 000000000000..a264c7aef8e5 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplPopupPolicyTests.swift @@ -0,0 +1,166 @@ +import CmuxSettings +import Foundation +import Testing + +@testable import CmuxBrowser + +/// A window a page opens from a tab a REPL session drives becomes a new tab +/// that cmux opens itself, through the navigation that trusts local files +/// and cmux's internal schemes. The page controls the URL, so it must pass +/// as an untrusted navigation first. +@Suite("Browser REPL page-opened windows") +struct BrowserReplPopupPolicyTests { + private let open = BrowserURLAllowlistPolicy(managedPatterns: nil) + + private func policy(allowed: [String]? = nil, prohibited: [String] = []) throws -> BrowserReplDomainPolicy { + var policy = BrowserReplDomainPolicy() + policy.allowed = try allowed?.map { try BrowserReplDomainPattern.parse($0, title: "t") } + policy.prohibited = try prohibited.map { try BrowserReplDomainPattern.parse($0, title: "t") } + return policy + } + + @Test("Local files and cmux's internal schemes never open from a page, with or without a policy", + arguments: [ + "file:///etc/passwd", + "file://localhost/Users/me/.ssh/id_ed25519", + "data:text/html,", + "javascript:alert(1)", + "cmux-diff-viewer://session/index.html", + "cmux-browser-action://open", + "applewebdata://x/y", + "about:srcdoc", + "ftp://example.com/file", + ]) + func localAndInternalSchemesAreRefused(_ raw: String) throws { + let url = try #require(URL(string: raw)) + #expect(BrowserReplDomainPolicy().popupBlockReason(url, allowlist: open) != nil, "\(raw) would open") + #expect(try policy(allowed: ["*"]).popupBlockReason(url, allowlist: open) != nil, "\(raw) would open under allowedDomains *") + } + + @Test("Web pages open unless the creating session's policy or the URL allowlist blocks them") + func webPagesFollowThePolicyAndAllowlist() throws { + let page = try #require(URL(string: "https://docs.example.com/a")) + #expect(BrowserReplDomainPolicy().popupBlockReason(page, allowlist: open) == nil) + #expect(BrowserReplDomainPolicy().popupBlockReason(URL(string: "about:blank"), allowlist: open) == nil) + #expect(BrowserReplDomainPolicy().popupBlockReason(nil, allowlist: open) == nil) + let prohibiting = try policy(prohibited: ["docs.example.com"]) + #expect(prohibiting.popupBlockReason(page, allowlist: open) != nil) + let allowing = try policy(allowed: ["example.org"]) + #expect(allowing.popupBlockReason(page, allowlist: open) != nil) + #expect(allowing.popupBlockReason(URL(string: "https://example.org/x"), allowlist: open) == nil) + let managed = BrowserURLAllowlistPolicy(managedPatterns: ["example.org"]) + #expect(BrowserReplDomainPolicy().popupBlockReason(page, allowlist: managed) != nil) + } + + @Test("A blob: window opens only for an origin the policy allows") + func blobURLsFollowTheirOrigin() throws { + let blob = try #require(URL(string: "blob:https://docs.example.com/6f1c")) + #expect(BrowserReplDomainPolicy().popupBlockReason(blob, allowlist: open) == nil) + #expect(try policy(prohibited: ["docs.example.com"]).popupBlockReason(blob, allowlist: open) != nil) + #expect(BrowserReplDomainPolicy().popupBlockReason(URL(string: "blob:null/6f1c"), allowlist: open) != nil) + } +} + +/// Where a page-opened window goes depends on who owns the opener tab: only +/// a tab a session created hands its popups to the sessions. A user's tab +/// that a session drives (`tabs.use`) keeps its popups, so a session never +/// adopts, and at its end closes, a window the user's page opened. +@Suite("Browser REPL popup routing") +struct BrowserReplPopupRouteTests { + private let open = BrowserURLAllowlistPolicy(managedPatterns: nil) + + @Test("A popup of a user's tab goes to the browser, whatever its URL", + arguments: ["https://docs.example.com/a", "about:blank", "file:///etc/passwd"]) + func userTabPopupsStayWithTheBrowser(_ raw: String) throws { + let route = BrowserReplPopupRoute( + url: URL(string: raw), + openerCreatedBySession: false, + creatorPolicy: BrowserReplDomainPolicy(), + allowlist: open + ) + #expect(route == .browser, "\(raw) from a user's tab went to \(route)") + } + + @Test("A popup of a session's tab goes to the session when allowed, and nowhere otherwise") + func sessionTabPopupsFollowThePolicy() throws { + var prohibiting = BrowserReplDomainPolicy() + prohibiting.prohibited = [try BrowserReplDomainPattern.parse("evil.example", title: "t")] + let allowed = BrowserReplPopupRoute( + url: URL(string: "https://docs.example.com/a"), + openerCreatedBySession: true, + creatorPolicy: prohibiting, + allowlist: open + ) + #expect(allowed == .session) + let blocked = BrowserReplPopupRoute( + url: URL(string: "https://evil.example/a"), + openerCreatedBySession: true, + creatorPolicy: prohibiting, + allowlist: open + ) + guard case .refused = blocked else { + Issue.record("a blocked popup of a session's tab went to \(blocked)") + return + } + let local = BrowserReplPopupRoute( + url: URL(string: "file:///etc/passwd"), + openerCreatedBySession: true, + creatorPolicy: BrowserReplDomainPolicy(), + allowlist: open + ) + guard case .refused = local else { + Issue.record("a local file popup of a session's tab went to \(local)") + return + } + } + + // An agent's click in a user's tab that opens a window must not put a + // key popup window over the user's work (BrowserPopupWindowController + // makes it key), and the agent could not reach it. While the session's + // input runs, the window opens as a background tab for that session and + // stays the user's. + @Test("A popup the session's own input opens in a user's tab goes to that session") + func userTabPopupsFromTheSessionsInputGoToIt() throws { + let route = BrowserReplPopupRoute( + url: URL(string: "https://docs.example.com/a"), + openerCreatedBySession: false, + creatorPolicy: BrowserReplDomainPolicy(), + inputSession: (id: "agent", policy: BrowserReplDomainPolicy()), + allowlist: open + ) + #expect(route == .inputSession("agent")) + var prohibiting = BrowserReplDomainPolicy() + prohibiting.prohibited = [try BrowserReplDomainPattern.parse("evil.example", title: "t")] + let blocked = BrowserReplPopupRoute( + url: URL(string: "https://evil.example/a"), + openerCreatedBySession: false, + creatorPolicy: BrowserReplDomainPolicy(), + inputSession: (id: "agent", policy: prohibiting), + allowlist: open + ) + guard case .refused = blocked else { + Issue.record("a popup the session's policy blocks went to \(blocked)") + return + } + let local = BrowserReplPopupRoute( + url: URL(string: "file:///etc/passwd"), + openerCreatedBySession: false, + creatorPolicy: BrowserReplDomainPolicy(), + inputSession: (id: "agent", policy: BrowserReplDomainPolicy()), + allowlist: open + ) + guard case .refused = local else { + Issue.record("a local file popup went to \(local)") + return + } + // Without the session's input the user's page keeps its windows. + let own = BrowserReplPopupRoute( + url: URL(string: "https://docs.example.com/a"), + openerCreatedBySession: false, + creatorPolicy: BrowserReplDomainPolicy(), + inputSession: nil, + allowlist: open + ) + #expect(own == .browser) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplPublicSuffixListTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplPublicSuffixListTests.swift new file mode 100644 index 000000000000..bc8e33e0d860 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplPublicSuffixListTests.swift @@ -0,0 +1,40 @@ +import Testing + +@testable import CmuxBrowser + +@Suite("Browser REPL public suffix list") +struct BrowserReplPublicSuffixListTests { + @Test(arguments: [ + ("www.example.com", "example.com"), + ("a.b.example.co.uk", "example.co.uk"), + ("x.co.at", "x.co.at"), + ("a.x.co.at", "x.co.at"), + ("co.at", "co.at"), + ("ada.github.io", "ada.github.io"), + ("a.b.ck", "a.b.ck"), + ("www.ck", "www.ck"), + ("foo.bar.unlisted", "foo.bar.unlisted"), + ("localhost", "localhost"), + ("127.0.0.1", "127.0.0.1"), + ("[::1]", "[::1]"), + (".docs.google.com", "google.com"), + ("WWW.Example.COM.", "example.com"), + ("www.食狮.公司.cn", "xn--85x722f.xn--55qx5d.cn"), + ]) + func sitesOfTheSystemList(host: String, site: String) { + #expect(BrowserReplPublicSuffixList.system.site(of: host) == site) + } + + @Test func withoutTheSystemListEveryHostIsItsOwnSite() { + let list = BrowserReplPublicSuffixList(isPublicSuffix: { _ in false }) + #expect(list.site(of: "a.b.example.com") == "a.b.example.com") + #expect(list.site(of: "localhost") == "localhost") + } + + @Test func theSessionAnswersSitesFromTheList() throws { + let boundary = BrowserReplBoundary(publicSuffixes: BrowserReplPublicSuffixList(isPublicSuffix: { ["at", "co.at"].contains($0) })) + let (result, updated) = boundary.policyOperation("site", ["host": "shop.x.co.at"]) + #expect(try result.get() as? String == "x.co.at") + #expect(updated == nil) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplRoutedRequestsTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplRoutedRequestsTests.swift new file mode 100644 index 000000000000..e3be23ff9400 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplRoutedRequestsTests.swift @@ -0,0 +1,36 @@ +import Testing + +@testable import CmuxBrowser + +/// A dialog or file chooser routed to one session of a shared tab is that +/// session's to answer. +@Suite("Browser REPL routed requests") +struct BrowserReplRoutedRequestsTests { + @Test func onlyTheOwningSessionAnswers() { + var requests = BrowserReplRoutedRequests() + requests.add(id: "d1", owner: "owner", respond: "answer") + #expect(requests.take(id: "d1", sessionID: "other") == nil) + #expect(!requests.isEmpty, "a refused answer leaves the request open") + #expect(requests.take(id: "d1", sessionID: "owner") == "answer") + #expect(requests.take(id: "d1", sessionID: "owner") == nil, "answered once") + #expect(requests.isEmpty) + } + + @Test func aLeavingSessionGivesBackOnlyItsOwnRequests() { + var requests = BrowserReplRoutedRequests() + requests.add(id: "d1", owner: "a", respond: "a1") + requests.add(id: "d2", owner: "b", respond: "b1") + requests.add(id: "d3", owner: "a", respond: "a2") + #expect(requests.removeAll(ownedBy: "a").sorted() == ["a1", "a2"]) + #expect(requests.take(id: "d2", sessionID: "b") == "b1") + #expect(requests.isEmpty) + } + + @Test func removingEverythingReturnsEveryResponder() { + var requests = BrowserReplRoutedRequests() + requests.add(id: "c1", owner: "a", respond: 1) + requests.add(id: "c2", owner: "b", respond: 2) + #expect(requests.removeAll().sorted() == [1, 2]) + #expect(requests.isEmpty) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplRuntimeBundleTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplRuntimeBundleTests.swift new file mode 100644 index 000000000000..a99a2a8e75fa --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplRuntimeBundleTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing +@testable import CmuxBrowser + +@Suite("Browser REPL runtime bundle") +struct BrowserReplRuntimeBundleTests { + private func makeDirectory(files: [String: String]) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-repl-bundle-\(UUID().uuidString)", isDirectory: true) + for (name, contents) in files { + let url = directory.appendingPathComponent(name) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(contents.utf8).write(to: url) + } + return directory + } + + @Test("Scripts load in manifest order") + func loadsInManifestOrder() throws { + let directory = try makeDirectory(files: [ + "manifest.json": #"{ "repl": ["b.js", "vendor/a.js"], "agent": ["agent.js"] }"#, + "vendor/a.js": "a", + "b.js": "b", + "agent.js": "agent", + ]) + defer { try? FileManager.default.removeItem(at: directory) } + + let bundle = try BrowserReplRuntimeBundle.load(from: directory) + #expect(bundle.replScripts == [.init(name: "b.js", source: "b"), .init(name: "vendor/a.js", source: "a")]) + #expect(bundle.agentScripts == [.init(name: "agent.js", source: "agent")]) + } + + @Test("A missing manifest is an error") + func missingManifest() throws { + let directory = try makeDirectory(files: ["runtime-core.js": "x"]) + defer { try? FileManager.default.removeItem(at: directory) } + + #expect(throws: BrowserReplRuntimeBundleError.manifestMissing(path: directory.appendingPathComponent("manifest.json").path)) { + try BrowserReplRuntimeBundle.load(from: directory) + } + } + + @Test("A manifest that is not an object of two lists is an error") + func invalidManifest() throws { + let directory = try makeDirectory(files: ["manifest.json": #"["runtime-core.js"]"#]) + defer { try? FileManager.default.removeItem(at: directory) } + + #expect(throws: BrowserReplRuntimeBundleError.manifestInvalid(path: directory.appendingPathComponent("manifest.json").path)) { + try BrowserReplRuntimeBundle.load(from: directory) + } + } + + @Test("A listed file that is missing, or outside the directory, is an error") + func missingScript() throws { + let directory = try makeDirectory(files: [ + "manifest.json": #"{ "repl": ["api.js"], "agent": ["../escape.js"] }"#, + "api.js": "api", + ]) + defer { try? FileManager.default.removeItem(at: directory) } + + #expect(throws: BrowserReplRuntimeBundleError.scriptMissing( + name: "../escape.js", + path: directory.appendingPathComponent("../escape.js").path + )) { + try BrowserReplRuntimeBundle.load(from: directory) + } + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplSecretRedactionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplSecretRedactionTests.swift new file mode 100644 index 000000000000..c9738d826585 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplSecretRedactionTests.swift @@ -0,0 +1,110 @@ +import Foundation +import Testing + +@testable import CmuxBrowser + +/// Redaction of secret values on the native side for the forms the boundary +/// tests do not cover: generated TOTP codes, binary fetch bodies and files +/// read back through `fs`. +@Suite("Browser REPL secret redaction", .serialized) +struct BrowserReplSecretRedactionTests { + private static let value = "v4lue-xyz-7731" + /// RFC 6238's test key, base32. + private static let totpSeed = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + + private func makeSession(_ driver: ScriptedPageDriver, cwd: String = FileManager.default.temporaryDirectory.path) -> BrowserReplSession? { + guard let bundle = try? browserReplRepositoryBundle() else { return nil } + return BrowserReplSession(id: "redaction-\(UUID().uuidString)", cwd: cwd, bundle: bundle, driver: driver) + } + + private func run(_ session: BrowserReplSession, _ code: String) async -> BrowserReplEvalResult? { + await browserReplWithDeadline(seconds: 60) { await session.evaluate(code: code, timeout: .seconds(30)) } + } + + private func spelled(_ text: String) -> String { + text.map(String.init).joined(separator: " ") + } + + @Test("A generated TOTP code is redacted from results and masked in captures while it is valid") + func totpCodeIsRedactedAndMasked() async throws { + let driver = ScriptedPageDriver() + let code = currentCode() + driver.pageValue = ["text": "code \(code) sent", "number": Int(code) ?? 0] + let session = try #require(makeSession(driver)) + defer { session.close() } + let result = await run(session, """ + secrets.set("otp", "\(Self.totpSeed)", { domains: ["example.com"], totp: true }); + await page.goto("https://example.com/login"); + const read = await page.evaluate(() => 1); + console.log(JSON.stringify(read).split("").join(" ")); + console.log(read.text.includes(""), String(read.number).includes("")); + await page.screenshot().catch(() => {}); + """) + let output = result?.lines.map(\.text).joined(separator: "\n") ?? "" + #expect(result?.error == nil, "\(result?.error ?? "")") + #expect(!output.contains(spelled(code)), "\(output)") + #expect(output.contains("true true"), "\(output)") + let masks = driver.params("tab.screenshot").first?["secretMasks"] as? [[String: Any]] ?? [] + let masked = masks.filter { ($0["value"] as? String) == code } + #expect(!masked.isEmpty, "\(masks.map { $0["value"] ?? "" })") + #expect(masked.allSatisfy { (($0["domains"] as? [[String: Any]]) ?? []).contains { $0["host"] as? String == "example.com" } }) + } + + @Test("A secret in a binary fetch body is redacted before JavaScript sees the bytes") + func binaryFetchBodyIsRedacted() async throws { + let value = Self.value + let server = try BrowserReplTestHTTPServer { _, _, _ in + var body = Data([0xff, 0x00, 0xfe]) + body.append(Data(value.utf8)) + body.append(Data([0x00, 0x80])) + return (200, ["Content-Type": "application/octet-stream"], body) + } + try await server.start() + defer { server.stop() } + let session = try #require(makeSession(ScriptedPageDriver())) + defer { session.close() } + let result = await run(session, """ + secrets.set("k", "\(value)", { domains: ["example.com"] }); + const bytes = new Uint8Array(await (await fetch("http://127.0.0.1:\(server.port)/blob")).arrayBuffer()); + const text = Array.from(bytes, (b) => String.fromCharCode(b)).join(""); + console.log(text.split("").join(" ")); + console.log("masked", text.includes(""), bytes[0], bytes[bytes.length - 1]); + """) + let output = result?.lines.map(\.text).joined(separator: "\n") ?? "" + #expect(result?.error == nil, "\(result?.error ?? "")") + #expect(!output.contains(spelled(value)), "\(output)") + #expect(output.contains("masked true 255 128"), "\(output)") + } + + @Test("fs.readFile returns a loaded secret's value masked, from text and from bytes") + func readFileIsRedacted() async throws { + let work = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-repl-redaction-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: work, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: work) } + try Data(#"{"example.com":{"pw":"\#(Self.value)"}}"#.utf8).write(to: work.appendingPathComponent("secrets.json")) + var blob = Data([0xff, 0x00]) + blob.append(Data(Self.value.utf8)) + blob.append(Data([0x80])) + try blob.write(to: work.appendingPathComponent("blob.bin")) + let session = try #require(makeSession(ScriptedPageDriver(), cwd: work.path)) + defer { session.close() } + let result = await run(session, """ + const fs = await import("node:fs"); + secrets.load("./secrets.json"); + const text = fs.readFileSync("./secrets.json", "utf8"); + console.log(text.split("").join(" ")); + const bytes = fs.readFileSync("./blob.bin"); + const latin = Array.from(bytes, (b) => String.fromCharCode(b)).join(""); + console.log(latin.split("").join(" ")); + console.log("masked", text.includes(""), latin.includes(""), bytes[0], bytes[bytes.length - 1]); + """) + let output = result?.lines.map(\.text).joined(separator: "\n") ?? "" + #expect(result?.error == nil, "\(result?.error ?? "")") + #expect(!output.contains(spelled(Self.value)), "\(output)") + #expect(output.contains("masked true true 255 128"), "\(output)") + } + + private func currentCode() -> String { + BrowserReplSecretStore.totp(key: BrowserReplSecretStore.base32Decode(Self.totpSeed) ?? Data(), time: Date().timeIntervalSince1970) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplSecretTargetTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplSecretTargetTests.swift new file mode 100644 index 000000000000..c2895ff5570c --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplSecretTargetTests.swift @@ -0,0 +1,125 @@ +import AppKit +import Testing +import WebKit + +@testable import CmuxBrowser + +/// A secret is typed only into a frame whose document is on the secret's +/// domains. A frame keeps its id when it navigates, so the frame tree can +/// name a document the frame no longer shows; the check must judge the +/// document that holds the focus, not that record. +@MainActor +@Suite("Browser REPL secret typing target", .serialized) +struct BrowserReplSecretTargetTests { + /// Collects each frame's `WKFrameInfo` as its document posts its name. + private final class Frames: NSObject, WKScriptMessageHandler, WKNavigationDelegate { + var infos: [String: WKFrameInfo] = [:] + var finished = false + private var waiters: [(ready: () -> Bool, continuation: CheckedContinuation)] = [] + + func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { + if let name = message.body as? String { infos[name] = message.frameInfo } + wake() + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + finished = true + wake() + } + + func wait(until ready: @escaping () -> Bool) async { + if ready() { return } + await withCheckedContinuation { waiters.append((ready, $0)) } + } + + private func wake() { + let (done, pending) = (waiters.filter { $0.ready() }, waiters.filter { !$0.ready() }) + waiters = pending + done.forEach { $0.continuation.resume() } + } + } + + private let frames = Frames() + private let world = WKContentWorld.world(name: "cmux-secret-target-test") + + private func load(_ body: String, posting names: [String]) async -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.userContentController.add(frames, name: "frame") + let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), configuration: configuration) + webView.navigationDelegate = frames + webView.loadHTMLString("\(body)", baseURL: URL(string: "https://example.test/")) + let frames = frames + await frames.wait { frames.finished && names.allSatisfy { frames.infos[$0] != nil } } + return webView + } + + private func frame(_ id: String, _ info: WKFrameInfo, parent: String?) -> BrowserReplFrame { + BrowserReplFrame(frameID: id, parentFrameID: parent, indexInParent: 0, info: info, url: "", name: "", crossOrigin: false) + } + + /// A web view outside a window never has the system focus, so these + /// tests count a document's focused field as the focus. + private func target() throws -> BrowserReplSecretTarget { + var target = BrowserReplSecretTarget( + name: "pw", + domains: [try BrowserReplDomainPattern.parse("example.test", title: "test").json], + world: world + ) + target.focusProbe = #"const el = document.activeElement; return !!el && el.tagName === "INPUT";"# + return target + } + + private static let focusedField = #""# + + @Test func aFocusedFieldOnTheDomainTakesTheSecret() async throws { + let webView = await load(#""#, posting: ["main", "child"]) + let main = try #require(frames.infos["main"]) + let child = try #require(frames.infos["child"]) + try await target().check(in: webView, frames: [frame("1", main, parent: nil), frame("2", child, parent: "1")]) + } + + /// The frame tree was read while the frame showed a page on the domain; + /// the frame then navigated to a page of another origin with a focused + /// field. The secret must not be typed there. + @Test func aFrameThatNavigatedSinceTheTreeReadIsJudgedByItsDocument() async throws { + let webView = await load(#""#, posting: ["main", "child"]) + let main = try #require(frames.infos["main"]) + let stale = try #require(frames.infos["child"]) + _ = try await webView.callAsyncJavaScript(""" + const child = document.getElementById('child'); + child.removeAttribute('srcdoc'); + child.src = 'data:text/html,"#, posting: ["main", "child"]) + let main = try #require(frames.infos["main"]) + let child = try #require(frames.infos["child"]) + let target = SendableBox(try target()) + let tree = SendableBox([frame("1", main, parent: nil), frame("2", child, parent: "1")]) + let view = SendableBox(webView) + // The page's web process runs no other script for 8 s, past the + // 5 s bound. + BrowserReplFrameGateTests.startBusyLoop(in: webView, seconds: 8) + let error = await browserReplWithDeadline(seconds: 20) { @MainActor in + await BrowserReplFrameGateTests.error { + try await target.value.check(in: view.value, frames: tree.value) + } + } + #expect(error??.code == "stale", "a focus probe that did not answer hung or passed: \(String(describing: error))") + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplSessionLifecycleTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplSessionLifecycleTests.swift new file mode 100644 index 000000000000..74c7ef3b078f --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplSessionLifecycleTests.swift @@ -0,0 +1,435 @@ +import Foundation +import Testing + +@testable import CmuxBrowser + +/// A runtime that hands the cell the raw native host, so a cell can call +/// `native.setTimer` and the driver directly. +private let lifecycleRuntime = #""" +const pending = new Map(); +let nextCall = 1; +const timers = new Map(); +globalThis.__cmuxHostOnResult = (id, error, result) => { + const p = pending.get(id); pending.delete(id); + if (!p) return; + if (error) p.reject(new Error(JSON.parse(error).message)); else p.resolve(JSON.parse(result)); +}; +globalThis.__cmuxHostOnTimer = (id) => { const t = timers.get(id); if (t) { timers.delete(id); t(); } }; +const native = __cmuxNative; +const call = (method, params) => new Promise((resolve, reject) => { + const id = nextCall++; pending.set(id, { resolve, reject }); + native.driverCall(id, method, JSON.stringify(params ?? {})); +}); +const timer = (id, ms) => new Promise((r) => { timers.set(id, r); native.setTimer(id, ms, false); }); +const console = { log: (...a) => native.print("log", a.map(String).join(" ")) }; +const AsyncFunction = (async () => {}).constructor; +globalThis.__cmuxFormatError = (e) => `${e.name}: ${e.message}`; +globalThis.__cmuxReplEval = (code) => new AsyncFunction("console", "call", "timer", "native", code)(console, call, timer, native); +"""# + +@Suite("Browser REPL session lifecycle") +struct BrowserReplSessionLifecycleTests { + private func makeSession( + driver: any BrowserReplDriver, + bundle: BrowserReplRuntimeBundle? = nil + ) -> BrowserReplSession { + BrowserReplSession( + id: "lifecycle-\(UUID().uuidString)", + cwd: FileManager.default.temporaryDirectory.path, + bundle: bundle ?? BrowserReplRuntimeBundle( + replScripts: [.init(name: "lifecycle.js", source: lifecycleRuntime)], + agentScripts: [] + ), + driver: driver + ) + } + + @Test("Timer delays that are huge, negative, NaN or infinite are clamped instead of trapping") + func outOfRangeTimerDelays() async { + let session = makeSession(driver: RecordingReplDriver()) + defer { session.close() } + + let result = await browserReplWithDeadline(seconds: 20) { + await session.evaluate(code: """ + native.setTimer(101, 1e20, false); + native.setTimer(102, Infinity, true); + native.setTimer(103, -Infinity, false); + native.setTimer(104, Number.MAX_VALUE, false); + await timer(105, NaN); + await timer(106, -5); + console.log("fired"); + """) + } + #expect(result?.error == nil) + #expect(result?.lines == [BrowserReplOutputLine(level: "log", text: "fired")]) + } + + @Test("A synchronous infinite loop times out and the session stays usable") + func synchronousLoopTimesOut() async { + let session = makeSession(driver: RecordingReplDriver()) + defer { session.close() } + + let hung = await browserReplWithDeadline(seconds: 20) { + await session.evaluate(code: "while (true) {}", timeout: .milliseconds(300)) + } + #expect(hung?.error?.contains("timed out") == true) + + let next = await browserReplWithDeadline(seconds: 20) { + await session.evaluate(code: "console.log('alive');", timeout: .seconds(10)) + } + #expect(next?.lines == [BrowserReplOutputLine(level: "log", text: "alive")]) + } + + @Test("A cell that never settles is cancelled at its timeout and later cells run (real runtime)") + func hungCellDoesNotBlockLaterCells() async throws { + let session = makeSession(driver: RecordingReplDriver(), bundle: try browserReplRepositoryBundle()) + defer { session.close() } + + let hung = await browserReplWithDeadline(seconds: 30) { + await session.evaluate(code: "await new Promise(() => {});", timeout: .milliseconds(300)) + } + #expect(hung?.error?.contains("timed out") == true) + + let next = await browserReplWithDeadline(seconds: 30) { + await session.evaluate(code: "1 + 1", timeout: .seconds(10)) + } + #expect(next?.error == nil) + #expect(next?.lines.map(\.text) == ["2"]) + } + + @Test("A synchronous loop in the real runtime times out and later cells run") + func realRuntimeLoopTimesOut() async throws { + let session = makeSession(driver: RecordingReplDriver(), bundle: try browserReplRepositoryBundle()) + defer { session.close() } + + let hung = await browserReplWithDeadline(seconds: 30) { + await session.evaluate(code: "await 0; for (;;) {}", timeout: .milliseconds(300)) + } + #expect(hung?.error?.contains("timed out") == true) + + let next = await browserReplWithDeadline(seconds: 30) { + await session.evaluate(code: "const x = 20; x + 1", timeout: .seconds(10)) + } + #expect(next?.error == nil) + #expect(next?.lines.map(\.text) == ["21"], "\(String(describing: next))") + } + + @Test("A cell terminated with a tab update pending leaves later calls on that tab usable") + func terminatedCellLeavesTabUsable() async throws { + let driver = ScriptedPageDriver() + let session = makeSession(driver: driver, bundle: try browserReplRepositoryBundle()) + defer { session.close() } + + let opened = await browserReplWithDeadline(seconds: 30) { + await session.evaluate(code: "await page.goto('https://example.com/login')", timeout: .seconds(10)) + } + #expect(opened?.error == nil) + // Inside a microtask drain, adding a dialog listener queues a + // tab.handleEvents update; terminating the loop drops that job. + let hung = await browserReplWithDeadline(seconds: 30) { + await session.evaluate(code: "await 0; page.on('dialog', () => {}); for (;;) {}", timeout: .milliseconds(300)) + } + #expect(hung?.error?.contains("timed out") == true) + + let next = await browserReplWithDeadline(seconds: 40) { + await session.evaluate(code: "await page.title()", timeout: .seconds(15)) + } + #expect(next?.error == nil, "\(String(describing: next?.error))") + #expect(next?.lines.map(\.text) == ["Login"]) + } + + @Test("In JavaScriptCore, a timed-out cell's late output is dropped and its functions still print") + func cancelledCellOutputInJavaScriptCore() async throws { + let session = makeSession(driver: RecordingReplDriver(), bundle: try browserReplRepositoryBundle()) + defer { session.close() } + let hung = await browserReplWithDeadline(seconds: 30) { + await session.evaluate( + code: "function hello() { console.log('hello'); } globalThis.lateDone = new Promise((done) => setTimeout(() => { console.log('late from cell 1'); done(); }, 400)); await new Promise(() => {})", + timeout: .milliseconds(200) + ) + } + #expect(hung?.error?.contains("timed out") == true) + let next = await browserReplWithDeadline(seconds: 30) { + // Cell 2 runs until cell 1's timer has printed. + await session.evaluate(code: "hello(); await lateDone; console.log('cell 2')", timeout: .seconds(10)) + } + #expect(next?.error == nil) + #expect(next?.lines.map(\.text) == ["hello", "cell 2"]) + } + + @Test("close() cancels in-flight driver calls and the evaluation returns") + func closeCancelsInFlightDriverCalls() async { + let driver = GatedReplDriver() + let session = makeSession(driver: driver) + + let evaluation = Task { await session.evaluate(code: "await call('slow');", timeout: .seconds(30)) } + await driver.waitUntilSlowCallStarted() + session.close() + let result = await browserReplWithDeadline(seconds: 20) { await evaluation.value } + #expect(result?.error?.contains("closed") == true) + + driver.release() + // The call's task observes the cancellation once it resumes. + for _ in 0..<200 where driver.cancelledAfterRelease.isEmpty { + try? await Task.sleep(for: .milliseconds(10)) + } + #expect(driver.cancelledAfterRelease == [true]) + } + + @Test("Evaluations racing close() always return and never re-attach the driver") + func evaluateRacingClose() async { + for _ in 0..<200 { + let driver = GatedReplDriver() + let session = makeSession(driver: driver) + _ = await session.evaluate(code: "1") + let attachedBefore = driver.attachCount + async let racing = browserReplWithDeadline(seconds: 10) { await session.evaluate(code: "1") } + session.close() + let result = await racing + #expect(result != nil) + #expect(driver.attachCount == attachedBefore) + if result == nil { break } + } + } +} + +@Suite("Browser REPL fetcher lifecycle") +struct BrowserReplFetcherLifecycleTests { + @Test("A fetch after invalidate() fails with closed instead of crashing") + func fetchAfterInvalidate() async { + let fetcher = BrowserReplFetcher(driver: RecordingReplDriver()) + fetcher.invalidate() + let result = await fetcher.fetch(requestJSON: #"{"url":"http://127.0.0.1:9/"}"#) + guard case .failure(let error) = result else { + Issue.record("expected a failure, got \(result)") + return + } + #expect(error.code == "closed") + } + + @Test("invalidate() during the cookie lookup fails the fetch instead of crashing") + func invalidateDuringCookieLookup() async { + let driver = CookieGateDriver() + let fetcher = BrowserReplFetcher(driver: driver) + let fetch = Task { await fetcher.fetch(requestJSON: #"{"url":"http://127.0.0.1:9/"}"#) } + await driver.waitForCookieLookup() + fetcher.invalidate() + driver.release() + let result = await fetch.value + guard case .failure(let error) = result else { + Issue.record("expected a failure, got \(result)") + return + } + #expect(error.code == "closed") + } +} + +/// Holds `cookies.get` until `release()`. +private final class CookieGateDriver: BrowserReplDriver, @unchecked Sendable { + private let lock = NSLock() + private var lookup: CheckedContinuation? + private var lookupStarted = false + private var startWaiter: CheckedContinuation? + private var released = false + + var capabilities: [String] { [] } + + func call(method: String, paramsJSON: String) async -> Result { + guard method == "cookies.get" else { return .success("null") } + let waiter: CheckedContinuation? = lock.withLock { + lookupStarted = true + defer { startWaiter = nil } + return startWaiter + } + waiter?.resume() + await withCheckedContinuation { (continuation: CheckedContinuation) in + lock.lock() + if released { + lock.unlock() + continuation.resume() + } else { + lookup = continuation + lock.unlock() + } + } + return .success("[]") + } + + func waitForCookieLookup() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + lock.lock() + if lookupStarted { + lock.unlock() + continuation.resume() + } else { + startWaiter = continuation + lock.unlock() + } + } + } + + func release() { + let pending: CheckedContinuation? = lock.withLock { + released = true + defer { lookup = nil } + return lookup + } + pending?.resume() + } + + func attach(eventSink: @escaping BrowserReplDriverEventSink) {} + func detach() {} +} + +/// A cell that arms a timer whose callback never returns, and evaluates to +/// the timer's id. The hour never elapses in a test: the test fires the +/// timer itself through the session's timer entry point, so nothing waits +/// on wall-clock time. +private let armRunawayTimerCell = "setTimeout(() => { for (;;) {} }, 3600000)" + +/// Sleeps on the continuous clock and reports each sleep it starts. The +/// session starts a cell's timeout right after it made the cell current +/// and queued it on its thread, so a test learns from this when that has +/// happened. +private final class ReportingSleeper: BrowserReplSleeping, @unchecked Sendable { + private let lock = NSLock() + private var started: [Duration] = [] + private var waiters: [(duration: Duration, continuation: CheckedContinuation)] = [] + + func sleep(for duration: Duration) async throws { + let ready: [CheckedContinuation] = lock.withLock { + started.append(duration) + let matched = waiters.filter { $0.duration == duration }.map(\.continuation) + waiters.removeAll { $0.duration == duration } + return matched + } + for waiter in ready { waiter.resume() } + try await ContinuousClock().sleep(for: duration) + } + + /// Returns once a sleep of `duration` has started. + func waitUntilSleeping(for duration: Duration) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let now: Bool = lock.withLock { + if started.contains(duration) { return true } + waiters.append((duration, continuation)) + return false + } + if now { continuation.resume() } + } + } +} + +/// Agent code runs in the session's JavaScript context and can reach any +/// object there. Every JavaScript run on the session's thread, not only a +/// cell, is bounded, and closing (`cmux browser repl reset`) always ends it. +@Suite("Browser REPL session watchdog") +struct BrowserReplSessionWatchdogTests { + private func makeSession( + callbackTimeLimit: Duration = BrowserReplSession.defaultCallbackTimeLimit, + sleeper: any BrowserReplSleeping = BrowserReplClockSleeper(clock: ContinuousClock()) + ) throws -> BrowserReplSession { + BrowserReplSession( + id: "watchdog-\(UUID().uuidString)", + cwd: FileManager.default.temporaryDirectory.path, + bundle: try browserReplRepositoryBundle(), + driver: RecordingReplDriver(), + sleeper: sleeper, + callbackTimeLimit: callbackTimeLimit + ) + } + + /// Runs ``armRunawayTimerCell`` and returns the timer's id. + private func armRunawayTimer(in session: BrowserReplSession) async throws -> Int { + let armed = await browserReplWithDeadline(seconds: 30) { + await session.evaluate(code: armRunawayTimerCell, timeout: .seconds(20)) + } + #expect(armed?.error == nil) + return try #require(armed?.lines.first.flatMap { Int($0.text) }, "\(String(describing: armed))") + } + + @Test("A cell cannot call the app's entry points or reach the runtime's constructors") + func entryPointsAreNotInCellScope() async throws { + let session = try makeSession() + defer { session.close() } + let result = await browserReplWithDeadline(seconds: 30) { + await session.evaluate(code: """ + [typeof __cmuxReplEval, typeof __cmuxReplCancel, typeof __cmuxHostOnResult, typeof __cmuxHostOnTimer, + typeof __cmuxHostOnEvent, typeof __cmuxFormatError, typeof __cmuxNative, typeof CmuxBrowserRepl].join(",") + """, timeout: .seconds(20)) + } + #expect(result?.error == nil) + #expect(result?.lines.map(\.text) == [Array(repeating: "undefined", count: 8).joined(separator: ",")]) + } + + @Test("A timer callback that never returns after its cell ended is stopped, and the next cell runs") + func runawayCallbackOutsideACellIsStopped() async throws { + // A zero budget stops a run outside a cell at the watchdog's first check. + let session = try makeSession(callbackTimeLimit: .zero) + defer { session.close() } + let timer = try await armRunawayTimer(in: session) + // The timer elapses while no cell runs; its callback starts at once. + session.fireTimer(timer) + // A block queued behind the callback runs once the watchdog stopped it. + let freed = await withCheckedContinuation { (continuation: CheckedContinuation) in + if !session.thread.perform({ continuation.resume(returning: true) }) { + continuation.resume(returning: false) + } + } + #expect(freed) + let next = await browserReplWithDeadline(seconds: 60) { + await session.evaluate(code: "1 + 1", timeout: .seconds(30)) + } + #expect(next?.error == nil, "\(String(describing: next?.error))") + #expect(next?.lines.map(\.text) == ["2"]) + } + + /// The next cell is current as soon as it is submitted, but it runs only + /// once the thread reaches it. A callback queued ahead of it is not that + /// cell's work: counting it as the cell's would exempt it from the + /// callback budget, so it would hold the thread until the cell timed out. + @Test("A timer callback queued ahead of the next cell is not counted as that cell's: it is stopped and the cell runs") + func runawayCallbackQueuedAheadOfACellIsStopped() async throws { + let sleeper = ReportingSleeper() + let session = try makeSession(callbackTimeLimit: .zero, sleeper: sleeper) + defer { session.close() } + let timer = try await armRunawayTimer(in: session) + + // Hold the thread so the callback and the next cell queue behind it, in that order. + let hold = DispatchSemaphore(value: 0) + defer { hold.signal() } + #expect(session.thread.perform { hold.wait() }) + session.fireTimer(timer) + let cellTimeout = Duration.seconds(30) + async let next = browserReplWithDeadline(seconds: 60) { + await session.evaluate(code: "1 + 1", timeout: cellTimeout) + } + // The cell is current and queued once its timeout started. + await sleeper.waitUntilSleeping(for: cellTimeout) + hold.signal() + + let result = await next + #expect(result?.error == nil, "\(String(describing: result?.error))") + #expect(result?.lines.map(\.text) == ["2"]) + } + + @Test("close() ends the session's thread even when agent code loops in the timed-out cell's cleanup") + func closeEndsALoopThatStartsAfterTheTimeout() async throws { + let session = try makeSession(callbackTimeLimit: .seconds(60)) + let thread = session.thread + // The runtime calls this while it cancels a timed-out cell. + let patched = await browserReplWithDeadline(seconds: 30) { + await session.evaluate(code: "page._session._resetPendingState = () => { for (;;) {} }; 'patched'", timeout: .seconds(20)) + } + #expect(patched?.error == nil) + // The cell is still running when it times out, so the timeout's + // cleanup (which cancels the cell) waits behind it on the thread. + let hung = await browserReplWithDeadline(seconds: 30) { + await session.evaluate(code: "const end = Date.now() + 3000; while (Date.now() < end) {} await new Promise(() => {});", timeout: .milliseconds(500)) + } + #expect(hung?.error?.contains("timed out") == true) + session.close() + let exited = await Task.detached { thread.waitUntilExited(timeout: .seconds(15)) }.value + #expect(exited, "the session's JavaScript thread kept running after close()") + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplSessionResourceTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplSessionResourceTests.swift new file mode 100644 index 000000000000..7a38cfaec50a --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplSessionResourceTests.swift @@ -0,0 +1,269 @@ +import Foundation +import Testing + +@testable import CmuxBrowser + +/// A minimal runtime over the native host: `fetchOnce(url)` and `sleep(ms)` +/// return promises, `native` is the host itself. +private let resourceRuntime = #""" +const pending = new Map(); +let nextCall = 1; +const timers = new Map(); +let nextTimer = 1; +globalThis.__cmuxHostOnResult = (id, error, result) => { + const p = pending.get(id); pending.delete(id); + if (!p) return; + if (error) p.reject(Object.assign(new Error(JSON.parse(error).message), { code: JSON.parse(error).code })); + else p.resolve(JSON.parse(result)); +}; +globalThis.__cmuxHostOnTimer = (id) => { const t = timers.get(id); if (t) { timers.delete(id); t(); } }; +globalThis.__cmuxHostOnEvent = () => {}; +const fetchOnce = (url) => new Promise((resolve, reject) => { + const id = nextCall++; pending.set(id, { resolve, reject }); + __cmuxNative.fetch(id, JSON.stringify({ url })); +}); +const sleep = (ms) => new Promise((r) => { const id = nextTimer++; timers.set(id, r); __cmuxNative.setTimer(id, ms, false); }); +const console = { log: (...a) => __cmuxNative.print("log", a.map(String).join(" ")) }; +const AsyncFunction = (async () => {}).constructor; +globalThis.__cmuxFormatError = (e) => `${e.name}: ${e.message}`; +globalThis.__cmuxReplEval = (code) => + new AsyncFunction("console", "fetchOnce", "sleep", "native", code)(console, fetchOnce, sleep, __cmuxNative); +"""# + +/// Holds every `cookies.get` (the fetcher's first step for a cookie-bearing +/// request) until `releaseAll()`, and records for each one how many +/// responses `responses` had sent when it arrived, and whether it was cancelled. +final class HeldCookiesDriver: BrowserReplDriver, @unchecked Sendable { + private let lock = NSLock() + private var released = false + private var held: [UUID: CheckedContinuation] = [:] + private var entryWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var cancellationWaiters: [CheckedContinuation] = [] + private(set) var responsesAtEntry: [Int] = [] + private(set) var cancelledCount = 0 + let responses: @Sendable () -> Int + + init(responses: @escaping @Sendable () -> Int = { 0 }) { + self.responses = responses + } + + var capabilities: [String] { [] } + + func call(method: String, paramsJSON: String) async -> Result { + guard method == "cookies.get" else { return .success("null") } + let id = UUID() + let ready: [CheckedContinuation] = lock.withLock { + responsesAtEntry.append(responses()) + let count = responsesAtEntry.count + let satisfied = entryWaiters.filter { $0.count <= count }.map(\.continuation) + entryWaiters.removeAll { $0.count <= count } + return satisfied + } + for waiter in ready { waiter.resume() } + await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let resumeNow: Bool = lock.withLock { + if released || Task.isCancelled { return true } + held[id] = continuation + return false + } + if resumeNow { continuation.resume() } + } + } onCancel: { + let (continuation, waiters): (CheckedContinuation?, [CheckedContinuation]) = lock.withLock { + cancelledCount += 1 + defer { cancellationWaiters.removeAll() } + return (held.removeValue(forKey: id), cancellationWaiters) + } + continuation?.resume() + for waiter in waiters { waiter.resume() } + } + return .success("[]") + } + + /// Returns once `count` calls have arrived. + func waitForEntries(_ count: Int) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let now: Bool = lock.withLock { + if responsesAtEntry.count >= count { return true } + entryWaiters.append((count, continuation)) + return false + } + if now { continuation.resume() } + } + } + + /// Returns once a held call has been cancelled. + func waitForCancellation() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let now: Bool = lock.withLock { + if cancelledCount > 0 { return true } + cancellationWaiters.append(continuation) + return false + } + if now { continuation.resume() } + } + } + + func releaseAll() { + let pending: [CheckedContinuation] = lock.withLock { + released = true + defer { held.removeAll() } + return Array(held.values) + } + for continuation in pending { continuation.resume() } + } + + func attach(eventSink: @escaping BrowserReplDriverEventSink) {} + func detach() {} +} + +/// Counts what a test server answered. +final class BrowserReplResponseCounter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + var count: Int { lock.withLock { value } } + + func increment() { lock.withLock { value += 1 } } +} + +@Suite("Browser REPL session resources", .serialized) +struct BrowserReplSessionResourceTests { + private func makeSession(_ driver: any BrowserReplDriver) -> BrowserReplSession { + BrowserReplSession( + id: "resources-\(UUID().uuidString)", + cwd: FileManager.default.temporaryDirectory.path, + bundle: BrowserReplRuntimeBundle(replScripts: [.init(name: "resources.js", source: resourceRuntime)], agentScripts: []), + driver: driver + ) + } + + @Test("A session holds at most 10,000 pending timers, also while their callbacks wait for a busy thread") + func pendingTimersAreBounded() async throws { + let session = BrowserReplSession( + id: "timers-\(UUID().uuidString)", + cwd: FileManager.default.temporaryDirectory.path, + bundle: try browserReplRepositoryBundle(), + driver: HeldCookiesDriver() + ) + defer { session.close() } + + // Zero-delay timers fire at once, but this loop keeps the JS thread + // busy, so every callback is still waiting to run. The last callback + // to run settles `floodRan`. + let flood = await browserReplWithDeadline(seconds: 60) { + await session.evaluate(code: """ + let n = 0; + globalThis.floodRan = new Promise((resolve) => { + let ran = 0; + try { + for (let i = 0; i < 20001; i++) { setTimeout(() => { if (++ran === n) resolve(ran); }, 0); n++; } + } catch (e) { + console.log(e.name, n); + } + }); + // The cell's value is not the promise, so the cell does not wait for it. + undefined; + """) + } + #expect(flood?.error == nil) + #expect(flood?.lines.map(\.text) == ["RangeError 10000"]) + + let ran = await browserReplWithDeadline(seconds: 60) { + await session.evaluate(code: "console.log(await globalThis.floodRan)") + } + #expect(ran?.lines.map(\.text) == ["10000"]) + + // Once those callbacks ran, every slot is free again: the session + // takes 10,000 timers (none of which comes due) once more. + let refill = await browserReplWithDeadline(seconds: 60) { + await session.evaluate(code: """ + const ids = []; + for (let i = 0; i < 10000; i++) ids.push(setTimeout(() => {}, 3600000)); + ids.forEach(clearTimeout); + console.log(ids.length); + """) + } + #expect(refill?.error == nil, "\(String(describing: refill?.error))") + #expect(refill?.lines.map(\.text) == ["10000"]) + } + + @Test("Output past the native ceiling goes to a file in the session's temporary directory") + func nativeOutputCeilingSpillsToAFile() async throws { + let session = makeSession(HeldCookiesDriver()) + defer { session.close() } + + // 20 MiB straight to the native host, past any runtime output gate. + let result = await browserReplWithDeadline(seconds: 60) { + await session.evaluate(code: """ + const line = "x".repeat(1 << 20); + for (let i = 0; i < 20; i++) native.print("log", line); + native.print("log", "last"); + """) + } + let lines = try #require(result?.lines) + let retained = lines.reduce(0) { $0 + $1.text.utf8.count + 1 } + #expect(retained <= 16 << 20, "retained \(retained) bytes in \(lines.count) lines") + let summary = try #require(lines.last?.text) + let path = try #require(summary.range(of: "full output: ").map { String(summary[$0.upperBound...]) }, "\(summary)") + let temporary = await session.evaluate(code: "native.print('log', native.tmpdir);") + #expect(path.hasPrefix((temporary.lines.first?.text ?? "?") + "/")) + let spilled = try String(contentsOfFile: path, encoding: .utf8) + #expect(spilled.hasSuffix("last\n")) + #expect(lines.contains { $0.text.hasPrefix("# output continues in ") }) + } + + @Test("A cell that times out cancels the fetches it started") + func timeoutCancelsTheCellsFetches() async { + let driver = HeldCookiesDriver() + let session = makeSession(driver) + defer { session.close() } + + let result = await session.evaluate( + code: "fetchOnce('http://127.0.0.1:9/held'); await new Promise(() => {});", + timeout: .milliseconds(100) + ) + #expect(result.error?.contains("timed out") == true) + + // The fetch was waiting for its cookies; the timeout cancels it, not close(). + let cancelled = await browserReplWithDeadline(seconds: 10) { await driver.waitForCancellation() } + #expect(cancelled != nil) + #expect(!session.isClosed) + } + + @Test("A session runs at most 16 fetches at once; the rest start as earlier ones finish") + func fetchConcurrencyIsBounded() async throws { + let counter = BrowserReplResponseCounter() + let server = try BrowserReplTestHTTPServer { _, _, _ in + counter.increment() + return (200, ["Content-Type": "text/plain"], Data("ok".utf8)) + } + try await server.start() + defer { server.stop() } + let driver = HeldCookiesDriver(responses: { counter.count }) + let session = makeSession(driver) + defer { session.close() } + + let base = "http://127.0.0.1:\(server.port)" + let evaluation = Task { + await session.evaluate(code: """ + const results = await Promise.all(Array.from({ length: 40 }, (_, i) => fetchOnce("\(base)/?i=" + i))); + console.log(results.filter((r) => r.status === 200).length); + """) + } + let started = await browserReplWithDeadline(seconds: 30) { await driver.waitForEntries(16) } + #expect(started != nil) + driver.releaseAll() + let result = await browserReplWithDeadline(seconds: 60) { await evaluation.value } + + #expect(result?.error == nil) + #expect(result?.lines.map(\.text) == ["40"]) + // The 17th fetch starts only after one has its response, the 18th after two, and so on. + let arrivals = driver.responsesAtEntry + #expect(arrivals.count == 40) + for (index, responses) in arrivals.enumerated() where index >= 16 { + #expect(responses >= index - 15, "fetch \(index + 1) started after \(responses) responses: \(arrivals)") + } + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplSessionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplSessionTests.swift new file mode 100644 index 000000000000..07d04f6ff616 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplSessionTests.swift @@ -0,0 +1,340 @@ +import Foundation +import Testing + +@testable import CmuxBrowser + +/// Answers `tabs.list` and echoes other calls, recording what it received. +final class RecordingReplDriver: BrowserReplDriver, @unchecked Sendable { + private let lock = NSLock() + private var sink: BrowserReplDriverEventSink? + private(set) var calls: [String] = [] + + var capabilities: [String] { [] } + + func call(method: String, paramsJSON: String) async -> Result { + lock.withLock { calls.append(method) } + switch method { + case "tabs.list": + return .success(#"[{"targetId":"t1","title":"Fixture","url":"about:blank","active":true}]"#) + case "tab.missing": + return .failure(BrowserReplDriverError(code: "not_found", message: "no such tab")) + default: + return .success(paramsJSON) + } + } + + func attach(eventSink: @escaping BrowserReplDriverEventSink) { + lock.withLock { sink = eventSink } + } + + func detach() { + lock.withLock { sink = nil } + } + + func emit(_ name: String, _ payload: String) { + let sink = lock.withLock { self.sink } + sink?(name, payload) + } +} + +/// A minimal runtime that exercises every native host entry point. +private let stubRuntime = #""" +const pending = new Map(); +let nextCall = 1; +const timers = new Map(); +let nextTimer = 1; +globalThis.__cmuxHostOnResult = (id, error, result) => { + const p = pending.get(id); pending.delete(id); + if (error) p.reject(Object.assign(new Error(JSON.parse(error).message), { code: JSON.parse(error).code })); + else p.resolve(JSON.parse(result)); +}; +globalThis.__cmuxHostOnTimer = (id) => { const t = timers.get(id); if (t) { timers.delete(id); t(); } }; +globalThis.__cmuxHostOnEvent = (name, payload) => { globalThis.lastEvent = [name, JSON.parse(payload)]; }; +const call = (method, params) => new Promise((resolve, reject) => { + const id = nextCall++; pending.set(id, { resolve, reject }); + __cmuxNative.driverCall(id, method, JSON.stringify(params ?? {})); +}); +const sleep = (ms) => new Promise((r) => { const id = nextTimer++; timers.set(id, r); __cmuxNative.setTimer(id, ms, false); }); +const fs = (op, args) => { const r = JSON.parse(__cmuxNative.fs(op, JSON.stringify(args))); if (r.error) throw new Error(r.error.code); return r.ok; }; +const console = { log: (...a) => __cmuxNative.print("log", a.map(String).join(" ")), error: (...a) => __cmuxNative.print("error", a.map(String).join(" ")) }; +const AsyncFunction = (async () => {}).constructor; +globalThis.__cmuxFormatError = (e) => `${e.name}: ${e.message}`; +globalThis.__cmuxReplEval = (...args) => + new AsyncFunction("console", "call", "sleep", "fs", "evalArity", "native", "evalOptions", args[0])(console, call, sleep, fs, args.length, __cmuxNative, args[1]); +"""# + +@Suite("Browser REPL session") +struct BrowserReplSessionTests { + private func makeSession( + driver: RecordingReplDriver, + cwd: String? = nil, + temporaryDirectory: String? = nil + ) -> BrowserReplSession { + BrowserReplSession( + id: "test-\(UUID().uuidString)", + cwd: cwd ?? FileManager.default.temporaryDirectory.path, + bundle: BrowserReplRuntimeBundle( + replScripts: [.init(name: "stub.js", source: stubRuntime)], + agentScripts: [] + ), + driver: driver, + temporaryDirectory: temporaryDirectory + ) + } + + @Test("Console output, driver calls and timers complete inside one evaluation") + func evaluationRoundTrip() async { + let driver = RecordingReplDriver() + let session = makeSession(driver: driver) + defer { session.close() } + + let result = await session.evaluate( + code: """ + const tabs = await call("tabs.list"); + console.log(evalArity, tabs[0].targetId); + await sleep(5); + console.error("after", (await call("tab.info", { targetId: "t1" })).targetId); + """ + ) + + #expect(result.error == nil) + #expect(result.lines == [ + BrowserReplOutputLine(level: "log", text: "2 t1"), + BrowserReplOutputLine(level: "error", text: "after t1"), + ]) + #expect(driver.calls == ["tabs.list", "tab.info"]) + } + + @Test("An output cap reaches the runtime as its options argument") + func maxOutputOption() async { + let session = makeSession(driver: RecordingReplDriver()) + defer { session.close() } + + // The options always carry the evaluation's id, so a timeout cancels only that cell. + let capped = await session.evaluate(code: "console.log(evalArity, evalOptions);", maxOutput: 1234) + #expect(capped.lines == [BrowserReplOutputLine(level: "log", text: #"2 {"evalId":1,"maxOutput":1234}"#)]) + let unlimited = await session.evaluate(code: "console.log(evalOptions);", maxOutput: 0) + #expect(unlimited.lines == [BrowserReplOutputLine(level: "log", text: #"{"evalId":2,"maxOutput":0}"#)]) + let runtimeDefault = await session.evaluate(code: "console.log(evalOptions);") + #expect(runtimeDefault.lines == [BrowserReplOutputLine(level: "log", text: #"{"evalId":3}"#)]) + } + + @Test("Uncaught errors and driver errors are reported with the runtime's formatter") + func uncaughtError() async { + let driver = RecordingReplDriver() + let session = makeSession(driver: driver) + defer { session.close() } + + let thrown = await session.evaluate(code: "console.log('before'); throw new TypeError('boom');") + #expect(thrown.error == "TypeError: boom") + #expect(thrown.lines == [BrowserReplOutputLine(level: "log", text: "before")]) + + let driverError = await session.evaluate(code: "await call('tab.missing');") + #expect(driverError.error == "Error: no such tab") + } + + @Test("An evaluation that never settles times out without wedging the session") + func timeout() async { + let driver = RecordingReplDriver() + let session = makeSession(driver: driver) + defer { session.close() } + + let hung = await session.evaluate(code: "await new Promise(() => {});", timeout: .milliseconds(50)) + #expect(hung.error?.contains("timed out") == true) + + let next = await session.evaluate(code: "console.log('alive');") + #expect(next.lines == [BrowserReplOutputLine(level: "log", text: "alive")]) + } + + @Test("Driver events reach the runtime, and a finished download becomes readable") + func downloadEventAllowsReading() async throws { + let base = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-repl-session-\(UUID().uuidString)") + let work = base.appendingPathComponent("work") + let downloads = base.appendingPathComponent("downloads") + try FileManager.default.createDirectory(at: work, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: downloads, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + let file = downloads.appendingPathComponent("report.csv") + try Data("a,b".utf8).write(to: file) + + let driver = RecordingReplDriver() + // The scratch tree lives in the real temporary directory; point the + // session's temporary root elsewhere so the download starts unreadable. + let session = makeSession(driver: driver, cwd: work.path, temporaryDirectory: base.appendingPathComponent("tmp").path) + defer { session.close() } + + let before = await session.evaluate(code: "fs('readFile', { path: \(quoted(file.path)) });") + #expect(before.error == "Error: EACCES") + + driver.emit("download.finished", #"{"targetId":"t1","downloadId":"d1","path":\#(quoted(file.path))}"#) + let after = await session.evaluate( + code: """ + console.log(lastEvent[0], fs('readFile', { path: \(quoted(file.path)) })); + """ + ) + #expect(after.error == nil) + #expect(after.lines == [BrowserReplOutputLine(level: "log", text: "download.finished YSxi")]) + } + + @Test( + "A working directory that holds the user's files is refused as the fs root", + arguments: [ + "/", + NSHomeDirectory(), + NSHomeDirectory() + "/", + (NSHomeDirectory() as NSString).deletingLastPathComponent, + ] + ) + func broadWorkingDirectoryIsRefused(cwd: String) async throws { + let work = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-repl-session-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: work, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: work) } + let session = makeSession(driver: RecordingReplDriver(), cwd: work.path) + defer { session.close() } + + let refused = await session.evaluate(code: "console.log('ran');", cwd: cwd) + + #expect(refused.lines.isEmpty) + #expect(refused.error?.contains("refusing to use") == true) + #expect(refused.error?.contains("cd to a project or scratch directory") == true) + #expect(session.cwd == work.path) + let next = await session.evaluate(code: "console.log('ran');") + #expect(next.error == nil) + #expect(next.lines == [BrowserReplOutputLine(level: "log", text: "ran")]) + } + + @Test("A session created with / as its working directory refuses to evaluate") + func sessionCreatedAtFileSystemRootIsRefused() async { + let session = makeSession(driver: RecordingReplDriver(), cwd: "/") + defer { session.close() } + + let refused = await session.evaluate(code: "console.log(fs('exists', { path: '/etc/hosts' }));") + + #expect(refused.lines.isEmpty) + #expect(refused.error?.contains("refusing to use '/'") == true) + } + + @Test("A session without a cwd gets its own temporary directory, removed on close when empty") + func sessionWithoutWorkingDirectory() async throws { + let base = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-repl-session-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + func make(_ id: String) -> BrowserReplSession { + BrowserReplSession( + id: id, + cwd: nil, + bundle: BrowserReplRuntimeBundle(replScripts: [.init(name: "stub.js", source: stubRuntime)], agentScripts: []), + driver: RecordingReplDriver(), + temporaryDirectory: base.path + ) + } + let writer = make("writer/../session") + let idle = make("idle") + let canonicalBase = BrowserReplFileSandbox.canonicalize(base.path) + + #expect(writer.cwd != idle.cwd) + for session in [writer, idle] { + #expect((session.cwd as NSString).deletingLastPathComponent == canonicalBase + "/cmux-browser-repl") + var isDirectory: ObjCBool = false + #expect(FileManager.default.fileExists(atPath: session.cwd, isDirectory: &isDirectory) && isDirectory.boolValue) + } + let wrote = await writer.evaluate(code: "fs('writeFile', { path: 'out.txt', base64: 'aGk=' }); console.log(native.cwd);") + #expect(wrote.error == nil) + #expect(wrote.lines == [BrowserReplOutputLine(level: "log", text: writer.cwd)]) + + writer.close() + idle.close() + #expect(FileManager.default.contents(atPath: writer.cwd + "/out.txt") == Data("hi".utf8)) + #expect(!FileManager.default.fileExists(atPath: idle.cwd)) + } + + @Test("Each session's temporary directory is private to it and never reaches another session's files") + func temporaryDirectoryIsPrivateToTheSession() async throws { + let base = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-repl-session-\(UUID().uuidString)") + for name in ["a", "b"] { + try FileManager.default.createDirectory(at: base.appendingPathComponent(name), withIntermediateDirectories: true) + } + defer { try? FileManager.default.removeItem(at: base) } + let shared = BrowserReplFileSandbox.canonicalize(base.path) + try Data("x".utf8).write(to: base.appendingPathComponent("other-app-file.txt")) + func make(_ id: String) -> BrowserReplSession { + BrowserReplSession( + id: id, + cwd: base.appendingPathComponent(id).path, + bundle: BrowserReplRuntimeBundle(replScripts: [.init(name: "stub.js", source: stubRuntime)], agentScripts: []), + driver: RecordingReplDriver(), + temporaryDirectory: base.path + ) + } + let first = make("a") + let second = make("b") + defer { + first.close() + second.close() + } + + // A spill file in the first session's temporary directory, as the runtime writes one. + let wrote = await first.evaluate(code: "fs('writeFile', { path: native.tmpdir + '/output-1.txt', base64: 'aGk=' }); console.log(native.tmpdir);") + #expect(wrote.error == nil) + let firstTemporary = try #require(wrote.lines.first?.text) + let listed = await second.evaluate(code: "console.log(native.tmpdir);") + let secondTemporary = try #require(listed.lines.first?.text) + #expect(firstTemporary != shared && secondTemporary != shared && firstTemporary != secondTemporary) + for path in [firstTemporary, secondTemporary] { + let mode = try FileManager.default.attributesOfItem(atPath: path)[.posixPermissions] as? NSNumber + #expect(mode?.intValue == 0o700, "\(path)") + } + + // The other session reaches neither that file, nor the shared directory, nor other files in it. + for code in [ + "fs('readFile', { path: \(quoted(firstTemporary + "/output-1.txt")) });", + "fs('writeFile', { path: \(quoted(firstTemporary + "/output-1.txt")), base64: '' });", + "fs('readdir', { path: \(quoted(shared)) });", + "fs('readFile', { path: \(quoted(shared + "/other-app-file.txt")) });", + ] { + let refused = await second.evaluate(code: code) + #expect(refused.error == "Error: EACCES", "\(code)") + } + #expect(FileManager.default.contents(atPath: firstTemporary + "/output-1.txt") == Data("hi".utf8)) + + // Closing removes an empty temporary directory; files the session wrote stay readable. + first.close() + second.close() + #expect(!FileManager.default.fileExists(atPath: secondTemporary)) + #expect(FileManager.default.contents(atPath: firstTemporary + "/output-1.txt") == Data("hi".utf8)) + } + + @Test("The injected home directory is the one refused") + func injectedHomeDirectoryIsRefused() async throws { + let home = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-repl-home-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: home.appendingPathComponent("project"), withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + let session = BrowserReplSession( + id: "home", + cwd: home.appendingPathComponent("project").path, + bundle: BrowserReplRuntimeBundle(replScripts: [.init(name: "stub.js", source: stubRuntime)], agentScripts: []), + driver: RecordingReplDriver(), + homeDirectory: home.path + ) + defer { session.close() } + + let project = await session.evaluate(code: "console.log(native.homedir);") + let refused = await session.evaluate(code: "console.log('ran');", cwd: home.path + "/project/..") + + #expect(project.lines == [BrowserReplOutputLine(level: "log", text: home.path)]) + #expect(refused.lines.isEmpty) + #expect(refused.error?.contains("refusing to use the home directory") == true) + } + + @Test("A closed session refuses evaluations") + func closedSession() async { + let session = makeSession(driver: RecordingReplDriver()) + session.close() + let result = await session.evaluate(code: "1") + #expect(result.error?.contains("closed") == true) + } + + private func quoted(_ string: String) -> String { + JSONSerialization.browserReplString(string) ?? "\"\"" + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplTabOwnershipTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplTabOwnershipTests.swift new file mode 100644 index 000000000000..14b22209db8d --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplTabOwnershipTests.swift @@ -0,0 +1,137 @@ +import Testing +@testable import CmuxBrowser + +@Suite struct BrowserReplTabOwnershipTests { + @Test func aTabNoSessionCreatedKeepsTheUsersUI() { + var ownership = BrowserReplTabOwnership() + ownership.attach(sessionID: "agent") + #expect(!ownership.isSessionOwned) + for event in BrowserReplTabEvent.allCases { + #expect(!ownership.routesToSessions(event)) + } + } + + @Test func aTabTheSessionCreatedRoutesEverythingToIt() { + var ownership = BrowserReplTabOwnership() + ownership.markCreated(by: "agent") + #expect(ownership.isSessionOwned) + for event in BrowserReplTabEvent.allCases { + #expect(ownership.routesToSessions(event)) + } + } + + @Test func aHandlerOnAUsersTabTakesOnlyItsEvent() { + var ownership = BrowserReplTabOwnership() + ownership.attach(sessionID: "agent") + ownership.setHandledEvents([.dialog], for: "agent") + #expect(ownership.routesToSessions(.dialog)) + #expect(!ownership.routesToSessions(.fileChooser)) + #expect(!ownership.routesToSessions(.download)) + #expect(!ownership.isSessionOwned) + ownership.setHandledEvents([], for: "agent") + #expect(!ownership.routesToSessions(.dialog)) + } + + @Test func handlersEndWithTheirSession() { + var ownership = BrowserReplTabOwnership() + ownership.attach(sessionID: "a") + ownership.attach(sessionID: "b") + ownership.setHandledEvents([.download], for: "a") + ownership.detach(sessionID: "a") + #expect(!ownership.routesToSessions(.download)) + // A session that is not attached cannot register handlers. + ownership.setHandledEvents([.download], for: "a") + #expect(!ownership.routesToSessions(.download)) + } + + @Test func theTabIsTheUsersOnceItsCreatorLeaves() { + var ownership = BrowserReplTabOwnership() + ownership.markCreated(by: "creator") + ownership.attach(sessionID: "other") + ownership.detach(sessionID: "creator") + #expect(!ownership.isSessionOwned) + #expect(!ownership.routesToSessions(.dialog)) + #expect(ownership.creatorSessionID == nil) + } + + // One session gets each routed event, so only that session can answer it. + @Test func aRoutedEventGoesToOneSession() { + var ownership = BrowserReplTabOwnership() + ownership.attach(sessionID: "first") + ownership.attach(sessionID: "second") + ownership.setHandledEvents([.dialog], for: "first") + ownership.setHandledEvents([.dialog, .download], for: "second") + #expect(ownership.recipient(for: .dialog) == "first", "the session that registered first") + #expect(ownership.recipient(for: .download) == "second") + #expect(ownership.recipient(for: .fileChooser) == nil, "the user's UI") + // A session that drives the tab without a handler gets none of them. + ownership.attach(sessionID: "bystander") + #expect(ownership.recipient(for: .dialog) == "first") + ownership.setHandledEvents([], for: "first") + #expect(ownership.recipient(for: .dialog) == "second") + ownership.detach(sessionID: "second") + #expect(ownership.recipient(for: .dialog) == nil) + } + + @Test func aSessionTabsEventsGoToItsCreatorUnlessAnotherSessionHandlesThem() { + var ownership = BrowserReplTabOwnership() + ownership.markCreated(by: "creator") + ownership.attach(sessionID: "other") + #expect(ownership.recipient(for: .dialog) == "creator") + ownership.setHandledEvents([.dialog], for: "other") + #expect(ownership.recipient(for: .dialog) == "other", "a handler takes the event") + ownership.setHandledEvents([.dialog], for: "creator") + #expect(ownership.recipient(for: .dialog) == "creator", "the creator's handler first") + #expect(ownership.recipient(for: .download) == "creator") + } + + // An agent's click in a user's tab that opens an alert or a file panel + // must not put cmux's UI in front of the user (a file panel opened over + // their work from a hidden workspace) or hang the agent on a dialog only + // the user can see. What the agent's own input opens goes to the agent. + @Test func whatASessionsInputOpensInAUsersTabGoesToThatSession() { + var ownership = BrowserReplTabOwnership() + ownership.attach(sessionID: "agent") + ownership.attach(sessionID: "other") + ownership.beginInput(sessionID: "agent") + #expect(ownership.recipient(for: .dialog) == "agent") + #expect(ownership.recipient(for: .fileChooser) == "agent") + #expect(ownership.recipient(for: .download) == nil, "a download keeps the user's download location") + #expect(!ownership.isSessionOwned, "the tab stays the user's") + ownership.endInput(sessionID: "agent") + #expect(ownership.recipient(for: .dialog) == nil, "after the input, the user's UI again") + #expect(ownership.recipient(for: .fileChooser) == nil) + } + + @Test func aHandlerStillWinsOverTheSessionWhoseInputOpenedTheEvent() { + var ownership = BrowserReplTabOwnership() + ownership.attach(sessionID: "agent") + ownership.attach(sessionID: "watcher") + ownership.setHandledEvents([.dialog], for: "watcher") + ownership.beginInput(sessionID: "agent") + #expect(ownership.recipient(for: .dialog) == "watcher") + #expect(ownership.recipient(for: .fileChooser) == "agent") + } + + @Test func inputEndsWithTheSessionAndNests() { + var ownership = BrowserReplTabOwnership() + ownership.attach(sessionID: "a") + ownership.attach(sessionID: "b") + ownership.beginInput(sessionID: "a") + ownership.beginInput(sessionID: "b") + #expect(ownership.recipient(for: .dialog) == "b", "the latest input") + ownership.endInput(sessionID: "b") + #expect(ownership.recipient(for: .dialog) == "a") + ownership.detach(sessionID: "a") + #expect(ownership.recipient(for: .dialog) == nil, "a session that left gets nothing") + // Input from a session that is not attached routes nothing. + ownership.beginInput(sessionID: "ghost") + #expect(ownership.recipient(for: .dialog) == nil) + } + + @Test func eventNamesParseStrictly() { + #expect(BrowserReplTabOwnership.events(named: ["dialog", "filechooser", "download"]) == Set(BrowserReplTabEvent.allCases)) + #expect(BrowserReplTabOwnership.events(named: []) == []) + #expect(BrowserReplTabOwnership.events(named: ["dialog", "popup"]) == nil) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplTabWakeTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplTabWakeTests.swift new file mode 100644 index 000000000000..300dc345b4af --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplTabWakeTests.swift @@ -0,0 +1,260 @@ +import Foundation +import Testing + +@testable import CmuxBrowser + +/// A deadline that has already passed. +private struct ElapsedSleeper: BrowserReplSleeping { + func sleep(for duration: Duration) async throws {} +} + +/// A deadline that never passes during a test (cancelled with its race). +private struct DistantSleeper: BrowserReplSleeping { + func sleep(for duration: Duration) async throws { + await browserReplWaitUntilCancelled() + throw CancellationError() + } +} + +/// A tab whose web content the test changes, standing in for a browser pane. +@MainActor +private final class FakeTab { + var condition: BrowserReplTabCondition + var wakeCount = 0 + /// What a wake does to the tab: by default a restore starts. + var onWake: (FakeTab) -> Void = { $0.condition.isWaking = true } + /// What waiting for the load does: by default the restore commits. + var onWait: (FakeTab) async -> Void = { $0.condition = BrowserReplTabCondition() } + + init(_ condition: BrowserReplTabCondition) { + self.condition = condition + } + + var recoverCount = 0 + /// What reloading a crashed tab does: by default a new process starts loading. + var onRecover: (FakeTab) -> Void = { $0.condition = BrowserReplTabCondition(isHibernated: true, isWaking: true) } + + @discardableResult + func prepare(_ method: String, sleeper: any BrowserReplSleeping) async throws -> BrowserReplTabPreparation { + let waker = BrowserReplTabWaker(sleeper: sleeper, timeout: .seconds(30)) + return try await waker.prepare( + method: method, + tab: BrowserReplTabLabel(id: "T1", title: "Inbox", url: "https://mail.example.com/"), + condition: { self.condition }, + wake: { + self.wakeCount += 1 + self.onWake(self) + }, + recoverCrash: { + self.recoverCount += 1 + self.onRecover(self) + }, + waitUntilLoaded: { await self.onWait(self) } + ) + } +} + +@MainActor +@Suite("Browser REPL hibernated and crashed tabs", .serialized) +struct BrowserReplTabWakeTests { + private func driverError(_ body: () async throws -> Void) async -> BrowserReplDriverError? { + do { + try await body() + return nil + } catch let error as BrowserReplDriverError { + return error + } catch { + Issue.record("unexpected error \(error)") + return nil + } + } + + @Test func statesNameWhatTheAgentSees() { + #expect(BrowserReplTabCondition().state == .live) + #expect(BrowserReplTabCondition(isHibernated: true).state == .hibernated) + #expect(BrowserReplTabCondition(isHibernated: true, isWaking: true).state == .waking) + #expect(BrowserReplTabCondition(isCrashed: true).state == .crashed) + #expect(BrowserReplTabCondition(isHibernated: true, isCrashed: true).state == .crashed) + } + + @Test func aLiveTabRunsTheCallAtOnce() async throws { + let tab = FakeTab(BrowserReplTabCondition()) + try await tab.prepare("frame.evaluate", sleeper: DistantSleeper()) + #expect(tab.wakeCount == 0) + } + + @Test func aHibernatedTabIsWokenAndTheCallWaitsForItsPage() async throws { + let tab = FakeTab(BrowserReplTabCondition(isHibernated: true)) + try await tab.prepare("frame.evaluate", sleeper: DistantSleeper()) + #expect(tab.wakeCount == 1) + #expect(tab.condition.state == .live) + } + + @Test func aTabAlreadyWakingIsWaitedFor() async throws { + let tab = FakeTab(BrowserReplTabCondition(isHibernated: true, isWaking: true)) + try await tab.prepare("input.mouse", sleeper: DistantSleeper()) + #expect(tab.condition.state == .live) + } + + @Test func aWakeThatOutlastsItsBoundFailsWithATimeoutThatNamesTheTab() async { + let tab = FakeTab(BrowserReplTabCondition(isHibernated: true)) + // The restore never commits; the waker cancels the wait at its deadline. + tab.onWait = { _ in await browserReplWaitUntilCancelled() } + let error = await driverError { try await tab.prepare("frame.evaluate", sleeper: ElapsedSleeper()) } + #expect(error?.code == "timeout") + #expect(error?.message.contains("tab T1 (\"Inbox\", https://mail.example.com/)") == true) + #expect(error?.message.contains("hibernated") == true) + #expect(error?.message.contains("30 s") == true) + #expect(error?.message.contains("retry") == true) + } + + @Test func aRestoreTheUserStoppedFailsAtOnceWithWhatToDo() async { + let tab = FakeTab(BrowserReplTabCondition(isHibernated: true, restoreStoppedByUser: true)) + // Stopped: the wake starts no restore, so nothing is waited for. + tab.onWake = { _ in } + tab.onWait = { _ in Issue.record("waited for a restore that never started") } + let error = await browserReplWithDeadline(seconds: 10) { @MainActor in + await driverError { try await tab.prepare("frame.evaluate", sleeper: DistantSleeper()) } + } + let thrown = error ?? nil + #expect(thrown?.code == "hibernated") + #expect(thrown?.message.contains("stopped") == true) + #expect(thrown?.message.contains("page.reload()") == true) + } + + @Test func aRestoreThatEndsWithoutAPageFailsWithWhatToDo() async { + let tab = FakeTab(BrowserReplTabCondition(isHibernated: true)) + // The restore navigation failed: the tab is still unloaded and no load runs. + tab.onWait = { $0.condition = BrowserReplTabCondition(isHibernated: true) } + let error = await driverError { try await tab.prepare("tab.screenshot", sleeper: DistantSleeper()) } + #expect(error?.code == "hibernated") + #expect(error?.message.contains("page.reload()") == true) + } + + @Test func aCrashedTabFailsWithReloadAdvice() async { + let tab = FakeTab(BrowserReplTabCondition(isCrashed: true)) + let error = await driverError { try await tab.prepare("input.key", sleeper: DistantSleeper()) } + #expect(error?.code == "crashed") + #expect(error?.message.contains("tab T1") == true) + #expect(error?.message.contains("page.reload()") == true) + #expect(tab.wakeCount == 0) + } + + // Seen live: page.reload() on a tab that crashed before the session + // attached failed with "interrupted by another navigation", because + // recovering a crashed tab loads the page into a new web view. + @Test func reloadingACrashedTabRecoversItAndWaitsForThePage() async throws { + let tab = FakeTab(BrowserReplTabCondition(isCrashed: true)) + let outcome = try await tab.prepare("tab.reload", sleeper: DistantSleeper()) + #expect(outcome == .reloaded) + #expect(tab.recoverCount == 1) + #expect(tab.condition.state == .live) + } + + @Test func aCrashedTabRecoveringInANewProcessIsWaitedFor() async throws { + let tab = FakeTab(BrowserReplTabCondition(isCrashed: true)) + // Recovery starts a load in a new web view; the tab is not hibernated. + tab.onRecover = { $0.condition = BrowserReplTabCondition() } + var waited = false + tab.onWait = { _ in waited = true } + #expect(try await tab.prepare("tab.reload", sleeper: DistantSleeper()) == .reloaded) + #expect(waited) + } + + @Test func reloadingAHibernatedTabIsItsWake() async throws { + let tab = FakeTab(BrowserReplTabCondition(isHibernated: true)) + let outcome = try await tab.prepare("tab.reload", sleeper: DistantSleeper()) + #expect(outcome == .reloaded) + #expect(tab.wakeCount == 1) + #expect(tab.condition.state == .live) + } + + @Test func otherCallsRunThemselves() async throws { + let tab = FakeTab(BrowserReplTabCondition(isHibernated: true)) + #expect(try await tab.prepare("frame.evaluate", sleeper: DistantSleeper()) == .ready) + #expect(try await FakeTab(BrowserReplTabCondition()).prepare("tab.reload", sleeper: DistantSleeper()) == .ready) + } + + @Test func aCrashedTabStillAnswersNavigationAndInfo() async throws { + for method in ["tab.navigate", "tab.history", "tab.info", "tabs.close", "tab.bringToFront"] { + let tab = FakeTab(BrowserReplTabCondition(isCrashed: true)) + try await tab.prepare(method, sleeper: DistantSleeper()) + } + } + + @Test func aWakeThatEndsInACrashSaysSo() async { + let tab = FakeTab(BrowserReplTabCondition(isHibernated: true)) + tab.onWait = { $0.condition = BrowserReplTabCondition(isCrashed: true) } + let error = await driverError { try await tab.prepare("frame.evaluate", sleeper: DistantSleeper()) } + #expect(error?.code == "crashed") + } + + @Test func closingKeepingOrNavigatingAwayFromAHibernatedTabDoesNotWaitForItsOldPage() async throws { + for method in ["tabs.close", "tab.keep", "tab.navigate", "tab.history"] { + let tab = FakeTab(BrowserReplTabCondition(isHibernated: true)) + tab.onWait = { _ in Issue.record("\(method) waited for the old page") } + try await tab.prepare(method, sleeper: DistantSleeper()) + #expect(tab.condition.state == .hibernated) + } + } + + @Test func handlingEventsOrShowingAHibernatedTabDoesNotWakeIt() async throws { + for method in ["tab.handleEvents", "tabs.activate", "tab.bringToFront"] { + let tab = FakeTab(BrowserReplTabCondition(isHibernated: true)) + try await tab.prepare(method, sleeper: DistantSleeper()) + #expect(tab.wakeCount == 0, "\(method) woke the tab") + } + } + + // A call the session cancels (its cell timed out, the session closed) + // must not keep waiting for the page until the 30 s bound. + @Test func aCancelledWakeStopsWaiting() async { + let tab = FakeTab(BrowserReplTabCondition(isHibernated: true)) + tab.onWait = { _ in + await withCheckedContinuation { (_: CheckedContinuation) in } + } + let task = Task { @MainActor in try? await tab.prepare("frame.evaluate", sleeper: DistantSleeper()) } + task.cancel() + let ended = await browserReplWithDeadline(seconds: 10) { await task.value; return true } + #expect(ended == true) + } + + // A web view invalidated by a close or crash answers every commit wait + // with superseded at once; the wait must end instead of spinning on the + // main actor, where the deadline task could never run. + @Test func aCommitWaitOnAnInvalidatedWebViewEnds() async { + let instance = UUID() + var waits = 0 + let committed = await BrowserReplTabWaker.waitForPageCommit( + instance: { instance }, + waitForCommit: { _ in + waits += 1 + return .superseded + } + ) + #expect(!committed) + #expect(waits == 1) + } + + @Test func aCommitWaitFollowsAReplacedWebView() async { + var current = UUID() + var waits = 0 + let committed = await BrowserReplTabWaker.waitForPageCommit( + instance: { current }, + waitForCommit: { _ in + waits += 1 + // The first web view is replaced before it commits; the next commits. + guard waits == 1 else { return .committed } + current = UUID() + return .superseded + } + ) + #expect(committed) + #expect(waits == 2) + } + + @Test func aTabWithNoTitleIsNamedByItsURL() { + #expect(BrowserReplTabLabel(id: "T2", title: "", url: "https://x.example/").description == "tab T2 (https://x.example/)") + #expect(BrowserReplTabLabel(id: "T3", title: "", url: "").description == "tab T3") + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplTestSupport.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplTestSupport.swift new file mode 100644 index 000000000000..f9a4991f4a27 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplTestSupport.swift @@ -0,0 +1,178 @@ +import Foundation + +@testable import CmuxBrowser + +/// Runs `operation` and returns its value, or `nil` when it has not finished +/// after `seconds`. Unlike a task group, this does not wait for an operation +/// that never finishes, so a wedged session fails the test instead of hanging it. +func browserReplWithDeadline( + seconds: Double, + _ operation: @escaping @Sendable () async -> T +) async -> T? { + let box = BrowserReplOnceBox() + return await withCheckedContinuation { continuation in + box.set(continuation) + Task { box.resume(await operation()) } + Task { + try? await Task.sleep(for: .seconds(seconds)) + box.resume(nil) + } + } +} + +/// Resumes a continuation once; later values are dropped. +final class BrowserReplOnceBox: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var early: T? + private var done = false + + func set(_ continuation: CheckedContinuation) { + lock.lock() + if done, let early { + lock.unlock() + continuation.resume(returning: early) + return + } + self.continuation = continuation + lock.unlock() + } + + func resume(_ value: T) { + lock.lock() + guard !done else { + lock.unlock() + return + } + done = true + guard let continuation else { + early = value + lock.unlock() + return + } + self.continuation = nil + lock.unlock() + continuation.resume(returning: value) + } +} + +/// Suspends until the calling task is cancelled, and never resumes on its +/// own: work that never finishes, without a timer standing in for it. +func browserReplWaitUntilCancelled() async { + let gate = BrowserReplCancellationGate() + await withTaskCancellationHandler { + await withCheckedContinuation { gate.park($0) } + } onCancel: { + gate.open() + } +} + +/// Holds a continuation until `open()`; one parked after `open()` resumes at once. +private final class BrowserReplCancellationGate: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var opened = false + + func park(_ continuation: CheckedContinuation) { + let resumeNow: Bool = lock.withLock { + if opened { return true } + self.continuation = continuation + return false + } + if resumeNow { continuation.resume() } + } + + func open() { + let parked: CheckedContinuation? = lock.withLock { + opened = true + defer { continuation = nil } + return continuation + } + parked?.resume() + } +} + +/// The repository's `Resources/browser-repl` runtime, as the app bundles it. +func browserReplRepositoryBundle() throws -> BrowserReplRuntimeBundle { + var directory = URL(fileURLWithPath: #filePath) + // Tests/CmuxBrowserTests/Repl/ -> package -> Packages/macOS -> repository root. + for _ in 0..<7 { directory.deleteLastPathComponent() } + return try BrowserReplRuntimeBundle.load(from: directory.appendingPathComponent("Resources/browser-repl")) +} + +/// A driver whose `slow` method waits until `release()` and records whether +/// its task was cancelled meanwhile; other methods behave like +/// `RecordingReplDriver`. +final class GatedReplDriver: BrowserReplDriver, @unchecked Sendable { + private let lock = NSLock() + private var waiters: [CheckedContinuation] = [] + private var released = false + private var started: [CheckedContinuation] = [] + private var startedCount = 0 + private(set) var cancelledAfterRelease: [Bool] = [] + private(set) var attachCount = 0 + private(set) var methods: [String] = [] + + var capabilities: [String] { [] } + + func call(method: String, paramsJSON: String) async -> Result { + lock.withLock { methods.append(method) } + switch method { + case "slow": + let startedWaiters: [CheckedContinuation] = lock.withLock { + startedCount += 1 + defer { started.removeAll() } + return started + } + for waiter in startedWaiters { waiter.resume() } + await withCheckedContinuation { (continuation: CheckedContinuation) in + lock.lock() + if released { + lock.unlock() + continuation.resume() + } else { + waiters.append(continuation) + lock.unlock() + } + } + let cancelled = Task.isCancelled + lock.withLock { cancelledAfterRelease.append(cancelled) } + return .success("null") + case "tabs.list": + return .success(#"[{"targetId":"t1","title":"Fixture","url":"about:blank","active":true}]"#) + case "cookies.get": + return .success("[]") + default: + return .success("null") + } + } + + /// Returns once a `slow` call has started. + func waitUntilSlowCallStarted() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + lock.lock() + if startedCount > 0 { + lock.unlock() + continuation.resume() + } else { + started.append(continuation) + lock.unlock() + } + } + } + + func release() { + let pending: [CheckedContinuation] = lock.withLock { + released = true + defer { waiters.removeAll() } + return waiters + } + for waiter in pending { waiter.resume() } + } + + func attach(eventSink: @escaping BrowserReplDriverEventSink) { + lock.withLock { attachCount += 1 } + } + + func detach() {} +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplTextCommitTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplTextCommitTests.swift new file mode 100644 index 000000000000..187b8d1238a0 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplTextCommitTests.swift @@ -0,0 +1,74 @@ +import Testing + +@testable import CmuxBrowser + +/// A text input client whose focus can move while the commit waits for the +/// editor state, as a page can move focus during that wait. +@MainActor +private final class FakeTextTarget: BrowserReplTextCommitTarget { + var focusedOrigin = "https://allowed.example" + var originAfterPreparation: String? + var richText = true + var hasMarkedText = false + private(set) var log: [String] = [] + + func prepareComposition() async -> Bool { + log.append("prepare") + if let originAfterPreparation { focusedOrigin = originAfterPreparation } + return richText + } + + func setMarkedText(_ text: String) { log.append("marked \(focusedOrigin) \(text)") } + + func insertText(_ text: String) { log.append("insert \(focusedOrigin) \(text)") } +} + +private struct Refused: Error, Equatable { + let origin: String +} + +@MainActor +@Suite("Browser REPL text commit") +struct BrowserReplTextCommitTests { + private func allowOnly(_ target: FakeTextTarget) -> @MainActor () async throws -> Void { + { + guard target.focusedOrigin == "https://allowed.example" else { + throw Refused(origin: target.focusedOrigin) + } + } + } + + @Test func aRichTextEditorGetsMarkedTextThenTheInsert() async throws { + let target = FakeTextTarget() + try await target.commit("pw", checkTarget: allowOnly(target)) + #expect(target.log == ["prepare", "marked https://allowed.example pw", "insert https://allowed.example pw"]) + } + + @Test func aLineBreakIsInsertedWithoutAComposition() async throws { + let target = FakeTextTarget() + try await target.commit("a\nb", checkTarget: allowOnly(target)) + #expect(target.log == ["insert https://allowed.example a\nb"]) + } + + @Test func focusThatMovesWhileTheEditorStateSettlesIsRefused() async throws { + // The target passes the check when the call starts; the page moves + // focus to another origin's frame while the commit waits for the + // editor state. The check must see the element that receives the text. + let target = FakeTextTarget() + target.originAfterPreparation = "https://evil.example" + await #expect(throws: Refused(origin: "https://evil.example")) { + try await target.commit("pw", checkTarget: allowOnly(target)) + } + #expect(!target.log.contains { $0.hasPrefix("insert") || $0.hasPrefix("marked") }) + } + + @Test func aRefusedTargetGetsNothing() async throws { + let target = FakeTextTarget() + target.focusedOrigin = "https://evil.example" + target.richText = false + await #expect(throws: Refused(origin: "https://evil.example")) { + try await target.commit("pw", checkTarget: allowOnly(target)) + } + #expect(!target.log.contains { $0.hasPrefix("insert") || $0.hasPrefix("marked") }) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplTimerSchedulerTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplTimerSchedulerTests.swift new file mode 100644 index 000000000000..b917eb399297 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplTimerSchedulerTests.swift @@ -0,0 +1,186 @@ +import Foundation +import Testing + +@testable import CmuxBrowser + +/// A clock that only moves when a test advances it. +final class BrowserReplManualClock: Clock, @unchecked Sendable { + struct Instant: InstantProtocol { + var offset: Duration + + func advanced(by duration: Duration) -> Instant { Instant(offset: offset + duration) } + func duration(to other: Instant) -> Duration { other.offset - offset } + static func < (lhs: Instant, rhs: Instant) -> Bool { lhs.offset < rhs.offset } + } + + private struct Sleeper { + let deadline: Instant + let continuation: CheckedContinuation + } + + private let lock = NSLock() + private var current = Instant(offset: .zero) + private var sleepers: [UUID: Sleeper] = [:] + + var now: Instant { lock.withLock { current } } + var minimumResolution: Duration { .zero } + + func sleep(until deadline: Instant, tolerance: Duration?) async throws { + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let resumeNow: Bool = lock.withLock { + if Task.isCancelled || deadline <= current { return true } + sleepers[id] = Sleeper(deadline: deadline, continuation: continuation) + return false + } + if resumeNow { + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + } else { + continuation.resume() + } + } + } + } onCancel: { + let sleeper = lock.withLock { sleepers.removeValue(forKey: id) } + sleeper?.continuation.resume(throwing: CancellationError()) + } + } + + func advance(by duration: Duration) { + let due: [Sleeper] = lock.withLock { + current = current.advanced(by: duration) + let ready = sleepers.filter { $0.value.deadline <= current } + for key in ready.keys { sleepers.removeValue(forKey: key) } + return Array(ready.values) + } + for sleeper in due { sleeper.continuation.resume() } + } +} + +/// Collects fired timer ids and lets a test await the next ones. +final class FiredTimers: @unchecked Sendable { + private let lock = NSLock() + private var ids: [Int] = [] + private var waiters: [(count: Int, continuation: CheckedContinuation<[Int], Never>)] = [] + + func record(_ id: Int) { + let ready: [CheckedContinuation<[Int], Never>] + let snapshot: [Int] + (ready, snapshot) = lock.withLock { + ids.append(id) + let satisfied = waiters.filter { $0.count <= ids.count } + waiters.removeAll { $0.count <= ids.count } + return (satisfied.map(\.continuation), ids) + } + for continuation in ready { continuation.resume(returning: snapshot) } + } + + func wait(forCount count: Int) async -> [Int] { + await withCheckedContinuation { continuation in + let snapshot: [Int]? = lock.withLock { + if ids.count >= count { return ids } + waiters.append((count, continuation)) + return nil + } + if let snapshot { continuation.resume(returning: snapshot) } + } + } +} + +@Suite("Browser REPL timer scheduler") +struct BrowserReplTimerSchedulerTests { + @Test("Timers fire in deadline order, ties in scheduling order") + func deadlineOrder() async { + let clock = BrowserReplManualClock() + let fired = FiredTimers() + let scheduler = BrowserReplTimerScheduler(clock: clock) { fired.record($0) } + + scheduler.schedule(id: 1, after: .milliseconds(10), repeating: false) + scheduler.schedule(id: 2, after: .milliseconds(5), repeating: false) + scheduler.schedule(id: 3, after: .milliseconds(10), repeating: false) + clock.advance(by: .milliseconds(10)) + + #expect(await fired.wait(forCount: 3) == [2, 1, 3]) + #expect(scheduler.count == 0) + } + + @Test("A cancelled timer never fires") + func cancelledTimerDoesNotFire() async { + let clock = BrowserReplManualClock() + let fired = FiredTimers() + let scheduler = BrowserReplTimerScheduler(clock: clock) { fired.record($0) } + + scheduler.schedule(id: 1, after: .milliseconds(10), repeating: false) + scheduler.schedule(id: 2, after: .milliseconds(20), repeating: false) + scheduler.cancel(id: 1) + clock.advance(by: .milliseconds(30)) + + #expect(await fired.wait(forCount: 1) == [2]) + #expect(!scheduler.isScheduled(id: 1)) + } + + @Test("Scheduling an earlier timer preempts the sleeping one") + func earlierTimerPreempts() async { + let clock = BrowserReplManualClock() + let fired = FiredTimers() + let scheduler = BrowserReplTimerScheduler(clock: clock) { fired.record($0) } + + scheduler.schedule(id: 1, after: .seconds(60), repeating: false) + scheduler.schedule(id: 2, after: .milliseconds(1), repeating: false) + clock.advance(by: .milliseconds(1)) + + #expect(await fired.wait(forCount: 1) == [2]) + #expect(scheduler.isScheduled(id: 1)) + } + + @Test("An interval re-arms after each delivered fire until cancelled") + func intervalRepeatsUntilCancelled() async { + let clock = BrowserReplManualClock() + let fired = FiredTimers() + let scheduler = BrowserReplTimerScheduler(clock: clock) { fired.record($0) } + + scheduler.schedule(id: 7, after: .milliseconds(10), repeating: true) + clock.advance(by: .milliseconds(10)) + #expect(await fired.wait(forCount: 1) == [7]) + scheduler.delivered(id: 7) + clock.advance(by: .milliseconds(10)) + #expect(await fired.wait(forCount: 2) == [7, 7]) + + scheduler.cancel(id: 7) + scheduler.schedule(id: 8, after: .milliseconds(15), repeating: false) + clock.advance(by: .milliseconds(20)) + #expect(await fired.wait(forCount: 3) == [7, 7, 8]) + } + + @Test("An interval whose last fire has not run yet does not fire again") + func overdueIntervalTicksCoalesce() async { + let clock = BrowserReplManualClock() + let fired = FiredTimers() + let scheduler = BrowserReplTimerScheduler(clock: clock) { fired.record($0) } + + scheduler.schedule(id: 7, after: .milliseconds(10), repeating: true) + clock.advance(by: .milliseconds(10)) + #expect(await fired.wait(forCount: 1) == [7]) + // The JS thread is busy: interval 7's fire is still queued there. + clock.advance(by: .milliseconds(10)) + clock.advance(by: .milliseconds(10)) + scheduler.schedule(id: 8, after: .milliseconds(1), repeating: false) + clock.advance(by: .milliseconds(1)) + + #expect(await fired.wait(forCount: 2) == [7, 8]) + } + + @Test("Invalidation drops pending timers and refuses new ones") + func invalidation() async { + let clock = BrowserReplManualClock() + let fired = FiredTimers() + let scheduler = BrowserReplTimerScheduler(clock: clock) { fired.record($0) } + + scheduler.schedule(id: 1, after: .milliseconds(5), repeating: false) + scheduler.invalidate() + scheduler.schedule(id: 2, after: .milliseconds(5), repeating: false) + #expect(scheduler.count == 0) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplTypedSecretsTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplTypedSecretsTests.swift new file mode 100644 index 000000000000..8fb7062adb1f --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplTypedSecretsTests.swift @@ -0,0 +1,98 @@ +import Testing + +@testable import CmuxBrowser + +/// A secret one session typed into a tab stays masked for every other +/// session that reads that tab (`tabs.use`), which does not hold the secret. +@Suite("Browser REPL typed secrets") +struct BrowserReplTypedSecretsTests { + private static let domains = [try! BrowserReplDomainPattern.parse("https://login.example.com", title: "test")] + + @Test func anotherSessionsReadsMaskATypedSecret() throws { + var typed = BrowserReplTypedSecrets() + typed.record(tab: "tab1", name: "password", value: "hunter2-secret", domains: Self.domains, typist: "typist") + let reader = try #require(typed.redaction(forReader: "reader")) + #expect(reader.redactJSON(#"{"value":"hunter2-secret"}"#) == #"{"value":""}"#) + #expect(reader.redact("q=hunter2%2Dsecret") == "q=") + let masks = typed.captureMasks(forReader: "reader") + #expect(masks.map { $0["value"] as? String } == ["hunter2-secret"]) + #expect((masks.first?["domains"] as? [[String: Any]])?.first?["raw"] as? String == "https://login.example.com") + } + + @Test func theTypingSessionKeepsItsOwnRedaction() { + var typed = BrowserReplTypedSecrets() + typed.record(tab: "tab1", name: "password", value: "hunter2-secret", domains: Self.domains, typist: "typist") + #expect(typed.redaction(forReader: "typist") == nil) + #expect(typed.captureMasks(forReader: "typist").isEmpty) + } + + @Test func aSessionThatLeftNoLongerKeepsItsTypedSecretsToItself() { + var typed = BrowserReplTypedSecrets() + typed.record(tab: "tab1", name: "password", value: "hunter2-secret", domains: Self.domains, typist: "typist") + // A later session with the same name does not hold the secret. + typed.sessionLeft("typist") + #expect(typed.redaction(forReader: "typist")?.redact("hunter2-secret") == "") + } + + @Test func masksEndWhenTheTabCloses() { + var typed = BrowserReplTypedSecrets() + typed.record(tab: "tab1", name: "a", value: "first-value", domains: Self.domains, typist: "typist") + typed.record(tab: "tab2", name: "b", value: "second-value", domains: Self.domains, typist: "typist") + typed.tabClosed("tab1") + let reader = typed.redaction(forReader: "reader") + #expect(reader?.redact("first-value second-value") == "first-value ") + typed.tabClosed("tab2") + #expect(typed.redaction(forReader: "reader") == nil) + } + + /// A typed value is what the field holds: a TOTP secret's typed value + /// is its current code, not a seed. A name that ends in `bu_2fa_code` + /// (reference C's TOTP naming) must not turn the typed code into a seed + /// that is dropped (not base32) or masks other numbers (base32 digits). + @Test(arguments: ["123456", "234567"]) + func aTypedCodeUnderATOTPNameIsMaskedAsTyped(code: String) throws { + var typed = BrowserReplTypedSecrets() + typed.record(tab: "tab1", name: "acct_bu_2fa_code", value: code, domains: Self.domains, typist: "typist") + let reader = try #require(typed.redaction(forReader: "reader"), "a typed code under a TOTP name was not masked for another session") + #expect(reader.redact("code \(code) entered") == "code entered") + #expect(typed.captureMasks(forReader: "reader").map { $0["value"] as? String } == [code]) + } + + /// Two sessions that each hold a secret named `password` type different + /// values into one tab: both values stay masked for a third session. + @Test func sameNamedSecretsOfTwoSessionsInOneTabAreBothMasked() throws { + var typed = BrowserReplTypedSecrets() + typed.record(tab: "tab1", name: "password", value: "first-session-value", domains: Self.domains, typist: "a") + typed.record(tab: "tab1", name: "password", value: "second-session-value", domains: Self.domains, typist: "b") + let reader = try #require(typed.redaction(forReader: "reader")) + #expect(reader.redact("first-session-value second-session-value") == " ") + // Each typist still sees the other's value masked, and its own not. + #expect(typed.redaction(forReader: "a")?.redact("first-session-value second-session-value") == "first-session-value ") + #expect(typed.captureMasks(forReader: "reader").count == 2) + } + + /// One session types a secret of one name into two tabs: both values + /// stay masked. + @Test func sameNamedSecretsInTwoTabsAreBothMasked() throws { + var typed = BrowserReplTypedSecrets() + typed.record(tab: "tab1", name: "password", value: "value-in-tab-one", domains: Self.domains, typist: "a") + typed.record(tab: "tab2", name: "password", value: "value-in-tab-two", domains: Self.domains, typist: "a") + let reader = try #require(typed.redaction(forReader: "reader")) + #expect(reader.redact("value-in-tab-one value-in-tab-two") == " ") + } + + /// Redaction runs on every result and event; its store (whose patterns + /// compile on creation) is built once per change of the typed values. + @Test func theRedactionStoreIsReusedUntilTheTypedValuesChange() throws { + var typed = BrowserReplTypedSecrets() + typed.record(tab: "tab1", name: "password", value: "hunter2-secret", domains: Self.domains, typist: "typist") + let first = try #require(typed.redaction(forReader: "reader")) + #expect(typed.redaction(forReader: "reader") === first, "the redaction store was rebuilt without a change") + typed.record(tab: "tab2", name: "token", value: "another-secret", domains: Self.domains, typist: "typist") + let second = try #require(typed.redaction(forReader: "reader")) + #expect(second !== first) + #expect(second.redact("hunter2-secret another-secret") == " ") + typed.sessionLeft("typist") + #expect(typed.redaction(forReader: "typist")?.redact("hunter2-secret") == "") + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplWorkspaceBindingTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplWorkspaceBindingTests.swift new file mode 100644 index 000000000000..ae781fab0bac --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Repl/BrowserReplWorkspaceBindingTests.swift @@ -0,0 +1,44 @@ +import Foundation +import Testing +@testable import CmuxBrowser + +@Suite("Browser REPL workspace binding") +struct BrowserReplWorkspaceBindingTests { + private let known = UUID() + private let focusedWorkspace = UUID() + private let foreign = UUID() + + private func resolve(explicit: UUID? = nil, caller: UUID? = nil, focused: UUID?) -> Result { + let known = [self.known, focusedWorkspace] + return BrowserReplWorkspaceBinding( + exists: { known.contains($0) }, + focused: { focused } + ).resolve(explicit: explicit, caller: caller) + } + + @Test("The caller's workspace wins when this instance knows it") + func callerWorkspaceKnown() { + #expect(resolve(caller: known, focused: focusedWorkspace) == .success(known)) + } + + @Test("A caller workspace from another cmux instance falls back to the focused workspace") + func callerWorkspaceUnknownFallsBack() { + #expect(resolve(caller: foreign, focused: focusedWorkspace) == .success(focusedWorkspace)) + } + + @Test("A caller outside cmux binds to the focused workspace") + func noCallerUsesFocused() { + #expect(resolve(focused: focusedWorkspace) == .success(focusedWorkspace)) + } + + @Test("An explicit workspace must exist and never falls back") + func explicitWorkspace() { + #expect(resolve(explicit: known, caller: focusedWorkspace, focused: focusedWorkspace) == .success(known)) + #expect(resolve(explicit: foreign, focused: focusedWorkspace) == .failure(.explicitWorkspaceNotFound(foreign))) + } + + @Test("Without a focused workspace the fallback fails") + func noFocusedWorkspace() { + #expect(resolve(caller: foreign, focused: nil) == .failure(.noFocusedWorkspace)) + } +} diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Billing/ProUpgradeSource.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Billing/ProUpgradeSource.swift index 87fda61fd044..4bf8574b4312 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Billing/ProUpgradeSource.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Billing/ProUpgradeSource.swift @@ -22,6 +22,8 @@ public enum ProUpgradeSource: String, CaseIterable, Sendable { case settingsCloudMachines = "mac_settings_cloud_machines" /// Machines panel empty state: plan does not include Cloud machines. case machinesPanelRequiresPro = "mac_machines_panel_requires_pro" + /// "Introducing cmux Cloud" welcome window, Free plan. + case cloudWelcome = "mac_cloud_welcome" /// Machines panel nudge under the create button. case machinesPanelUpgradeNudge = "mac_machines_panel_upgrade_nudge" /// Machines panel free-access countdown / expired banner. @@ -34,6 +36,8 @@ public enum ProUpgradeSource: String, CaseIterable, Sendable { case newMachineSheetMaxUpgrade = "mac_new_machine_sheet_max_upgrade" /// Link inside the `vm_memory_requires_plan` error text (`VMClient`). case vmMemoryRequiresPlanError = "mac_vm_memory_requires_plan_error" + /// Max checkout link inside the `vm_resource_pool_exceeded` error text (`VMClient`). + case vmResourcePoolExceededError = "mac_vm_resource_pool_exceeded_error" /// DEBUG native pricing window. case nativePricingPreview = "mac_native_pricing_preview" /// Link inside the `vm_requires_pro` error text (`VMClient`); the token diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Display/CloudDisplayCoordinator.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Display/CloudDisplayCoordinator.swift index 43a7e461a692..a4dda18f50cf 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Display/CloudDisplayCoordinator.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Display/CloudDisplayCoordinator.swift @@ -24,6 +24,13 @@ public final class CloudDisplayCoordinator { } public var canCreate: Bool { isAvailable && (snapshot?.canCreate == true || requestID != nil) && creation == nil } + + /// Creation needs no prior discovery: the guest's create reply is the + /// authoritative catalog. Only a validated catalog that says the machine is + /// full blocks a new request (a retained request id may still be retried). + var allowsCreateRequest: Bool { + requestID != nil || !(isAvailable && snapshot?.canCreate == false) + } public var displaySnapshot: CloudGuestDisplaySnapshot? { snapshot ?? lastValidatedSnapshot } public func refresh() async { @@ -77,9 +84,12 @@ public final class CloudDisplayCoordinator { creation.cancel() } } - guard isAvailable, snapshot?.canCreate == true || requestID != nil else { + guard allowsCreateRequest else { throw SurfaceCatalogError.unsupported(CloudGuestDisplaySnapshot.unavailableMessage) } + // Creation supersedes any in-flight discovery; its reply carries the catalog. + refreshTask?.cancel() + refreshTask = nil generation &+= 1 let token = generation let request = requestID ?? UUID() @@ -94,6 +104,8 @@ public final class CloudDisplayCoordinator { guard let self, self.generation == token else { throw CancellationError() } self.snapshot = snapshot self.lastValidatedSnapshot = snapshot + self.isAvailable = true + self.hasAttemptedDiscovery = true guard response.exitCode == 0, snapshot.error == nil, snapshot.created != nil else { throw SurfaceCatalogError.unsupported(String(localized: "cloud.display.creationFailed", defaultValue: "The new display could not start. Refresh Displays, then retry. Existing displays are unchanged.")) } diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Display/CloudGuestDisplayScript.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Display/CloudGuestDisplayScript.swift index f7f1d309bbb8..e2da31c349cb 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Display/CloudGuestDisplayScript.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Display/CloudGuestDisplayScript.swift @@ -13,300 +13,331 @@ Y2Vzc2libGUgdG8gaXRzIHdvcmsgdXNlci4KIiIiCmltcG9ydCBhcmdwYXJzZQppbXBvcnQgZmNudGwK ZW50CmltcG9ydCBqc29uCmltcG9ydCBvcwpmcm9tIHBhdGhsaWIgaW1wb3J0IFBhdGgKaW1wb3J0IHJlCmltcG9ydCBzaHV0aWwK aW1wb3J0IHNvY2tldAppbXBvcnQgc29ja2V0c2VydmVyCmltcG9ydCBzdWJwcm9jZXNzCmltcG9ydCB0ZW1wZmlsZQppbXBvcnQg dGhyZWFkaW5nCmltcG9ydCB0aW1lCmltcG9ydCB1dWlkCgpNQVhfRElTUExBWVMgPSAxNgpTVEFSVEVSID0gIi91c3IvbG9jYWwv -YmluL3N0YXJ0LXZuYy5zaCIKCgpkZWYgZGVzY3JpcHRvcihudW1iZXIsIHN0YXRlPSJydW5uaW5nIik6CiAgICByZXR1cm4geyJp -ZCI6IGYiZGlzcGxheTp7bnVtYmVyfSIsICJudW1iZXIiOiBudW1iZXIsCiAgICAgICAgICAgICJwb3J0IjogNjkwMCArIG51bWJl -ciwgInN0YXRlIjogc3RhdGV9CgoKZGVmIGxpc3RlbmluZyhwb3J0KToKICAgIHRyeToKICAgICAgICB3aXRoIHNvY2tldC5jcmVh -dGVfY29ubmVjdGlvbigoIjEyNy4wLjAuMSIsIHBvcnQpLCB0aW1lb3V0PTAuMyk6CiAgICAgICAgICAgIHJldHVybiBUcnVlCiAg -ICBleGNlcHQgT1NFcnJvcjoKICAgICAgICByZXR1cm4gRmFsc2UKCgpkZWYgcmZiX3JlYWR5KG51bWJlcik6CiAgICAiIiJBIGJv -dW5kIEhUVFAgcG9ydCBhbG9uZSBkb2VzIG5vdCBwcm92ZSB0aGF0IGFuIFgvUkZCIHNlcnZlciBleGlzdHMuIiIiCiAgICB0cnk6 -CiAgICAgICAgd2l0aCBzb2NrZXQuY3JlYXRlX2Nvbm5lY3Rpb24oKCIxMjcuMC4wLjEiLCA1OTAwICsgbnVtYmVyKSwgdGltZW91 -dD0wLjUpIGFzIHJmYjoKICAgICAgICAgICAgdmVyc2lvbiA9IGIiIgogICAgICAgICAgICB3aGlsZSBsZW4odmVyc2lvbikgPCAx -MjoKICAgICAgICAgICAgICAgIHBhcnQgPSByZmIucmVjdigxMiAtIGxlbih2ZXJzaW9uKSkKICAgICAgICAgICAgICAgIGlmIG5v -dCBwYXJ0OgogICAgICAgICAgICAgICAgICAgIHJldHVybiBGYWxzZQogICAgICAgICAgICAgICAgdmVyc2lvbiArPSBwYXJ0CiAg -ICAgICAgICAgIGlmIG5vdCB2ZXJzaW9uLnN0YXJ0c3dpdGgoYiJSRkIgIik6CiAgICAgICAgICAgICAgICByZXR1cm4gRmFsc2UK -ICAgIGV4Y2VwdCAoT1NFcnJvciwgaHR0cC5jbGllbnQuSFRUUEV4Y2VwdGlvbik6CiAgICAgICAgcmV0dXJuIEZhbHNlCiAgICBy -ZXR1cm4gVHJ1ZQoKCmRlZiBub3ZuY19yZWFkeShudW1iZXIpOgogICAgdHJ5OgogICAgICAgIGNvbm5lY3Rpb24gPSBodHRwLmNs -aWVudC5IVFRQQ29ubmVjdGlvbigiMTI3LjAuMC4xIiwgNjkwMCArIG51bWJlciwgdGltZW91dD0wLjUpCiAgICAgICAgdHJ5Ogog -ICAgICAgICAgICBjb25uZWN0aW9uLnJlcXVlc3QoIkhFQUQiLCAiL3ZuYy5odG1sIikKICAgICAgICAgICAgcmV0dXJuIGNvbm5l -Y3Rpb24uZ2V0cmVzcG9uc2UoKS5zdGF0dXMgPT0gMjAwCiAgICAgICAgZmluYWxseToKICAgICAgICAgICAgY29ubmVjdGlvbi5j -bG9zZSgpCiAgICBleGNlcHQgKE9TRXJyb3IsIGh0dHAuY2xpZW50LkhUVFBFeGNlcHRpb24pOgogICAgICAgIHJldHVybiBGYWxz -ZQoKCmRlZiByZWFkeShudW1iZXIpOgogICAgIiIiQSBib3VuZCBlbmRwb2ludCBhbG9uZSBkb2VzIG5vdCBwcm92ZSB0aGF0IHRo -ZSBjb21wbGV0ZSBkZXNrdG9wIGV4aXN0cy4iIiIKICAgIHJldHVybiByZmJfcmVhZHkobnVtYmVyKSBhbmQgbm92bmNfcmVhZHko -bnVtYmVyKQoKCmNsYXNzIERpc3BsYXlDYXRhbG9nOgogICAgIiIiU2VyaWFsaXplcyBhbGxvY2F0aW9uIGFuZCBwZXJzaXN0cyBy -ZXF1ZXN0IHJlY2VpcHRzIGJlZm9yZSBzdGFydGluZyB3b3JrLiIiIgoKICAgIGRlZiBfX2luaXRfXyhzZWxmLCBkaXJlY3Rvcnks -IG9jY3VwaWVkPU5vbmUpOgogICAgICAgIHNlbGYuZGlyZWN0b3J5ID0gUGF0aChkaXJlY3RvcnkpCiAgICAgICAgc2VsZi5kaXJl -Y3RvcnkubWtkaXIobW9kZT0wbzcwMCwgcGFyZW50cz1UcnVlLCBleGlzdF9vaz1UcnVlKQogICAgICAgIHNlbGYucGF0aCA9IHNl -bGYuZGlyZWN0b3J5IC8gImNhdGFsb2cuanNvbiIKICAgICAgICBzZWxmLmxvY2sgPSB0aHJlYWRpbmcuUkxvY2soKQogICAgICAg -IHNlbGYub2NjdXBpZWQgPSBvY2N1cGllZCBvciAobGFtYmRhIG46IFBhdGgoZiIvdG1wLy5YMTEtdW5peC9Ye259IikuZXhpc3Rz -KCkKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIG9yIGxpc3RlbmluZyg1OTAwICsgbikgb3IgbGlzdGVuaW5n -KDY5MDAgKyBuKSkKICAgICAgICBzZWxmLnJlY29yZHMgPSBbXQogICAgICAgIGlmIHNlbGYucGF0aC5leGlzdHMoKToKICAgICAg -ICAgICAgZGF0YSA9IGpzb24ubG9hZHMoc2VsZi5wYXRoLnJlYWRfdGV4dCgpKQogICAgICAgICAgICBpZiBkYXRhLmdldCgidmVy -c2lvbiIpICE9IDE6CiAgICAgICAgICAgICAgICByYWlzZSBWYWx1ZUVycm9yKCJ1bnN1cHBvcnRlZCBkaXNwbGF5IGNhdGFsb2cg -dmVyc2lvbiIpCiAgICAgICAgICAgIHNlZW5fbnVtYmVycywgc2Vlbl9yZXF1ZXN0cyA9IHNldCgpLCBzZXQoKQogICAgICAgICAg -ICBmb3IgcmVjb3JkIGluIGRhdGFbImRpc3BsYXlzIl06CiAgICAgICAgICAgICAgICBudW1iZXIsIHJlcXVlc3QgPSByZWNvcmRb -Im51bWJlciJdLCByZWNvcmRbInJlcXVlc3QiXQogICAgICAgICAgICAgICAgaWYgdHlwZShudW1iZXIpIGlzIG5vdCBpbnQgb3Ig -bm90IDIgPD0gbnVtYmVyIDw9IE1BWF9ESVNQTEFZUzoKICAgICAgICAgICAgICAgICAgICByYWlzZSBWYWx1ZUVycm9yKCJpbnZh -bGlkIGRpc3BsYXkgbnVtYmVyIikKICAgICAgICAgICAgICAgIGlmIHN0cih1dWlkLlVVSUQocmVxdWVzdCkpICE9IHJlcXVlc3Qg -b3IgbnVtYmVyIGluIHNlZW5fbnVtYmVycyBvciByZXF1ZXN0IGluIHNlZW5fcmVxdWVzdHM6CiAgICAgICAgICAgICAgICAgICAg -cmFpc2UgVmFsdWVFcnJvcigiaW52YWxpZCBkaXNwbGF5IGlkZW50aXR5IikKICAgICAgICAgICAgICAgIHNlZW5fbnVtYmVycy5h -ZGQobnVtYmVyKQogICAgICAgICAgICAgICAgc2Vlbl9yZXF1ZXN0cy5hZGQocmVxdWVzdCkKICAgICAgICAgICAgICAgIHNlbGYu -cmVjb3Jkcy5hcHBlbmQoeyJudW1iZXIiOiBudW1iZXIsICJyZXF1ZXN0IjogcmVxdWVzdH0pCgogICAgZGVmIG51bWJlcnMoc2Vs -Zik6CiAgICAgICAgd2l0aCBzZWxmLmxvY2s6CiAgICAgICAgICAgIHJldHVybiBzb3J0ZWQocmVjb3JkWyJudW1iZXIiXSBmb3Ig -cmVjb3JkIGluIHNlbGYucmVjb3JkcykKCiAgICBkZWYgYWxsb2NhdGUoc2VsZiwgcmVxdWVzdCk6CiAgICAgICAgaWYgbm90IGlz -aW5zdGFuY2UocmVxdWVzdCwgc3RyKSBvciBzdHIodXVpZC5VVUlEKHJlcXVlc3QpKSAhPSByZXF1ZXN0OgogICAgICAgICAgICBy -YWlzZSBWYWx1ZUVycm9yKCJpbnZhbGlkIHJlcXVlc3QgaWRlbnRpdHkiKQogICAgICAgIHdpdGggc2VsZi5sb2NrOgogICAgICAg -ICAgICBmb3IgcmVjb3JkIGluIHNlbGYucmVjb3JkczoKICAgICAgICAgICAgICAgIGlmIHJlY29yZFsicmVxdWVzdCJdID09IHJl -cXVlc3Q6CiAgICAgICAgICAgICAgICAgICAgcmV0dXJuIHJlY29yZFsibnVtYmVyIl0KICAgICAgICAgICAgdXNlZCA9IHNldChz -ZWxmLm51bWJlcnMoKSkKICAgICAgICAgICAgbnVtYmVyID0gbmV4dCgobiBmb3IgbiBpbiByYW5nZSgyLCBNQVhfRElTUExBWVMg -KyAxKQogICAgICAgICAgICAgICAgICAgICAgICAgICBpZiBuIG5vdCBpbiB1c2VkIGFuZCBub3Qgc2VsZi5vY2N1cGllZChuKSks -IE5vbmUpCiAgICAgICAgICAgIGlmIG51bWJlciBpcyBOb25lOgogICAgICAgICAgICAgICAgcmFpc2UgVmFsdWVFcnJvcigiZGlz -cGxheSBjYXBhY2l0eSBleGhhdXN0ZWQiKQogICAgICAgICAgICByZWNvcmRzID0gc2VsZi5yZWNvcmRzICsgW3sibnVtYmVyIjog -bnVtYmVyLCAicmVxdWVzdCI6IHJlcXVlc3R9XQogICAgICAgICAgICBmZCwgdGVtcG9yYXJ5ID0gdGVtcGZpbGUubWtzdGVtcChw -cmVmaXg9ImNhdGFsb2ctIiwgZGlyPXNlbGYuZGlyZWN0b3J5KQogICAgICAgICAgICB0cnk6CiAgICAgICAgICAgICAgICB3aXRo -IG9zLmZkb3BlbihmZCwgInciKSBhcyBvdXRwdXQ6CiAgICAgICAgICAgICAgICAgICAganNvbi5kdW1wKHsidmVyc2lvbiI6IDEs -ICJkaXNwbGF5cyI6IHJlY29yZHN9LCBvdXRwdXQpCiAgICAgICAgICAgICAgICAgICAgb3V0cHV0LmZsdXNoKCkKICAgICAgICAg -ICAgICAgICAgICBvcy5mc3luYyhvdXRwdXQuZmlsZW5vKCkpCiAgICAgICAgICAgICAgICBvcy5yZXBsYWNlKHRlbXBvcmFyeSwg -c2VsZi5wYXRoKQogICAgICAgICAgICBmaW5hbGx5OgogICAgICAgICAgICAgICAgaWYgb3MucGF0aC5leGlzdHModGVtcG9yYXJ5 -KToKICAgICAgICAgICAgICAgICAgICBvcy51bmxpbmsodGVtcG9yYXJ5KQogICAgICAgICAgICBzZWxmLnJlY29yZHMgPSByZWNv -cmRzCiAgICAgICAgICAgIHJldHVybiBudW1iZXIKCgpjbGFzcyBPd25lZFByb2Nlc3M6CiAgICAiIiJBIGhhbmRsZSBmb3IgYSBk -YWVtb24gc3RhcnRlZCBieSBkYnVzLWxhdW5jaCByYXRoZXIgdGhhbiBieSBQb3Blbi4iIiIKCiAgICBkZWYgX19pbml0X18oc2Vs -ZiwgcGlkKToKICAgICAgICBzZWxmLnBpZCA9IHBpZAoKICAgIGRlZiB0ZXJtaW5hdGUoc2VsZik6CiAgICAgICAgdHJ5OgogICAg -ICAgICAgICBvcy5raWxsKHNlbGYucGlkLCAxNSkKICAgICAgICBleGNlcHQgT1NFcnJvcjoKICAgICAgICAgICAgcGFzcwoKICAg -IGRlZiBwb2xsKHNlbGYpOgogICAgICAgIHRyeToKICAgICAgICAgICAgb3Mua2lsbChzZWxmLnBpZCwgMCkKICAgICAgICAgICAg -cmV0dXJuIE5vbmUKICAgICAgICBleGNlcHQgT1NFcnJvcjoKICAgICAgICAgICAgcmV0dXJuIDAKCgpjbGFzcyBOb1ZOQ1VuYXZh -aWxhYmxlKFJ1bnRpbWVFcnJvcik6CiAgICAiIiJUaGUgdmlld2luZyB0cmFuc3BvcnQgZmFpbGVkIHdoaWxlIHRoZSBndWVzdCBk -ZXNrdG9wIG1heSBiZSBoZWFsdGh5LiIiIgoKCmNsYXNzIERpc3BsYXlTZXJ2aWNlOgogICAgZGVmIF9faW5pdF9fKHNlbGYsIGNh -dGFsb2csIHJ1bnRpbWUsIHN0YXJ0ZXI9U1RBUlRFUik6CiAgICAgICAgc2VsZi5jYXRhbG9nID0gY2F0YWxvZwogICAgICAgIHNl -bGYucnVudGltZSA9IFBhdGgocnVudGltZSkKICAgICAgICBzZWxmLnN0YXJ0ZXIgPSBzdGFydGVyCiAgICAgICAgc2VsZi5sb2Nr -ID0gdGhyZWFkaW5nLkxvY2soKQogICAgICAgIHNlbGYuam9icyA9IHt9CiAgICAgICAgc2VsZi5zdGF0ZXMgPSB7fQogICAgICAg -IHNlbGYucHJvY2Vzc2VzID0ge30KICAgICAgICBzZWxmLm5hbWVkX3Byb2Nlc3NlcyA9IHt9CiAgICAgICAgc2VsZi53ZWJzb2Nr -aWZ5X3Byb2Nlc3NlcyA9IHt9CiAgICAgICAgc2VsZi5zaHV0ZG93biA9IHRocmVhZGluZy5FdmVudCgpCgogICAgZGVmIHN0YXJ0 -KHNlbGYsIG51bWJlcik6CiAgICAgICAgd2l0aCBzZWxmLmxvY2s6CiAgICAgICAgICAgIGlmIG51bWJlciBpbiBzZWxmLmpvYnM6 -CiAgICAgICAgICAgICAgICByZXR1cm4gc2VsZi5qb2JzW251bWJlcl0KICAgICAgICAgICAgZmluaXNoZWQgPSB0aHJlYWRpbmcu -RXZlbnQoKQogICAgICAgICAgICBzZWxmLmpvYnNbbnVtYmVyXSA9IGZpbmlzaGVkCiAgICAgICAgICAgIHNlbGYuc3RhdGVzW251 -bWJlcl0gPSAibGF1bmNoaW5nIgogICAgICAgICAgICB0aHJlYWRpbmcuVGhyZWFkKHRhcmdldD1zZWxmLnN1cGVydmlzZSwgYXJn -cz0obnVtYmVyLCBmaW5pc2hlZCksIGRhZW1vbj1UcnVlKS5zdGFydCgpCiAgICAgICAgICAgIHJldHVybiBmaW5pc2hlZAoKICAg -IGRlZiB0ZXJtaW5hdGUoc2VsZiwgbnVtYmVyKToKICAgICAgICBmb3IgcHJvY2VzcyBpbiBzZWxmLnByb2Nlc3Nlcy5wb3AobnVt -YmVyLCBbXSk6CiAgICAgICAgICAgIHRyeToKICAgICAgICAgICAgICAgIHByb2Nlc3MudGVybWluYXRlKCkKICAgICAgICAgICAg -ZXhjZXB0IE9TRXJyb3I6CiAgICAgICAgICAgICAgICBwYXNzCiAgICAgICAgc2VsZi5uYW1lZF9wcm9jZXNzZXMucG9wKG51bWJl -ciwgTm9uZSkKICAgICAgICBzZWxmLnRlcm1pbmF0ZV93ZWJzb2NraWZ5KG51bWJlcikKICAgICAgICB0cnk6CiAgICAgICAgICAg -IChzZWxmLnJ1bnRpbWUgLyBzdHIobnVtYmVyKSAvICJzZXNzaW9uLmpzb24iKS51bmxpbmsoKQogICAgICAgIGV4Y2VwdCBGaWxl -Tm90Rm91bmRFcnJvcjoKICAgICAgICAgICAgcGFzcwoKICAgIGRlZiB0ZXJtaW5hdGVfd2Vic29ja2lmeShzZWxmLCBudW1iZXIp -OgogICAgICAgIHByb2Nlc3MgPSBzZWxmLndlYnNvY2tpZnlfcHJvY2Vzc2VzLnBvcChudW1iZXIsIE5vbmUpCiAgICAgICAgaWYg -cHJvY2VzcyBpcyBOb25lOgogICAgICAgICAgICByZXR1cm4KICAgICAgICB0cnk6CiAgICAgICAgICAgIHByb2Nlc3MudGVybWlu -YXRlKCkKICAgICAgICBleGNlcHQgT1NFcnJvcjoKICAgICAgICAgICAgcGFzcwoKICAgIEBzdGF0aWNtZXRob2QKICAgIGRlZiBz -Y29wZWRfY29tcG9uZW50X3BhdGgoZXhlY3V0YWJsZSwgcGF0aCwgbnVtYmVyLCBydW50aW1lKToKICAgICAgICAiIiJHaXZlIHBl -ci1kaXNwbGF5IGRlc2t0b3AgY2xpZW50cyBuYW1lcyB0aGUgOjEgc3VwZXJ2aXNvciBjYW5ub3QgbWF0Y2guIiIiCiAgICAgICAg -c291cmNlID0gUGF0aChwYXRoKQogICAgICAgIGlmIG5vdCBzb3VyY2UuaXNfZmlsZSgpOgogICAgICAgICAgICByZXR1cm4gcGF0 -aAogICAgICAgIGRpcmVjdG9yeSA9IFBhdGgocnVudGltZSkgLyAiYmluIgogICAgICAgIGRpcmVjdG9yeS5ta2Rpcihtb2RlPTBv -NzAwLCBwYXJlbnRzPVRydWUsIGV4aXN0X29rPVRydWUpCiAgICAgICAgdGFyZ2V0ID0gZGlyZWN0b3J5IC8gZiJjbXV4LWRpc3Bs -YXkte251bWJlcn0te3NvdXJjZS5uYW1lfSIKICAgICAgICBpZiBub3QgdGFyZ2V0LmV4aXN0cygpIG9yIHRhcmdldC5zdGF0KCku -c3RfbXRpbWVfbnMgPCBzb3VyY2Uuc3RhdCgpLnN0X210aW1lX25zOgogICAgICAgICAgICBzaHV0aWwuY29weTIoc291cmNlLCB0 -YXJnZXQpCiAgICAgICAgICAgIHRhcmdldC5jaG1vZCgwbzcwMCkKICAgICAgICByZXR1cm4gc3RyKHRhcmdldCkKCiAgICBAc3Rh -dGljbWV0aG9kCiAgICBkZWYgd2FpdF9mb3JfcG9ydChwb3J0LCB0aW1lb3V0PTIwKToKICAgICAgICBkZWFkbGluZSA9IHRpbWUu -bW9ub3RvbmljKCkgKyB0aW1lb3V0CiAgICAgICAgd2hpbGUgdGltZS5tb25vdG9uaWMoKSA8IGRlYWRsaW5lOgogICAgICAgICAg -ICB0cnk6CiAgICAgICAgICAgICAgICB3aXRoIHNvY2tldC5jcmVhdGVfY29ubmVjdGlvbigoIjEyNy4wLjAuMSIsIHBvcnQpLCB0 -aW1lb3V0PTAuNSk6CiAgICAgICAgICAgICAgICAgICAgcmV0dXJuIFRydWUKICAgICAgICAgICAgZXhjZXB0IE9TRXJyb3I6CiAg -ICAgICAgICAgICAgICB0aW1lLnNsZWVwKDAuMSkKICAgICAgICByZXR1cm4gRmFsc2UKCiAgICBAc3RhdGljbWV0aG9kCiAgICBk -ZWYgcHJvY2Vzc19hbGl2ZShwcm9jZXNzKToKICAgICAgICBwb2xsID0gZ2V0YXR0cihwcm9jZXNzLCAicG9sbCIsIE5vbmUpCiAg -ICAgICAgcmV0dXJuIHBvbGwgaXMgTm9uZSBvciBwb2xsKCkgaXMgTm9uZQoKICAgIGRlZiBjb21wb25lbnRzX2FsaXZlKHNlbGYs -IG51bWJlcik6CiAgICAgICAgbmFtZWQgPSBzZWxmLm5hbWVkX3Byb2Nlc3Nlcy5nZXQobnVtYmVyKQogICAgICAgIGlmIG5hbWVk -IGlzIG5vdCBOb25lOgogICAgICAgICAgICByZXR1cm4gYm9vbChuYW1lZCkgYW5kIGFsbChzZWxmLnByb2Nlc3NfYWxpdmUocHJv -Y2VzcykgZm9yIHByb2Nlc3MgaW4gbmFtZWQudmFsdWVzKCkpIFwKICAgICAgICAgICAgICAgIGFuZCAoInh2bmMiIGluIG5hbWVk -IG9yIHJmYl9yZWFkeShudW1iZXIpKQogICAgICAgIHByb2Nlc3NlcyA9IHNlbGYucHJvY2Vzc2VzLmdldChudW1iZXIsIFtdKQog -ICAgICAgIHJldHVybiBib29sKHByb2Nlc3NlcykgYW5kIGFsbChzZWxmLnByb2Nlc3NfYWxpdmUocHJvY2VzcykgZm9yIHByb2Nl -c3MgaW4gcHJvY2Vzc2VzKQoKICAgIGRlZiBzZXJ2aWNlX3JlYWR5KHNlbGYsIG51bWJlcik6CiAgICAgICAgaWYgbm90IHJlYWR5 -KG51bWJlcikgb3Igbm90IHNlbGYuY29tcG9uZW50c19hbGl2ZShudW1iZXIpOgogICAgICAgICAgICByZXR1cm4gRmFsc2UKICAg -ICAgICB3ZWJzb2NraWZ5ID0gc2VsZi53ZWJzb2NraWZ5X3Byb2Nlc3Nlcy5nZXQobnVtYmVyKQogICAgICAgIHJldHVybiB3ZWJz -b2NraWZ5IGlzIE5vbmUgb3Igc2VsZi5wcm9jZXNzX2FsaXZlKHdlYnNvY2tpZnkpCgogICAgZGVmIHRyYWNrX3Byb2Nlc3Moc2Vs -ZiwgbnVtYmVyLCBuYW1lLCBwcm9jZXNzKToKICAgICAgICBuYW1lZCA9IHNlbGYubmFtZWRfcHJvY2Vzc2VzLnNldGRlZmF1bHQo -bnVtYmVyLCB7fSkKICAgICAgICBwcmV2aW91cyA9IG5hbWVkLmdldChuYW1lKQogICAgICAgIGlmIHByZXZpb3VzIGlzIG5vdCBO -b25lOgogICAgICAgICAgICBzZWxmLnByb2Nlc3Nlc1tudW1iZXJdID0gWwogICAgICAgICAgICAgICAgaXRlbSBmb3IgaXRlbSBp -biBzZWxmLnByb2Nlc3Nlcy5nZXQobnVtYmVyLCBbXSkgaWYgaXRlbSBpcyBub3QgcHJldmlvdXMKICAgICAgICAgICAgXQogICAg -ICAgIHNlbGYucHJvY2Vzc2VzLnNldGRlZmF1bHQobnVtYmVyLCBbXSkuYXBwZW5kKHByb2Nlc3MpCiAgICAgICAgbmFtZWRbbmFt -ZV0gPSBwcm9jZXNzCgogICAgZGVmIHJlbW92ZV9wcm9jZXNzKHNlbGYsIG51bWJlciwgbmFtZSk6CiAgICAgICAgcHJvY2VzcyA9 -IHNlbGYubmFtZWRfcHJvY2Vzc2VzLnNldGRlZmF1bHQobnVtYmVyLCB7fSkucG9wKG5hbWUsIE5vbmUpCiAgICAgICAgaWYgcHJv -Y2VzcyBpcyBOb25lOgogICAgICAgICAgICByZXR1cm4KICAgICAgICBzZWxmLnByb2Nlc3Nlc1tudW1iZXJdID0gW2l0ZW0gZm9y -IGl0ZW0gaW4gc2VsZi5wcm9jZXNzZXMuZ2V0KG51bWJlciwgW10pIGlmIGl0ZW0gaXMgbm90IHByb2Nlc3NdCiAgICAgICAgdHJ5 -OgogICAgICAgICAgICBwcm9jZXNzLnRlcm1pbmF0ZSgpCiAgICAgICAgZXhjZXB0IE9TRXJyb3I6CiAgICAgICAgICAgIHBhc3MK -CiAgICBkZWYgcmVjb3Zlcl9wcm9jZXNzZXMoc2VsZiwgbnVtYmVyLCBydW50aW1lLCBlbnZpcm9ubWVudCk6CiAgICAgICAgc2Vs -Zi5wcm9jZXNzZXNbbnVtYmVyXSA9IFtdCiAgICAgICAgc2VsZi5uYW1lZF9wcm9jZXNzZXNbbnVtYmVyXSA9IHt9CiAgICAgICAg -c2Vzc2lvbiA9IFBhdGgocnVudGltZSkgLyAic2Vzc2lvbi5qc29uIgogICAgICAgIHRyeToKICAgICAgICAgICAgc2F2ZWQgPSBq -c29uLmxvYWRzKHNlc3Npb24ucmVhZF90ZXh0KCkpCiAgICAgICAgICAgIGRidXNfcGlkID0gaW50KHNhdmVkLmdldCgicGlkIiwg -MCkpCiAgICAgICAgICAgIGRidXMgPSBPd25lZFByb2Nlc3MoZGJ1c19waWQpCiAgICAgICAgICAgIGlmIGRidXNfcGlkID4gMSBh -bmQgc2VsZi5wcm9jZXNzX2FsaXZlKGRidXMpOgogICAgICAgICAgICAgICAgaWYgc2F2ZWQuZ2V0KCJhZGRyZXNzIik6CiAgICAg -ICAgICAgICAgICAgICAgZW52aXJvbm1lbnRbIkRCVVNfU0VTU0lPTl9CVVNfQUREUkVTUyJdID0gc2F2ZWRbImFkZHJlc3MiXQog -ICAgICAgICAgICAgICAgZW52aXJvbm1lbnRbIkRCVVNfU0VTU0lPTl9CVVNfUElEIl0gPSBzdHIoZGJ1c19waWQpCiAgICAgICAg -ICAgICAgICBzZWxmLnRyYWNrX3Byb2Nlc3MobnVtYmVyLCAiZGJ1cyIsIGRidXMpCiAgICAgICAgZXhjZXB0IChPU0Vycm9yLCBW -YWx1ZUVycm9yLCBUeXBlRXJyb3IsIGpzb24uSlNPTkRlY29kZUVycm9yKToKICAgICAgICAgICAgcGFzcwogICAgICAgIHVpZCA9 -IHN0cihvcy5nZXR1aWQoKSkKICAgICAgICBmb3IgbmFtZSBpbiAoIm9wZW5ib3giLCAidGludDIiLCAidm5jY29uZmlnIik6CiAg -ICAgICAgICAgIG1hcmtlciA9IHN0cihQYXRoKHJ1bnRpbWUpIC8gImJpbiIgLyBmImNtdXgtZGlzcGxheS17bnVtYmVyfS17bmFt -ZX0iKQogICAgICAgICAgICB0cnk6CiAgICAgICAgICAgICAgICBwYXR0ZXJuID0gcmYiXntyZS5lc2NhcGUobWFya2VyKX0oPzpb -WzpzcGFjZTpdXXwkKSIKICAgICAgICAgICAgICAgIG91dHB1dCA9IHN1YnByb2Nlc3MuY2hlY2tfb3V0cHV0KFsicGdyZXAiLCAi -LXUiLCB1aWQsICItZiIsIHBhdHRlcm5dLCB0ZXh0PVRydWUpCiAgICAgICAgICAgICAgICBwaWQgPSBpbnQob3V0cHV0LnNwbGl0 -bGluZXMoKVswXSkKICAgICAgICAgICAgZXhjZXB0IChPU0Vycm9yLCBWYWx1ZUVycm9yLCBJbmRleEVycm9yLCBzdWJwcm9jZXNz -LkNhbGxlZFByb2Nlc3NFcnJvcik6CiAgICAgICAgICAgICAgICBjb250aW51ZQogICAgICAgICAgICBzZWxmLnRyYWNrX3Byb2Nl -c3MobnVtYmVyLCBuYW1lLCBPd25lZFByb2Nlc3MocGlkKSkKICAgICAgICB0cnk6CiAgICAgICAgICAgIG91dHB1dCA9IHN1YnBy -b2Nlc3MuY2hlY2tfb3V0cHV0KAogICAgICAgICAgICAgICAgWyJwZ3JlcCIsICItdSIsIHVpZCwgIi1mIiwKICAgICAgICAgICAg -ICAgICByZiJeKD86LipcLyk/KD86WHZuY3xYdGlnZXJ2bmMpLio6e251bWJlcn0uKi1yZmJwb3J0XHMrezU5MDAgKyBudW1iZXJ9 -KD86XHN8JCkiXSwKICAgICAgICAgICAgICAgIHRleHQ9VHJ1ZQogICAgICAgICAgICApCiAgICAgICAgICAgIHBpZCA9IGludChv -dXRwdXQuc3BsaXRsaW5lcygpWzBdKQogICAgICAgIGV4Y2VwdCAoT1NFcnJvciwgVmFsdWVFcnJvciwgSW5kZXhFcnJvciwgc3Vi -cHJvY2Vzcy5DYWxsZWRQcm9jZXNzRXJyb3IpOgogICAgICAgICAgICBwaWQgPSAwCiAgICAgICAgaWYgcGlkID4gMToKICAgICAg -ICAgICAgc2VsZi50cmFja19wcm9jZXNzKG51bWJlciwgInh2bmMiLCBPd25lZFByb2Nlc3MocGlkKSkKCiAgICBkZWYgc3RhcnRf -ZGJ1cyhzZWxmLCBudW1iZXIsIGVudmlyb25tZW50LCBydW50aW1lKToKICAgICAgICBidXMgPSBzdWJwcm9jZXNzLnJ1bihbImRi -dXMtbGF1bmNoIiwgIi0tc2gtc3ludGF4Il0sIGVudj1lbnZpcm9ubWVudCwKICAgICAgICAgICAgICAgICAgICAgICAgICAgICBz -dGRpbj1zdWJwcm9jZXNzLkRFVk5VTEwsIHN0ZG91dD1zdWJwcm9jZXNzLlBJUEUsCiAgICAgICAgICAgICAgICAgICAgICAgICAg -ICAgc3RkZXJyPXN1YnByb2Nlc3MuREVWTlVMTCwgdGV4dD1UcnVlLCB0aW1lb3V0PTEwLAogICAgICAgICAgICAgICAgICAgICAg -ICAgICAgIGNoZWNrPVRydWUpLnN0ZG91dAogICAgICAgIGZvciBsaW5lIGluIGJ1cy5zcGxpdGxpbmVzKCk6CiAgICAgICAgICAg -IGlmICI9IiBub3QgaW4gbGluZToKICAgICAgICAgICAgICAgIGNvbnRpbnVlCiAgICAgICAgICAgIGtleSwgdmFsdWUgPSBsaW5l -LnNwbGl0KCI9IiwgMSkKICAgICAgICAgICAgZW52aXJvbm1lbnRba2V5XSA9IHZhbHVlLnN0cmlwKCkucnN0cmlwKCI7Iikuc3Ry -aXAoKS5zdHJpcCgiJyIpCiAgICAgICAgdHJ5OgogICAgICAgICAgICBkYnVzX3BpZCA9IGludChlbnZpcm9ubWVudC5nZXQoIkRC -VVNfU0VTU0lPTl9CVVNfUElEIiwgIjAiKSkKICAgICAgICBleGNlcHQgVmFsdWVFcnJvcjoKICAgICAgICAgICAgZGJ1c19waWQg -PSAwCiAgICAgICAgaWYgZGJ1c19waWQgPiAxOgogICAgICAgICAgICBzZWxmLnRyYWNrX3Byb2Nlc3MobnVtYmVyLCAiZGJ1cyIs -IE93bmVkUHJvY2VzcyhkYnVzX3BpZCkpCiAgICAgICAgICAgIChQYXRoKHJ1bnRpbWUpIC8gInNlc3Npb24uanNvbiIpLndyaXRl -X3RleHQoanNvbi5kdW1wcyh7CiAgICAgICAgICAgICAgICAicGlkIjogZGJ1c19waWQsCiAgICAgICAgICAgICAgICAiYWRkcmVz -cyI6IGVudmlyb25tZW50LmdldCgiREJVU19TRVNTSU9OX0JVU19BRERSRVNTIiwgIiIpCiAgICAgICAgICAgIH0pKQoKICAgIGRl -ZiBzdGFydF9zZXNzaW9uX2NvbXBvbmVudHMoc2VsZiwgbnVtYmVyLCBlbnZpcm9ubWVudCwgcnVudGltZSk6CiAgICAgICAgZm9y -IGV4ZWN1dGFibGUsIGFyZ3MgaW4gWwogICAgICAgICAgICAoIm9wZW5ib3giLCBbXSksICgidGludDIiLCBbIi1jIiwgIi9ldGMv -Y211eC90aW50MnJjIl0pLAogICAgICAgICAgICAoInZuY2NvbmZpZyIsIFsiLW5vd2luIl0pLAogICAgICAgIF06CiAgICAgICAg -ICAgIGV4aXN0aW5nID0gc2VsZi5uYW1lZF9wcm9jZXNzZXMuZ2V0KG51bWJlciwge30pLmdldChleGVjdXRhYmxlKQogICAgICAg -ICAgICBpZiBleGlzdGluZyBpcyBub3QgTm9uZSBhbmQgc2VsZi5wcm9jZXNzX2FsaXZlKGV4aXN0aW5nKToKICAgICAgICAgICAg -ICAgIGNvbnRpbnVlCiAgICAgICAgICAgIHBhdGggPSBleGVjdXRhYmxlIGlmICIvIiBpbiBleGVjdXRhYmxlIGVsc2Ugc2h1dGls -LndoaWNoKGV4ZWN1dGFibGUpCiAgICAgICAgICAgIGlmIG5vdCBwYXRoOgogICAgICAgICAgICAgICAgaWYgZXhlY3V0YWJsZSA9 -PSAidm5jY29uZmlnIjoKICAgICAgICAgICAgICAgICAgICBjb250aW51ZQogICAgICAgICAgICAgICAgcmFpc2UgUnVudGltZUVy -cm9yKGYibWlzc2luZyBkaXNwbGF5IGNvbXBvbmVudDoge2V4ZWN1dGFibGV9IikKICAgICAgICAgICAgcGF0aCA9IHNlbGYuc2Nv -cGVkX2NvbXBvbmVudF9wYXRoKGV4ZWN1dGFibGUsIHBhdGgsIG51bWJlciwgcnVudGltZSkKICAgICAgICAgICAgcHJvY2VzcyA9 -IHN1YnByb2Nlc3MuUG9wZW4oW3BhdGhdICsgYXJncywgZW52PWVudmlyb25tZW50LAogICAgICAgICAgICAgICAgICAgICAgICAg -ICAgICAgICAgICAgICBzdGRpbj1zdWJwcm9jZXNzLkRFVk5VTEwsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg -ICAgIHN0ZG91dD1zdWJwcm9jZXNzLkRFVk5VTEwsIHN0ZGVycj1zdWJwcm9jZXNzLkRFVk5VTEwpCiAgICAgICAgICAgIHNlbGYu -dHJhY2tfcHJvY2VzcyhudW1iZXIsIGV4ZWN1dGFibGUsIHByb2Nlc3MpCgogICAgZGVmIHNldF93YWxscGFwZXIoc2VsZiwgZW52 -aXJvbm1lbnQpOgogICAgICAgIHdhbGxwYXBlciA9IFBhdGgoIi91c3Ivc2hhcmUvYmFja2dyb3VuZHMvY211eC93YWxscGFwZXIu -anBnIikKICAgICAgICBpZiB3YWxscGFwZXIuZXhpc3RzKCkgYW5kIHNodXRpbC53aGljaCgiZmVoIik6CiAgICAgICAgICAgIHRy -eToKICAgICAgICAgICAgICAgIHN1YnByb2Nlc3MucnVuKFsiZmVoIiwgIi0tbm8tZmVoYmciLCAiLS1iZy1maWxsIiwgc3RyKHdh -bGxwYXBlcildLAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgZW52PWVudmlyb25tZW50LCBzdGRpbj1zdWJwcm9jZXNz -LkRFVk5VTEwsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBzdGRvdXQ9c3VicHJvY2Vzcy5ERVZOVUxMLCBzdGRlcnI9 -c3VicHJvY2Vzcy5ERVZOVUxMLAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgY2hlY2s9RmFsc2UsIHRpbWVvdXQ9MTAp -CiAgICAgICAgICAgICAgICByZXR1cm4KICAgICAgICAgICAgZXhjZXB0IChPU0Vycm9yLCBzdWJwcm9jZXNzLlN1YnByb2Nlc3NF -cnJvcik6CiAgICAgICAgICAgICAgICBwYXNzCiAgICAgICAgeHNldHJvb3QgPSBzaHV0aWwud2hpY2goInhzZXRyb290IikKICAg -ICAgICBpZiB4c2V0cm9vdDoKICAgICAgICAgICAgc3VicHJvY2Vzcy5ydW4oW3hzZXRyb290LCAiLXNvbGlkIiwgIiMxZjI0MzAi -XSwgZW52PWVudmlyb25tZW50LAogICAgICAgICAgICAgICAgICAgICAgICAgICBzdGRpbj1zdWJwcm9jZXNzLkRFVk5VTEwsIHN0 -ZG91dD1zdWJwcm9jZXNzLkRFVk5VTEwsCiAgICAgICAgICAgICAgICAgICAgICAgICAgIHN0ZGVycj1zdWJwcm9jZXNzLkRFVk5V -TEwsIGNoZWNrPUZhbHNlKQoKICAgIGRlZiBzdGFydF93ZWJzb2NraWZ5KHNlbGYsIG51bWJlciwgZW52aXJvbm1lbnQsIGV4ZWN1 -dGFibGUpOgogICAgICAgIHBhdGggPSBleGVjdXRhYmxlIGlmICIvIiBpbiBleGVjdXRhYmxlIGVsc2Ugc2h1dGlsLndoaWNoKGV4 -ZWN1dGFibGUpCiAgICAgICAgaWYgbm90IHBhdGg6CiAgICAgICAgICAgIHJhaXNlIE5vVk5DVW5hdmFpbGFibGUoIm1pc3Npbmcg -ZGlzcGxheSBjb21wb25lbnQ6IHdlYnNvY2tpZnkiKQogICAgICAgIHNlbGYudGVybWluYXRlX3dlYnNvY2tpZnkobnVtYmVyKQog -ICAgICAgIHJmYl9wb3J0LCBub3ZuY19wb3J0ID0gNTkwMCArIG51bWJlciwgNjkwMCArIG51bWJlcgogICAgICAgIHByb2Nlc3Mg -PSBzdWJwcm9jZXNzLlBvcGVuKAogICAgICAgICAgICBbcGF0aCwgIi0td2ViIiwgIi91c3Ivc2hhcmUvbm92bmMiLCAiLS1oZWFy -dGJlYXQiLCAiMzAiLAogICAgICAgICAgICAgZiIxMjcuMC4wLjE6e25vdm5jX3BvcnR9IiwgZiIxMjcuMC4wLjE6e3JmYl9wb3J0 -fSJdLAogICAgICAgICAgICBlbnY9ZW52aXJvbm1lbnQsIHN0ZGluPXN1YnByb2Nlc3MuREVWTlVMTCwKICAgICAgICAgICAgc3Rk -b3V0PXN1YnByb2Nlc3MuREVWTlVMTCwgc3RkZXJyPXN1YnByb2Nlc3MuREVWTlVMTCkKICAgICAgICBzZWxmLndlYnNvY2tpZnlf -cHJvY2Vzc2VzW251bWJlcl0gPSBwcm9jZXNzCiAgICAgICAgaWYgbm90IHNlbGYud2FpdF9mb3JfcG9ydChub3ZuY19wb3J0LCB0 -aW1lb3V0PTEwKToKICAgICAgICAgICAgc2VsZi50ZXJtaW5hdGVfd2Vic29ja2lmeShudW1iZXIpCiAgICAgICAgICAgIHJhaXNl -IE5vVk5DVW5hdmFpbGFibGUoIm5vVk5DIGRpZCBub3QgYmVjb21lIHJlYWR5IikKCiAgICBkZWYgc3RhcnRfY29tcG9uZW50cyhz -ZWxmLCBudW1iZXIsIGVudmlyb25tZW50LCBydW50aW1lKToKICAgICAgICBpZiBzZWxmLnNlcnZpY2VfcmVhZHkobnVtYmVyKToK -ICAgICAgICAgICAgcmV0dXJuCiAgICAgICAgcmZiX2lzX3JlYWR5ID0gcmZiX3JlYWR5KG51bWJlcikKICAgICAgICB2bmMgPSBz -aHV0aWwud2hpY2goIlh2bmMiKSBvciBzaHV0aWwud2hpY2goIlh0aWdlcnZuYyIpCiAgICAgICAgd2Vic29ja2lmeSA9IHNodXRp -bC53aGljaCgid2Vic29ja2lmeSIpCiAgICAgICAgaWYgbm90IHdlYnNvY2tpZnk6CiAgICAgICAgICAgIHJhaXNlIE5vVk5DVW5h -dmFpbGFibGUoImRpc3BsYXkgcnVudGltZSB1bmF2YWlsYWJsZSIpCiAgICAgICAgaWYgbm90IHJmYl9pc19yZWFkeSBhbmQgbm90 -IHZuYzoKICAgICAgICAgICAgcmFpc2UgUnVudGltZUVycm9yKCJkaXNwbGF5IHJ1bnRpbWUgdW5hdmFpbGFibGUiKQogICAgICAg -IGlmIHJmYl9pc19yZWFkeSBhbmQgbnVtYmVyIG5vdCBpbiBzZWxmLm5hbWVkX3Byb2Nlc3NlczoKICAgICAgICAgICAgc2VsZi5y -ZWNvdmVyX3Byb2Nlc3NlcyhudW1iZXIsIHJ1bnRpbWUsIGVudmlyb25tZW50KQogICAgICAgIGlmIG5vdCByZmJfaXNfcmVhZHk6 -CiAgICAgICAgICAgIHNlbGYudGVybWluYXRlKG51bWJlcikKICAgICAgICAgICAgcmZiX3BvcnQgPSA1OTAwICsgbnVtYmVyCiAg -ICAgICAgICAgIHNlbGYucHJvY2Vzc2VzW251bWJlcl0gPSBbXQogICAgICAgICAgICBzZWxmLm5hbWVkX3Byb2Nlc3Nlc1tudW1i -ZXJdID0ge30KICAgICAgICAgICAgc2VsZi50cmFja19wcm9jZXNzKG51bWJlciwgInh2bmMiLCBzdWJwcm9jZXNzLlBvcGVuKAog -ICAgICAgICAgICAgICAgW3ZuYywgZiI6e251bWJlcn0iLCAiLWdlb21ldHJ5Iiwgb3MuZW52aXJvbi5nZXQoIkNNVVhfVk5DX0dF -T01FVFJZIiwgIjE0NDB4OTAwIiksCiAgICAgICAgICAgICAgICAgIi1kZXB0aCIsICIyNCIsICItcmZicG9ydCIsIHN0cihyZmJf -cG9ydCksICItbG9jYWxob3N0IiwKICAgICAgICAgICAgICAgICAiLVNlY3VyaXR5VHlwZXMiLCAiTm9uZSIsICItQWx3YXlzU2hh -cmVkIl0sCiAgICAgICAgICAgICAgICBlbnY9ZW52aXJvbm1lbnQsIHN0ZGluPXN1YnByb2Nlc3MuREVWTlVMTCwKICAgICAgICAg -ICAgICAgIHN0ZG91dD1zdWJwcm9jZXNzLkRFVk5VTEwsIHN0ZGVycj1zdWJwcm9jZXNzLkRFVk5VTEwpKQogICAgICAgICAgICBp -ZiBub3Qgc2VsZi53YWl0X2Zvcl9wb3J0KHJmYl9wb3J0KToKICAgICAgICAgICAgICAgIHJhaXNlIFJ1bnRpbWVFcnJvcigiWHZu -YyBkaWQgbm90IGJlY29tZSByZWFkeSIpCiAgICAgICAgICAgIHNlbGYuc3RhcnRfZGJ1cyhudW1iZXIsIGVudmlyb25tZW50LCBy -dW50aW1lKQogICAgICAgICAgICBzZWxmLnN0YXJ0X3Nlc3Npb25fY29tcG9uZW50cyhudW1iZXIsIGVudmlyb25tZW50LCBydW50 -aW1lKQogICAgICAgICAgICBzZWxmLnNldF93YWxscGFwZXIoZW52aXJvbm1lbnQpCiAgICAgICAgZWxpZiBudW1iZXIgaW4gc2Vs -Zi5uYW1lZF9wcm9jZXNzZXM6CiAgICAgICAgICAgIG5hbWVkID0gc2VsZi5uYW1lZF9wcm9jZXNzZXNbbnVtYmVyXQogICAgICAg -ICAgICBkYnVzID0gbmFtZWQuZ2V0KCJkYnVzIikKICAgICAgICAgICAgaWYgZGJ1cyBpcyBOb25lIG9yIG5vdCBzZWxmLnByb2Nl -c3NfYWxpdmUoZGJ1cyk6CiAgICAgICAgICAgICAgICBmb3IgbmFtZSBpbiAoIm9wZW5ib3giLCAidGludDIiLCAidm5jY29uZmln -Iik6CiAgICAgICAgICAgICAgICAgICAgc2VsZi5yZW1vdmVfcHJvY2VzcyhudW1iZXIsIG5hbWUpCiAgICAgICAgICAgICAgICBz -ZWxmLnN0YXJ0X2RidXMobnVtYmVyLCBlbnZpcm9ubWVudCwgcnVudGltZSkKICAgICAgICAgICAgc2VsZi5zdGFydF9zZXNzaW9u -X2NvbXBvbmVudHMobnVtYmVyLCBlbnZpcm9ubWVudCwgcnVudGltZSkKICAgICAgICAgICAgc2VsZi5zZXRfd2FsbHBhcGVyKGVu -dmlyb25tZW50KQogICAgICAgIHdlYnNvY2tpZnlfcHJvY2VzcyA9IHNlbGYud2Vic29ja2lmeV9wcm9jZXNzZXMuZ2V0KG51bWJl -cikKICAgICAgICBpZiAod2Vic29ja2lmeV9wcm9jZXNzIGlzIE5vbmUgb3Igbm90IHNlbGYucHJvY2Vzc19hbGl2ZSh3ZWJzb2Nr -aWZ5X3Byb2Nlc3MpCiAgICAgICAgICAgICAgICBvciBub3Qgbm92bmNfcmVhZHkobnVtYmVyKSk6CiAgICAgICAgICAgIHNlbGYu -c3RhcnRfd2Vic29ja2lmeShudW1iZXIsIGVudmlyb25tZW50LCB3ZWJzb2NraWZ5KQogICAgICAgIChydW50aW1lIC8gImVudiIp -LndyaXRlX3RleHQoZiJleHBvcnQgRElTUExBWT06e251bWJlcn1cbiIpCgogICAgZGVmIHN1cGVydmlzZShzZWxmLCBudW1iZXIs -IGZpbmlzaGVkKToKICAgICAgICBydW50aW1lID0gc2VsZi5ydW50aW1lIC8gc3RyKG51bWJlcikKICAgICAgICBydW50aW1lLm1r -ZGlyKG1vZGU9MG83MDAsIHBhcmVudHM9VHJ1ZSwgZXhpc3Rfb2s9VHJ1ZSkKICAgICAgICBlbnZpcm9ubWVudCA9IGRpY3Qob3Mu -ZW52aXJvbiwgRElTUExBWT1mIjp7bnVtYmVyfSIsIENNVVhfREVTS1RPUF9SVU5USU1FX0RJUj1zdHIocnVudGltZSkpCiAgICAg -ICAgZW52aXJvbm1lbnRbIkNIUk9NRV9DT05GSUdfSE9NRSJdID0gc3RyKHJ1bnRpbWUgLyAiY2hyb21lLWNvbmZpZyIpCiAgICAg -ICAgZW52aXJvbm1lbnRbIlhER19DT05GSUdfSE9NRSJdID0gc3RyKHJ1bnRpbWUgLyAieGRnLWNvbmZpZyIpCiAgICAgICAgZm9y -IGtleSBpbiAoIk5PVElGWV9TT0NLRVQiLCAiREJVU19TRVNTSU9OX0JVU19BRERSRVNTIiwgIkRCVVNfU0VTU0lPTl9CVVNfUElE -IiwKICAgICAgICAgICAgICAgICAgICAiQVRfU1BJX0JVU19BRERSRVNTIiwgIkFUX1NQSV9CVVMiKToKICAgICAgICAgICAgZW52 -aXJvbm1lbnQucG9wKGtleSwgTm9uZSkKICAgICAgICB3aGlsZSBub3Qgc2VsZi5zaHV0ZG93bi5pc19zZXQoKToKICAgICAgICAg -ICAgdHJ5OgogICAgICAgICAgICAgICAgIyBUaGUgc3VwZXJ2aXNvciBpcyBhIGhlYWx0aCBjaGVjaywgbm90IGEgdGltZXItZHJp -dmVuIHJlc3RhcnQuCiAgICAgICAgICAgICAgICAjIFByZXNlcnZlIGFuIGFjdGl2ZSBSRkIvbm9WTkMgc2Vzc2lvbiB1bnRpbCBp -dHMgb3duZWQgZW5kcG9pbnRzIGZhaWwuCiAgICAgICAgICAgICAgICBpZiBub3Qgc2VsZi5zZXJ2aWNlX3JlYWR5KG51bWJlcik6 -CiAgICAgICAgICAgICAgICAgICAgc2VsZi5zdGFydF9jb21wb25lbnRzKG51bWJlciwgZW52aXJvbm1lbnQsIHJ1bnRpbWUpCiAg -ICAgICAgICAgICAgICBzdGF0ZSA9ICJydW5uaW5nIiBpZiBzZWxmLnNlcnZpY2VfcmVhZHkobnVtYmVyKSBlbHNlICJ1bmF2YWls -YWJsZSIKICAgICAgICAgICAgZXhjZXB0IE5vVk5DVW5hdmFpbGFibGU6CiAgICAgICAgICAgICAgICBzZWxmLnRlcm1pbmF0ZV93 -ZWJzb2NraWZ5KG51bWJlcikKICAgICAgICAgICAgICAgIHN0YXRlID0gInVuYXZhaWxhYmxlIgogICAgICAgICAgICBleGNlcHQg -KE9TRXJyb3IsIFJ1bnRpbWVFcnJvciwgc3VicHJvY2Vzcy5TdWJwcm9jZXNzRXJyb3IpOgogICAgICAgICAgICAgICAgc2VsZi50 -ZXJtaW5hdGUobnVtYmVyKQogICAgICAgICAgICAgICAgc3RhdGUgPSAidW5hdmFpbGFibGUiCiAgICAgICAgICAgIHdpdGggc2Vs -Zi5sb2NrOgogICAgICAgICAgICAgICAgc2VsZi5zdGF0ZXNbbnVtYmVyXSA9IHN0YXRlCiAgICAgICAgICAgIGZpbmlzaGVkLnNl -dCgpCiAgICAgICAgICAgIHNlbGYuc2h1dGRvd24ud2FpdCgzMCkKICAgICAgICBzZWxmLnRlcm1pbmF0ZShudW1iZXIpCgogICAg -ZGVmIHNuYXBzaG90KHNlbGYpOgogICAgICAgIHByaW1hcnlfc3RhdGUgPSAicnVubmluZyIgaWYgcmVhZHkoMSkgZWxzZSAidW5h -dmFpbGFibGUiCiAgICAgICAgd2l0aCBzZWxmLmxvY2s6CiAgICAgICAgICAgIHJldHVybiB7InZlcnNpb24iOiAxLCAiY2FuQ3Jl -YXRlIjogbGVuKHNlbGYuam9icykgPCBNQVhfRElTUExBWVMgLSAxLAogICAgICAgICAgICAgICAgICAgICJkaXNwbGF5cyI6IFtk -ZXNjcmlwdG9yKDEsIHByaW1hcnlfc3RhdGUpXQogICAgICAgICAgICAgICAgICAgICsgW2Rlc2NyaXB0b3Iobiwgc2VsZi5zdGF0 -ZXMuZ2V0KG4sICJsYXVuY2hpbmciKSkgZm9yIG4gaW4gc2VsZi5jYXRhbG9nLm51bWJlcnMoKV19CgogICAgZGVmIGhhbmRsZShz -ZWxmLCByZXF1ZXN0KToKICAgICAgICBpZiByZXF1ZXN0LmdldCgiYWN0aW9uIikgPT0gImxpc3QiOgogICAgICAgICAgICByZXR1 -cm4gc2VsZi5zbmFwc2hvdCgpCiAgICAgICAgaWYgcmVxdWVzdC5nZXQoImFjdGlvbiIpICE9ICJjcmVhdGUiOgogICAgICAgICAg -ICByYWlzZSBWYWx1ZUVycm9yKCJ1bnN1cHBvcnRlZCBkaXNwbGF5IGFjdGlvbiIpCiAgICAgICAgbnVtYmVyID0gc2VsZi5jYXRh -bG9nLmFsbG9jYXRlKHJlcXVlc3QuZ2V0KCJyZXF1ZXN0IikpCiAgICAgICAgc2VsZi5zdGFydChudW1iZXIpLndhaXQoNTUpCiAg -ICAgICAgcmVzdWx0ID0gc2VsZi5zbmFwc2hvdCgpCiAgICAgICAgcmVzdWx0WyJjcmVhdGVkIl0gPSBmImRpc3BsYXk6e251bWJl -cn0iCiAgICAgICAgaWYgc2VsZi5zdGF0ZXMuZ2V0KG51bWJlcikgIT0gInJ1bm5pbmciOgogICAgICAgICAgICByZXN1bHRbImVy -cm9yIl0gPSAiZGlzcGxheV9zdGFydF9mYWlsZWQiCiAgICAgICAgcmV0dXJuIHJlc3VsdAoKCmRlZiBzZXJ2ZShydW50aW1lKToK -ICAgIHJ1bnRpbWUgPSBQYXRoKHJ1bnRpbWUpCiAgICBydW50aW1lLm1rZGlyKG1vZGU9MG83MDAsIHBhcmVudHM9VHJ1ZSwgZXhp -c3Rfb2s9VHJ1ZSkKICAgIHdpdGggKHJ1bnRpbWUgLyAiZGlzcGxheS1jb250cm9sLmxvY2siKS5vcGVuKCJ3IikgYXMgbG9jazoK -ICAgICAgICB0cnk6CiAgICAgICAgICAgIGZjbnRsLmZsb2NrKGxvY2ssIGZjbnRsLkxPQ0tfRVggfCBmY250bC5MT0NLX05CKQog -ICAgICAgIGV4Y2VwdCBCbG9ja2luZ0lPRXJyb3I6CiAgICAgICAgICAgIHJldHVybgogICAgICAgIGlmIG5vdCBhbGwoc2h1dGls -LndoaWNoKG5hbWUpIGZvciBuYW1lIGluICgid2Vic29ja2lmeSIsICJvcGVuYm94IiwgImRidXMtbGF1bmNoIikpOgogICAgICAg -ICAgICByYWlzZSBWYWx1ZUVycm9yKCJkaXNwbGF5IHJ1bnRpbWUgdW5hdmFpbGFibGUiKQogICAgICAgIGlmIG5vdCAoc2h1dGls -LndoaWNoKCJYdm5jIikgb3Igc2h1dGlsLndoaWNoKCJYdGlnZXJ2bmMiKSk6CiAgICAgICAgICAgIHJhaXNlIFZhbHVlRXJyb3Io -ImRpc3BsYXkgcnVudGltZSB1bmF2YWlsYWJsZSIpCiAgICAgICAgc2VydmljZSA9IERpc3BsYXlTZXJ2aWNlKERpc3BsYXlDYXRh -bG9nKFBhdGguaG9tZSgpIC8gIi5jbXV4IiAvICJkaXNwbGF5cyIpLCBydW50aW1lIC8gImRpc3BsYXlzIikKICAgICAgICBmb3Ig -bnVtYmVyIGluIHNlcnZpY2UuY2F0YWxvZy5udW1iZXJzKCk6CiAgICAgICAgICAgIHNlcnZpY2Uuc3RhcnQobnVtYmVyKQogICAg -ICAgIHBhdGggPSBydW50aW1lIC8gImRpc3BsYXktY29udHJvbC5zb2NrIgogICAgICAgIGlmIHBhdGguZXhpc3RzKCk6CiAgICAg -ICAgICAgIHBhdGgudW5saW5rKCkKCiAgICAgICAgY2xhc3MgSGFuZGxlcihzb2NrZXRzZXJ2ZXIuU3RyZWFtUmVxdWVzdEhhbmRs -ZXIpOgogICAgICAgICAgICBkZWYgaGFuZGxlKHNlbGYpOgogICAgICAgICAgICAgICAgc2VsZi5jb25uZWN0aW9uLnNldHRpbWVv -dXQoNjApCiAgICAgICAgICAgICAgICB0cnk6CiAgICAgICAgICAgICAgICAgICAgcmF3ID0gc2VsZi5yZmlsZS5yZWFkbGluZSg0 -MDk3KQogICAgICAgICAgICAgICAgICAgIGlmIGxlbihyYXcpID4gNDA5NjoKICAgICAgICAgICAgICAgICAgICAgICAgcmFpc2Ug -VmFsdWVFcnJvcigiZGlzcGxheSByZXF1ZXN0IHRvbyBsYXJnZSIpCiAgICAgICAgICAgICAgICAgICAgcmVzdWx0ID0gc2Vydmlj -ZS5oYW5kbGUoanNvbi5sb2FkcyhyYXcpKQogICAgICAgICAgICAgICAgZXhjZXB0IChWYWx1ZUVycm9yLCBLZXlFcnJvciwgVHlw -ZUVycm9yLCBPU0Vycm9yKToKICAgICAgICAgICAgICAgICAgICByZXN1bHQgPSB7ImVycm9yIjogImRpc3BsYXlfcmVxdWVzdF9m -YWlsZWQifQogICAgICAgICAgICAgICAgdHJ5OgogICAgICAgICAgICAgICAgICAgIHNlbGYud2ZpbGUud3JpdGUoanNvbi5kdW1w -cyhyZXN1bHQpLmVuY29kZSgpICsgYiJcbiIpCiAgICAgICAgICAgICAgICBleGNlcHQgT1NFcnJvcjoKICAgICAgICAgICAgICAg -ICAgICBwYXNzICAjIFRoZSBwZXJzaXN0ZWQgcmVjZWlwdCByZW1haW5zIHJlcGxheWFibGUgYWZ0ZXIgZGlzY29ubmVjdC4KCiAg -ICAgICAgY2xhc3MgU2VydmVyKHNvY2tldHNlcnZlci5UaHJlYWRpbmdVbml4U3RyZWFtU2VydmVyKToKICAgICAgICAgICAgZGFl -bW9uX3RocmVhZHMgPSBUcnVlCgogICAgICAgIHByZXZpb3VzX3VtYXNrID0gb3MudW1hc2soMG8wNzcpCiAgICAgICAgdHJ5Ogog -ICAgICAgICAgICB3aXRoIFNlcnZlcihzdHIocGF0aCksIEhhbmRsZXIpIGFzIHNlcnZlcjoKICAgICAgICAgICAgICAgIG9zLnVt -YXNrKHByZXZpb3VzX3VtYXNrKQogICAgICAgICAgICAgICAgc2VydmVyLnNlcnZlX2ZvcmV2ZXIoKQogICAgICAgIGZpbmFsbHk6 -CiAgICAgICAgICAgIG9zLnVtYXNrKHByZXZpb3VzX3VtYXNrKQogICAgICAgICAgICBzZXJ2aWNlLnNodXRkb3duLnNldCgpCiAg -ICAgICAgICAgIHBhdGgudW5saW5rKG1pc3Npbmdfb2s9VHJ1ZSkKCgpkZWYgbWFpbigpOgogICAgcGFyc2VyID0gYXJncGFyc2Uu -QXJndW1lbnRQYXJzZXIoZGVzY3JpcHRpb249X19kb2NfXykKICAgIHBhcnNlci5hZGRfYXJndW1lbnQoImFjdGlvbiIsIGNob2lj -ZXM9WyJsaXN0IiwgImNyZWF0ZSIsICJzZXJ2ZSJdKQogICAgcGFyc2VyLmFkZF9hcmd1bWVudCgiLS1yZXF1ZXN0LWlkIikKICAg -IGFyZ3MgPSBwYXJzZXIucGFyc2VfYXJncygpCiAgICBydW50aW1lID0gb3MuZW52aXJvbi5nZXQoIkNNVVhfREVTS1RPUF9SVU5U -SU1FX0RJUiIsICIvcnVuL2NtdXgtZGVza3RvcCIpCiAgICBpZiBhcmdzLmFjdGlvbiA9PSAic2VydmUiOgogICAgICAgIHNlcnZl -KHJ1bnRpbWUpCiAgICAgICAgcmV0dXJuIDAKICAgIGlmIGFyZ3MuYWN0aW9uID09ICJjcmVhdGUiIGFuZCBub3QgYXJncy5yZXF1 -ZXN0X2lkOgogICAgICAgIHBhcnNlci5lcnJvcigiY3JlYXRlIHJlcXVpcmVzIC0tcmVxdWVzdC1pZCAocmV0YWluIGl0IHdoZW4g -cmV0cnlpbmcpIikKICAgIHRyeToKICAgICAgICB3aXRoIHNvY2tldC5zb2NrZXQoc29ja2V0LkFGX1VOSVgsIHNvY2tldC5TT0NL -X1NUUkVBTSkgYXMgY2xpZW50OgogICAgICAgICAgICBjbGllbnQuc2V0dGltZW91dCg2MCkKICAgICAgICAgICAgY2xpZW50LmNv -bm5lY3Qoc3RyKFBhdGgocnVudGltZSkgLyAiZGlzcGxheS1jb250cm9sLnNvY2siKSkKICAgICAgICAgICAgY2xpZW50LnNlbmRh -bGwoanNvbi5kdW1wcyh7ImFjdGlvbiI6IGFyZ3MuYWN0aW9uLCAicmVxdWVzdCI6IGFyZ3MucmVxdWVzdF9pZH0pLmVuY29kZSgp -ICsgYiJcbiIpCiAgICAgICAgICAgIHdpdGggY2xpZW50Lm1ha2VmaWxlKCJyYiIpIGFzIHN0cmVhbToKICAgICAgICAgICAgICAg -IHJlc3VsdCA9IGpzb24ubG9hZHMoc3RyZWFtLnJlYWRsaW5lKDY1NTM2KSkKICAgIGV4Y2VwdCAoT1NFcnJvciwgVmFsdWVFcnJv -cik6CiAgICAgICAgcmVzdWx0ID0geyJlcnJvciI6ICJkaXNwbGF5X3J1bnRpbWVfdW5hdmFpbGFibGUifQogICAgcHJpbnQoanNv -bi5kdW1wcyhyZXN1bHQpKQogICAgcmV0dXJuIDEgaWYgImVycm9yIiBpbiByZXN1bHQgZWxzZSAwCgoKaWYgX19uYW1lX18gPT0g -Il9fbWFpbl9fIjoKICAgIHJhaXNlIFN5c3RlbUV4aXQobWFpbigpKQo= +YmluL3N0YXJ0LXZuYy5zaCIKIyBBZGRpdGlvbmFsIGRpc3BsYXlzIHNlcnZlIG5vVk5DIG9uIHRoZSBzYW1lIGludGVyZmFjZXMg +YXMgdGhlIHByaW1hcnkKIyBkZXNrdG9wIChgWzo6XTo2OTAxYCkuIFRoZSBjbGllbnQgcmVhY2hlcyBldmVyeSBkaXNwbGF5IHRo +cm91Z2ggdGhlIFZNJ3MKIyBwcml2YXRlIGFkZHJlc3M7IGEgbG9vcGJhY2stb25seSBsaXN0ZW5lciByZWZ1c2VkIHRoYXQgcm91 +dGUsIHNvIGVhY2ggbmV3CiMgZGlzcGxheSdzIGZpcnN0IGNvbm5lY3Rpb24gZmFpbGVkIGJlZm9yZSBmYWxsaW5nIGJhY2suIFh2 +bmMgaXRzZWxmIHN0YXlzCiMgYC1sb2NhbGhvc3RgLCBhbmQgdGhlIHByaXZhdGUgbmV0d29yayByZW1haW5zIHRoZSBnYXRlLCBh +cyBmb3IgZGlzcGxheSAxLgpOT1ZOQ19CSU5EID0gIls6Ol0iCgoKZGVmIGRlc2NyaXB0b3IobnVtYmVyLCBzdGF0ZT0icnVubmlu +ZyIpOgogICAgcmV0dXJuIHsiaWQiOiBmImRpc3BsYXk6e251bWJlcn0iLCAibnVtYmVyIjogbnVtYmVyLAogICAgICAgICAgICAi +cG9ydCI6IDY5MDAgKyBudW1iZXIsICJzdGF0ZSI6IHN0YXRlfQoKCmRlZiBsaXN0ZW5pbmcocG9ydCk6CiAgICB0cnk6CiAgICAg +ICAgd2l0aCBzb2NrZXQuY3JlYXRlX2Nvbm5lY3Rpb24oKCIxMjcuMC4wLjEiLCBwb3J0KSwgdGltZW91dD0wLjMpOgogICAgICAg +ICAgICByZXR1cm4gVHJ1ZQogICAgZXhjZXB0IE9TRXJyb3I6CiAgICAgICAgcmV0dXJuIEZhbHNlCgoKZGVmIHJmYl9yZWFkeShu +dW1iZXIpOgogICAgIiIiQSBib3VuZCBIVFRQIHBvcnQgYWxvbmUgZG9lcyBub3QgcHJvdmUgdGhhdCBhbiBYL1JGQiBzZXJ2ZXIg +ZXhpc3RzLiIiIgogICAgdHJ5OgogICAgICAgIHdpdGggc29ja2V0LmNyZWF0ZV9jb25uZWN0aW9uKCgiMTI3LjAuMC4xIiwgNTkw +MCArIG51bWJlciksIHRpbWVvdXQ9MC41KSBhcyByZmI6CiAgICAgICAgICAgIHZlcnNpb24gPSBiIiIKICAgICAgICAgICAgd2hp +bGUgbGVuKHZlcnNpb24pIDwgMTI6CiAgICAgICAgICAgICAgICBwYXJ0ID0gcmZiLnJlY3YoMTIgLSBsZW4odmVyc2lvbikpCiAg +ICAgICAgICAgICAgICBpZiBub3QgcGFydDoKICAgICAgICAgICAgICAgICAgICByZXR1cm4gRmFsc2UKICAgICAgICAgICAgICAg +IHZlcnNpb24gKz0gcGFydAogICAgICAgICAgICBpZiBub3QgdmVyc2lvbi5zdGFydHN3aXRoKGIiUkZCICIpOgogICAgICAgICAg +ICAgICAgcmV0dXJuIEZhbHNlCiAgICBleGNlcHQgKE9TRXJyb3IsIGh0dHAuY2xpZW50LkhUVFBFeGNlcHRpb24pOgogICAgICAg +IHJldHVybiBGYWxzZQogICAgcmV0dXJuIFRydWUKCgpkZWYgbm92bmNfcmVhZHkobnVtYmVyKToKICAgIHRyeToKICAgICAgICBj +b25uZWN0aW9uID0gaHR0cC5jbGllbnQuSFRUUENvbm5lY3Rpb24oIjEyNy4wLjAuMSIsIDY5MDAgKyBudW1iZXIsIHRpbWVvdXQ9 +MC41KQogICAgICAgIHRyeToKICAgICAgICAgICAgY29ubmVjdGlvbi5yZXF1ZXN0KCJIRUFEIiwgIi92bmMuaHRtbCIpCiAgICAg +ICAgICAgIHJldHVybiBjb25uZWN0aW9uLmdldHJlc3BvbnNlKCkuc3RhdHVzID09IDIwMAogICAgICAgIGZpbmFsbHk6CiAgICAg +ICAgICAgIGNvbm5lY3Rpb24uY2xvc2UoKQogICAgZXhjZXB0IChPU0Vycm9yLCBodHRwLmNsaWVudC5IVFRQRXhjZXB0aW9uKToK +ICAgICAgICByZXR1cm4gRmFsc2UKCgpkZWYgcmVhZHkobnVtYmVyKToKICAgICIiIkEgYm91bmQgZW5kcG9pbnQgYWxvbmUgZG9l +cyBub3QgcHJvdmUgdGhhdCB0aGUgY29tcGxldGUgZGVza3RvcCBleGlzdHMuIiIiCiAgICByZXR1cm4gcmZiX3JlYWR5KG51bWJl +cikgYW5kIG5vdm5jX3JlYWR5KG51bWJlcikKCgpjbGFzcyBEaXNwbGF5Q2F0YWxvZzoKICAgICIiIlNlcmlhbGl6ZXMgYWxsb2Nh +dGlvbiBhbmQgcGVyc2lzdHMgcmVxdWVzdCByZWNlaXB0cyBiZWZvcmUgc3RhcnRpbmcgd29yay4iIiIKCiAgICBkZWYgX19pbml0 +X18oc2VsZiwgZGlyZWN0b3J5LCBvY2N1cGllZD1Ob25lKToKICAgICAgICBzZWxmLmRpcmVjdG9yeSA9IFBhdGgoZGlyZWN0b3J5 +KQogICAgICAgIHNlbGYuZGlyZWN0b3J5Lm1rZGlyKG1vZGU9MG83MDAsIHBhcmVudHM9VHJ1ZSwgZXhpc3Rfb2s9VHJ1ZSkKICAg +ICAgICBzZWxmLnBhdGggPSBzZWxmLmRpcmVjdG9yeSAvICJjYXRhbG9nLmpzb24iCiAgICAgICAgc2VsZi5sb2NrID0gdGhyZWFk +aW5nLlJMb2NrKCkKICAgICAgICBzZWxmLm9jY3VwaWVkID0gb2NjdXBpZWQgb3IgKGxhbWJkYSBuOiBQYXRoKGYiL3RtcC8uWDEx +LXVuaXgvWHtufSIpLmV4aXN0cygpCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBvciBsaXN0ZW5pbmcoNTkw +MCArIG4pIG9yIGxpc3RlbmluZyg2OTAwICsgbikpCiAgICAgICAgc2VsZi5yZWNvcmRzID0gW10KICAgICAgICBpZiBzZWxmLnBh +dGguZXhpc3RzKCk6CiAgICAgICAgICAgIGRhdGEgPSBqc29uLmxvYWRzKHNlbGYucGF0aC5yZWFkX3RleHQoKSkKICAgICAgICAg +ICAgaWYgZGF0YS5nZXQoInZlcnNpb24iKSAhPSAxOgogICAgICAgICAgICAgICAgcmFpc2UgVmFsdWVFcnJvcigidW5zdXBwb3J0 +ZWQgZGlzcGxheSBjYXRhbG9nIHZlcnNpb24iKQogICAgICAgICAgICBzZWVuX251bWJlcnMsIHNlZW5fcmVxdWVzdHMgPSBzZXQo +KSwgc2V0KCkKICAgICAgICAgICAgZm9yIHJlY29yZCBpbiBkYXRhWyJkaXNwbGF5cyJdOgogICAgICAgICAgICAgICAgbnVtYmVy +LCByZXF1ZXN0ID0gcmVjb3JkWyJudW1iZXIiXSwgcmVjb3JkWyJyZXF1ZXN0Il0KICAgICAgICAgICAgICAgIGlmIHR5cGUobnVt +YmVyKSBpcyBub3QgaW50IG9yIG5vdCAyIDw9IG51bWJlciA8PSBNQVhfRElTUExBWVM6CiAgICAgICAgICAgICAgICAgICAgcmFp +c2UgVmFsdWVFcnJvcigiaW52YWxpZCBkaXNwbGF5IG51bWJlciIpCiAgICAgICAgICAgICAgICBpZiBzdHIodXVpZC5VVUlEKHJl +cXVlc3QpKSAhPSByZXF1ZXN0IG9yIG51bWJlciBpbiBzZWVuX251bWJlcnMgb3IgcmVxdWVzdCBpbiBzZWVuX3JlcXVlc3RzOgog +ICAgICAgICAgICAgICAgICAgIHJhaXNlIFZhbHVlRXJyb3IoImludmFsaWQgZGlzcGxheSBpZGVudGl0eSIpCiAgICAgICAgICAg +ICAgICBzZWVuX251bWJlcnMuYWRkKG51bWJlcikKICAgICAgICAgICAgICAgIHNlZW5fcmVxdWVzdHMuYWRkKHJlcXVlc3QpCiAg +ICAgICAgICAgICAgICBzZWxmLnJlY29yZHMuYXBwZW5kKHsibnVtYmVyIjogbnVtYmVyLCAicmVxdWVzdCI6IHJlcXVlc3R9KQoK +ICAgIGRlZiBudW1iZXJzKHNlbGYpOgogICAgICAgIHdpdGggc2VsZi5sb2NrOgogICAgICAgICAgICByZXR1cm4gc29ydGVkKHJl +Y29yZFsibnVtYmVyIl0gZm9yIHJlY29yZCBpbiBzZWxmLnJlY29yZHMpCgogICAgZGVmIGFsbG9jYXRlKHNlbGYsIHJlcXVlc3Qp +OgogICAgICAgIGlmIG5vdCBpc2luc3RhbmNlKHJlcXVlc3QsIHN0cikgb3Igc3RyKHV1aWQuVVVJRChyZXF1ZXN0KSkgIT0gcmVx +dWVzdDoKICAgICAgICAgICAgcmFpc2UgVmFsdWVFcnJvcigiaW52YWxpZCByZXF1ZXN0IGlkZW50aXR5IikKICAgICAgICB3aXRo +IHNlbGYubG9jazoKICAgICAgICAgICAgZm9yIHJlY29yZCBpbiBzZWxmLnJlY29yZHM6CiAgICAgICAgICAgICAgICBpZiByZWNv +cmRbInJlcXVlc3QiXSA9PSByZXF1ZXN0OgogICAgICAgICAgICAgICAgICAgIHJldHVybiByZWNvcmRbIm51bWJlciJdCiAgICAg +ICAgICAgIHVzZWQgPSBzZXQoc2VsZi5udW1iZXJzKCkpCiAgICAgICAgICAgIG51bWJlciA9IG5leHQoKG4gZm9yIG4gaW4gcmFu +Z2UoMiwgTUFYX0RJU1BMQVlTICsgMSkKICAgICAgICAgICAgICAgICAgICAgICAgICAgaWYgbiBub3QgaW4gdXNlZCBhbmQgbm90 +IHNlbGYub2NjdXBpZWQobikpLCBOb25lKQogICAgICAgICAgICBpZiBudW1iZXIgaXMgTm9uZToKICAgICAgICAgICAgICAgIHJh +aXNlIFZhbHVlRXJyb3IoImRpc3BsYXkgY2FwYWNpdHkgZXhoYXVzdGVkIikKICAgICAgICAgICAgcmVjb3JkcyA9IHNlbGYucmVj +b3JkcyArIFt7Im51bWJlciI6IG51bWJlciwgInJlcXVlc3QiOiByZXF1ZXN0fV0KICAgICAgICAgICAgZmQsIHRlbXBvcmFyeSA9 +IHRlbXBmaWxlLm1rc3RlbXAocHJlZml4PSJjYXRhbG9nLSIsIGRpcj1zZWxmLmRpcmVjdG9yeSkKICAgICAgICAgICAgdHJ5Ogog +ICAgICAgICAgICAgICAgd2l0aCBvcy5mZG9wZW4oZmQsICJ3IikgYXMgb3V0cHV0OgogICAgICAgICAgICAgICAgICAgIGpzb24u +ZHVtcCh7InZlcnNpb24iOiAxLCAiZGlzcGxheXMiOiByZWNvcmRzfSwgb3V0cHV0KQogICAgICAgICAgICAgICAgICAgIG91dHB1 +dC5mbHVzaCgpCiAgICAgICAgICAgICAgICAgICAgb3MuZnN5bmMob3V0cHV0LmZpbGVubygpKQogICAgICAgICAgICAgICAgb3Mu +cmVwbGFjZSh0ZW1wb3JhcnksIHNlbGYucGF0aCkKICAgICAgICAgICAgZmluYWxseToKICAgICAgICAgICAgICAgIGlmIG9zLnBh +dGguZXhpc3RzKHRlbXBvcmFyeSk6CiAgICAgICAgICAgICAgICAgICAgb3MudW5saW5rKHRlbXBvcmFyeSkKICAgICAgICAgICAg +c2VsZi5yZWNvcmRzID0gcmVjb3JkcwogICAgICAgICAgICByZXR1cm4gbnVtYmVyCgoKY2xhc3MgT3duZWRQcm9jZXNzOgogICAg +IiIiQSBoYW5kbGUgZm9yIGEgZGFlbW9uIHN0YXJ0ZWQgYnkgZGJ1cy1sYXVuY2ggcmF0aGVyIHRoYW4gYnkgUG9wZW4uIiIiCgog +ICAgZGVmIF9faW5pdF9fKHNlbGYsIHBpZCk6CiAgICAgICAgc2VsZi5waWQgPSBwaWQKCiAgICBkZWYgdGVybWluYXRlKHNlbGYp +OgogICAgICAgIHRyeToKICAgICAgICAgICAgb3Mua2lsbChzZWxmLnBpZCwgMTUpCiAgICAgICAgZXhjZXB0IE9TRXJyb3I6CiAg +ICAgICAgICAgIHBhc3MKCiAgICBkZWYgcG9sbChzZWxmKToKICAgICAgICB0cnk6CiAgICAgICAgICAgIG9zLmtpbGwoc2VsZi5w +aWQsIDApCiAgICAgICAgICAgIHJldHVybiBOb25lCiAgICAgICAgZXhjZXB0IE9TRXJyb3I6CiAgICAgICAgICAgIHJldHVybiAw +CgoKY2xhc3MgTm9WTkNVbmF2YWlsYWJsZShSdW50aW1lRXJyb3IpOgogICAgIiIiVGhlIHZpZXdpbmcgdHJhbnNwb3J0IGZhaWxl +ZCB3aGlsZSB0aGUgZ3Vlc3QgZGVza3RvcCBtYXkgYmUgaGVhbHRoeS4iIiIKCgpjbGFzcyBEaXNwbGF5U2VydmljZToKICAgIGRl +ZiBfX2luaXRfXyhzZWxmLCBjYXRhbG9nLCBydW50aW1lLCBzdGFydGVyPVNUQVJURVIpOgogICAgICAgIHNlbGYuY2F0YWxvZyA9 +IGNhdGFsb2cKICAgICAgICBzZWxmLnJ1bnRpbWUgPSBQYXRoKHJ1bnRpbWUpCiAgICAgICAgc2VsZi5zdGFydGVyID0gc3RhcnRl +cgogICAgICAgIHNlbGYubG9jayA9IHRocmVhZGluZy5Mb2NrKCkKICAgICAgICBzZWxmLmpvYnMgPSB7fQogICAgICAgIHNlbGYu +c3RhdGVzID0ge30KICAgICAgICBzZWxmLnByb2Nlc3NlcyA9IHt9CiAgICAgICAgc2VsZi5uYW1lZF9wcm9jZXNzZXMgPSB7fQog +ICAgICAgIHNlbGYud2Vic29ja2lmeV9wcm9jZXNzZXMgPSB7fQogICAgICAgIHNlbGYuc2h1dGRvd24gPSB0aHJlYWRpbmcuRXZl +bnQoKQoKICAgIGRlZiBzdGFydChzZWxmLCBudW1iZXIpOgogICAgICAgIHdpdGggc2VsZi5sb2NrOgogICAgICAgICAgICBpZiBu +dW1iZXIgaW4gc2VsZi5qb2JzOgogICAgICAgICAgICAgICAgcmV0dXJuIHNlbGYuam9ic1tudW1iZXJdCiAgICAgICAgICAgIGZp +bmlzaGVkID0gdGhyZWFkaW5nLkV2ZW50KCkKICAgICAgICAgICAgc2VsZi5qb2JzW251bWJlcl0gPSBmaW5pc2hlZAogICAgICAg +ICAgICBzZWxmLnN0YXRlc1tudW1iZXJdID0gImxhdW5jaGluZyIKICAgICAgICAgICAgdGhyZWFkaW5nLlRocmVhZCh0YXJnZXQ9 +c2VsZi5zdXBlcnZpc2UsIGFyZ3M9KG51bWJlciwgZmluaXNoZWQpLCBkYWVtb249VHJ1ZSkuc3RhcnQoKQogICAgICAgICAgICBy +ZXR1cm4gZmluaXNoZWQKCiAgICBkZWYgdGVybWluYXRlKHNlbGYsIG51bWJlcik6CiAgICAgICAgZm9yIHByb2Nlc3MgaW4gc2Vs +Zi5wcm9jZXNzZXMucG9wKG51bWJlciwgW10pOgogICAgICAgICAgICB0cnk6CiAgICAgICAgICAgICAgICBwcm9jZXNzLnRlcm1p +bmF0ZSgpCiAgICAgICAgICAgIGV4Y2VwdCBPU0Vycm9yOgogICAgICAgICAgICAgICAgcGFzcwogICAgICAgIHNlbGYubmFtZWRf +cHJvY2Vzc2VzLnBvcChudW1iZXIsIE5vbmUpCiAgICAgICAgc2VsZi50ZXJtaW5hdGVfd2Vic29ja2lmeShudW1iZXIpCiAgICAg +ICAgdHJ5OgogICAgICAgICAgICAoc2VsZi5ydW50aW1lIC8gc3RyKG51bWJlcikgLyAic2Vzc2lvbi5qc29uIikudW5saW5rKCkK +ICAgICAgICBleGNlcHQgRmlsZU5vdEZvdW5kRXJyb3I6CiAgICAgICAgICAgIHBhc3MKCiAgICBkZWYgdGVybWluYXRlX3dlYnNv +Y2tpZnkoc2VsZiwgbnVtYmVyKToKICAgICAgICBwcm9jZXNzID0gc2VsZi53ZWJzb2NraWZ5X3Byb2Nlc3Nlcy5wb3AobnVtYmVy +LCBOb25lKQogICAgICAgIGlmIHByb2Nlc3MgaXMgTm9uZToKICAgICAgICAgICAgcmV0dXJuCiAgICAgICAgdHJ5OgogICAgICAg +ICAgICBwcm9jZXNzLnRlcm1pbmF0ZSgpCiAgICAgICAgZXhjZXB0IE9TRXJyb3I6CiAgICAgICAgICAgIHBhc3MKCiAgICBAc3Rh +dGljbWV0aG9kCiAgICBkZWYgc2NvcGVkX2NvbXBvbmVudF9wYXRoKGV4ZWN1dGFibGUsIHBhdGgsIG51bWJlciwgcnVudGltZSk6 +CiAgICAgICAgIiIiR2l2ZSBwZXItZGlzcGxheSBkZXNrdG9wIGNsaWVudHMgbmFtZXMgdGhlIDoxIHN1cGVydmlzb3IgY2Fubm90 +IG1hdGNoLiIiIgogICAgICAgIHNvdXJjZSA9IFBhdGgocGF0aCkKICAgICAgICBpZiBub3Qgc291cmNlLmlzX2ZpbGUoKToKICAg +ICAgICAgICAgcmV0dXJuIHBhdGgKICAgICAgICBkaXJlY3RvcnkgPSBQYXRoKHJ1bnRpbWUpIC8gImJpbiIKICAgICAgICBkaXJl +Y3RvcnkubWtkaXIobW9kZT0wbzcwMCwgcGFyZW50cz1UcnVlLCBleGlzdF9vaz1UcnVlKQogICAgICAgIHRhcmdldCA9IGRpcmVj +dG9yeSAvIGYiY211eC1kaXNwbGF5LXtudW1iZXJ9LXtzb3VyY2UubmFtZX0iCiAgICAgICAgaWYgbm90IHRhcmdldC5leGlzdHMo +KSBvciB0YXJnZXQuc3RhdCgpLnN0X210aW1lX25zIDwgc291cmNlLnN0YXQoKS5zdF9tdGltZV9uczoKICAgICAgICAgICAgc2h1 +dGlsLmNvcHkyKHNvdXJjZSwgdGFyZ2V0KQogICAgICAgICAgICB0YXJnZXQuY2htb2QoMG83MDApCiAgICAgICAgcmV0dXJuIHN0 +cih0YXJnZXQpCgogICAgQHN0YXRpY21ldGhvZAogICAgZGVmIHdhaXRfZm9yX3BvcnQocG9ydCwgdGltZW91dD0yMCk6CiAgICAg +ICAgZGVhZGxpbmUgPSB0aW1lLm1vbm90b25pYygpICsgdGltZW91dAogICAgICAgIHdoaWxlIHRpbWUubW9ub3RvbmljKCkgPCBk +ZWFkbGluZToKICAgICAgICAgICAgdHJ5OgogICAgICAgICAgICAgICAgd2l0aCBzb2NrZXQuY3JlYXRlX2Nvbm5lY3Rpb24oKCIx +MjcuMC4wLjEiLCBwb3J0KSwgdGltZW91dD0wLjUpOgogICAgICAgICAgICAgICAgICAgIHJldHVybiBUcnVlCiAgICAgICAgICAg +IGV4Y2VwdCBPU0Vycm9yOgogICAgICAgICAgICAgICAgdGltZS5zbGVlcCgwLjEpCiAgICAgICAgcmV0dXJuIEZhbHNlCgogICAg +QHN0YXRpY21ldGhvZAogICAgZGVmIHByb2Nlc3NfYWxpdmUocHJvY2Vzcyk6CiAgICAgICAgcG9sbCA9IGdldGF0dHIocHJvY2Vz +cywgInBvbGwiLCBOb25lKQogICAgICAgIHJldHVybiBwb2xsIGlzIE5vbmUgb3IgcG9sbCgpIGlzIE5vbmUKCiAgICBkZWYgY29t +cG9uZW50c19hbGl2ZShzZWxmLCBudW1iZXIpOgogICAgICAgIG5hbWVkID0gc2VsZi5uYW1lZF9wcm9jZXNzZXMuZ2V0KG51bWJl +cikKICAgICAgICBpZiBuYW1lZCBpcyBub3QgTm9uZToKICAgICAgICAgICAgcmV0dXJuIGJvb2wobmFtZWQpIGFuZCBhbGwoc2Vs +Zi5wcm9jZXNzX2FsaXZlKHByb2Nlc3MpIGZvciBwcm9jZXNzIGluIG5hbWVkLnZhbHVlcygpKSBcCiAgICAgICAgICAgICAgICBh +bmQgKCJ4dm5jIiBpbiBuYW1lZCBvciByZmJfcmVhZHkobnVtYmVyKSkKICAgICAgICBwcm9jZXNzZXMgPSBzZWxmLnByb2Nlc3Nl +cy5nZXQobnVtYmVyLCBbXSkKICAgICAgICByZXR1cm4gYm9vbChwcm9jZXNzZXMpIGFuZCBhbGwoc2VsZi5wcm9jZXNzX2FsaXZl +KHByb2Nlc3MpIGZvciBwcm9jZXNzIGluIHByb2Nlc3NlcykKCiAgICBkZWYgc2VydmljZV9yZWFkeShzZWxmLCBudW1iZXIpOgog +ICAgICAgIGlmIG5vdCByZWFkeShudW1iZXIpIG9yIG5vdCBzZWxmLmNvbXBvbmVudHNfYWxpdmUobnVtYmVyKToKICAgICAgICAg +ICAgcmV0dXJuIEZhbHNlCiAgICAgICAgIyBPbmx5IGEgd2Vic29ja2lmeSB0aGlzIHNlcnZpY2Ugc3RhcnRlZCBpcyBrbm93biB0 +byB1c2UgTk9WTkNfQklORC4KICAgICAgICAjIEFuIHVudHJhY2tlZCBvbmUgKGZyb20gYW4gb2xkZXIgaGVscGVyKSBpcyByZXBs +YWNlZCwgd2hpY2ggcmViaW5kcyBpdAogICAgICAgICMgd2l0aG91dCB0b3VjaGluZyB0aGUgWCBzZXNzaW9uLgogICAgICAgIHdl +YnNvY2tpZnkgPSBzZWxmLndlYnNvY2tpZnlfcHJvY2Vzc2VzLmdldChudW1iZXIpCiAgICAgICAgcmV0dXJuIHdlYnNvY2tpZnkg +aXMgbm90IE5vbmUgYW5kIHNlbGYucHJvY2Vzc19hbGl2ZSh3ZWJzb2NraWZ5KQoKICAgIGRlZiB0cmFja19wcm9jZXNzKHNlbGYs +IG51bWJlciwgbmFtZSwgcHJvY2Vzcyk6CiAgICAgICAgbmFtZWQgPSBzZWxmLm5hbWVkX3Byb2Nlc3Nlcy5zZXRkZWZhdWx0KG51 +bWJlciwge30pCiAgICAgICAgcHJldmlvdXMgPSBuYW1lZC5nZXQobmFtZSkKICAgICAgICBpZiBwcmV2aW91cyBpcyBub3QgTm9u +ZToKICAgICAgICAgICAgc2VsZi5wcm9jZXNzZXNbbnVtYmVyXSA9IFsKICAgICAgICAgICAgICAgIGl0ZW0gZm9yIGl0ZW0gaW4g +c2VsZi5wcm9jZXNzZXMuZ2V0KG51bWJlciwgW10pIGlmIGl0ZW0gaXMgbm90IHByZXZpb3VzCiAgICAgICAgICAgIF0KICAgICAg +ICBzZWxmLnByb2Nlc3Nlcy5zZXRkZWZhdWx0KG51bWJlciwgW10pLmFwcGVuZChwcm9jZXNzKQogICAgICAgIG5hbWVkW25hbWVd +ID0gcHJvY2VzcwoKICAgIGRlZiByZW1vdmVfcHJvY2VzcyhzZWxmLCBudW1iZXIsIG5hbWUpOgogICAgICAgIHByb2Nlc3MgPSBz +ZWxmLm5hbWVkX3Byb2Nlc3Nlcy5zZXRkZWZhdWx0KG51bWJlciwge30pLnBvcChuYW1lLCBOb25lKQogICAgICAgIGlmIHByb2Nl +c3MgaXMgTm9uZToKICAgICAgICAgICAgcmV0dXJuCiAgICAgICAgc2VsZi5wcm9jZXNzZXNbbnVtYmVyXSA9IFtpdGVtIGZvciBp +dGVtIGluIHNlbGYucHJvY2Vzc2VzLmdldChudW1iZXIsIFtdKSBpZiBpdGVtIGlzIG5vdCBwcm9jZXNzXQogICAgICAgIHRyeToK +ICAgICAgICAgICAgcHJvY2Vzcy50ZXJtaW5hdGUoKQogICAgICAgIGV4Y2VwdCBPU0Vycm9yOgogICAgICAgICAgICBwYXNzCgog +ICAgZGVmIHJlY292ZXJfcHJvY2Vzc2VzKHNlbGYsIG51bWJlciwgcnVudGltZSwgZW52aXJvbm1lbnQpOgogICAgICAgIHNlbGYu +cHJvY2Vzc2VzW251bWJlcl0gPSBbXQogICAgICAgIHNlbGYubmFtZWRfcHJvY2Vzc2VzW251bWJlcl0gPSB7fQogICAgICAgIHNl +c3Npb24gPSBQYXRoKHJ1bnRpbWUpIC8gInNlc3Npb24uanNvbiIKICAgICAgICB0cnk6CiAgICAgICAgICAgIHNhdmVkID0ganNv +bi5sb2FkcyhzZXNzaW9uLnJlYWRfdGV4dCgpKQogICAgICAgICAgICBkYnVzX3BpZCA9IGludChzYXZlZC5nZXQoInBpZCIsIDAp +KQogICAgICAgICAgICBkYnVzID0gT3duZWRQcm9jZXNzKGRidXNfcGlkKQogICAgICAgICAgICBpZiBkYnVzX3BpZCA+IDEgYW5k +IHNlbGYucHJvY2Vzc19hbGl2ZShkYnVzKToKICAgICAgICAgICAgICAgIGlmIHNhdmVkLmdldCgiYWRkcmVzcyIpOgogICAgICAg +ICAgICAgICAgICAgIGVudmlyb25tZW50WyJEQlVTX1NFU1NJT05fQlVTX0FERFJFU1MiXSA9IHNhdmVkWyJhZGRyZXNzIl0KICAg +ICAgICAgICAgICAgIGVudmlyb25tZW50WyJEQlVTX1NFU1NJT05fQlVTX1BJRCJdID0gc3RyKGRidXNfcGlkKQogICAgICAgICAg +ICAgICAgc2VsZi50cmFja19wcm9jZXNzKG51bWJlciwgImRidXMiLCBkYnVzKQogICAgICAgIGV4Y2VwdCAoT1NFcnJvciwgVmFs +dWVFcnJvciwgVHlwZUVycm9yLCBqc29uLkpTT05EZWNvZGVFcnJvcik6CiAgICAgICAgICAgIHBhc3MKICAgICAgICB1aWQgPSBz +dHIob3MuZ2V0dWlkKCkpCiAgICAgICAgZm9yIG5hbWUgaW4gKCJvcGVuYm94IiwgInRpbnQyIiwgInZuY2NvbmZpZyIpOgogICAg +ICAgICAgICBtYXJrZXIgPSBzdHIoUGF0aChydW50aW1lKSAvICJiaW4iIC8gZiJjbXV4LWRpc3BsYXkte251bWJlcn0te25hbWV9 +IikKICAgICAgICAgICAgdHJ5OgogICAgICAgICAgICAgICAgcGF0dGVybiA9IHJmIl57cmUuZXNjYXBlKG1hcmtlcil9KD86W1s6 +c3BhY2U6XV18JCkiCiAgICAgICAgICAgICAgICBvdXRwdXQgPSBzdWJwcm9jZXNzLmNoZWNrX291dHB1dChbInBncmVwIiwgIi11 +IiwgdWlkLCAiLWYiLCBwYXR0ZXJuXSwgdGV4dD1UcnVlKQogICAgICAgICAgICAgICAgcGlkID0gaW50KG91dHB1dC5zcGxpdGxp +bmVzKClbMF0pCiAgICAgICAgICAgIGV4Y2VwdCAoT1NFcnJvciwgVmFsdWVFcnJvciwgSW5kZXhFcnJvciwgc3VicHJvY2Vzcy5D +YWxsZWRQcm9jZXNzRXJyb3IpOgogICAgICAgICAgICAgICAgY29udGludWUKICAgICAgICAgICAgc2VsZi50cmFja19wcm9jZXNz +KG51bWJlciwgbmFtZSwgT3duZWRQcm9jZXNzKHBpZCkpCiAgICAgICAgdHJ5OgogICAgICAgICAgICBvdXRwdXQgPSBzdWJwcm9j +ZXNzLmNoZWNrX291dHB1dCgKICAgICAgICAgICAgICAgIFsicGdyZXAiLCAiLXUiLCB1aWQsICItZiIsCiAgICAgICAgICAgICAg +ICAgcmYiXig/Oi4qXC8pPyg/Olh2bmN8WHRpZ2Vydm5jKS4qOntudW1iZXJ9LiotcmZicG9ydFxzK3s1OTAwICsgbnVtYmVyfSg/ +OlxzfCQpIl0sCiAgICAgICAgICAgICAgICB0ZXh0PVRydWUKICAgICAgICAgICAgKQogICAgICAgICAgICBwaWQgPSBpbnQob3V0 +cHV0LnNwbGl0bGluZXMoKVswXSkKICAgICAgICBleGNlcHQgKE9TRXJyb3IsIFZhbHVlRXJyb3IsIEluZGV4RXJyb3IsIHN1YnBy +b2Nlc3MuQ2FsbGVkUHJvY2Vzc0Vycm9yKToKICAgICAgICAgICAgcGlkID0gMAogICAgICAgIGlmIHBpZCA+IDE6CiAgICAgICAg +ICAgIHNlbGYudHJhY2tfcHJvY2VzcyhudW1iZXIsICJ4dm5jIiwgT3duZWRQcm9jZXNzKHBpZCkpCgogICAgZGVmIHN0YXJ0X2Ri +dXMoc2VsZiwgbnVtYmVyLCBlbnZpcm9ubWVudCwgcnVudGltZSk6CiAgICAgICAgYnVzID0gc3VicHJvY2Vzcy5ydW4oWyJkYnVz +LWxhdW5jaCIsICItLXNoLXN5bnRheCJdLCBlbnY9ZW52aXJvbm1lbnQsCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgc3Rk +aW49c3VicHJvY2Vzcy5ERVZOVUxMLCBzdGRvdXQ9c3VicHJvY2Vzcy5QSVBFLAogICAgICAgICAgICAgICAgICAgICAgICAgICAg +IHN0ZGVycj1zdWJwcm9jZXNzLkRFVk5VTEwsIHRleHQ9VHJ1ZSwgdGltZW91dD0xMCwKICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICBjaGVjaz1UcnVlKS5zdGRvdXQKICAgICAgICBmb3IgbGluZSBpbiBidXMuc3BsaXRsaW5lcygpOgogICAgICAgICAgICBp +ZiAiPSIgbm90IGluIGxpbmU6CiAgICAgICAgICAgICAgICBjb250aW51ZQogICAgICAgICAgICBrZXksIHZhbHVlID0gbGluZS5z +cGxpdCgiPSIsIDEpCiAgICAgICAgICAgIGVudmlyb25tZW50W2tleV0gPSB2YWx1ZS5zdHJpcCgpLnJzdHJpcCgiOyIpLnN0cmlw +KCkuc3RyaXAoIiciKQogICAgICAgIHRyeToKICAgICAgICAgICAgZGJ1c19waWQgPSBpbnQoZW52aXJvbm1lbnQuZ2V0KCJEQlVT +X1NFU1NJT05fQlVTX1BJRCIsICIwIikpCiAgICAgICAgZXhjZXB0IFZhbHVlRXJyb3I6CiAgICAgICAgICAgIGRidXNfcGlkID0g +MAogICAgICAgIGlmIGRidXNfcGlkID4gMToKICAgICAgICAgICAgc2VsZi50cmFja19wcm9jZXNzKG51bWJlciwgImRidXMiLCBP +d25lZFByb2Nlc3MoZGJ1c19waWQpKQogICAgICAgICAgICAoUGF0aChydW50aW1lKSAvICJzZXNzaW9uLmpzb24iKS53cml0ZV90 +ZXh0KGpzb24uZHVtcHMoewogICAgICAgICAgICAgICAgInBpZCI6IGRidXNfcGlkLAogICAgICAgICAgICAgICAgImFkZHJlc3Mi +OiBlbnZpcm9ubWVudC5nZXQoIkRCVVNfU0VTU0lPTl9CVVNfQUREUkVTUyIsICIiKQogICAgICAgICAgICB9KSkKCiAgICBkZWYg +c3RhcnRfc2Vzc2lvbl9jb21wb25lbnRzKHNlbGYsIG51bWJlciwgZW52aXJvbm1lbnQsIHJ1bnRpbWUpOgogICAgICAgIGZvciBl +eGVjdXRhYmxlLCBhcmdzIGluIFsKICAgICAgICAgICAgKCJvcGVuYm94IiwgW10pLCAoInRpbnQyIiwgWyItYyIsICIvZXRjL2Nt +dXgvdGludDJyYyJdKSwKICAgICAgICAgICAgKCJ2bmNjb25maWciLCBbIi1ub3dpbiJdKSwKICAgICAgICBdOgogICAgICAgICAg +ICBleGlzdGluZyA9IHNlbGYubmFtZWRfcHJvY2Vzc2VzLmdldChudW1iZXIsIHt9KS5nZXQoZXhlY3V0YWJsZSkKICAgICAgICAg +ICAgaWYgZXhpc3RpbmcgaXMgbm90IE5vbmUgYW5kIHNlbGYucHJvY2Vzc19hbGl2ZShleGlzdGluZyk6CiAgICAgICAgICAgICAg +ICBjb250aW51ZQogICAgICAgICAgICBwYXRoID0gZXhlY3V0YWJsZSBpZiAiLyIgaW4gZXhlY3V0YWJsZSBlbHNlIHNodXRpbC53 +aGljaChleGVjdXRhYmxlKQogICAgICAgICAgICBpZiBub3QgcGF0aDoKICAgICAgICAgICAgICAgIGlmIGV4ZWN1dGFibGUgPT0g +InZuY2NvbmZpZyI6CiAgICAgICAgICAgICAgICAgICAgY29udGludWUKICAgICAgICAgICAgICAgIHJhaXNlIFJ1bnRpbWVFcnJv +cihmIm1pc3NpbmcgZGlzcGxheSBjb21wb25lbnQ6IHtleGVjdXRhYmxlfSIpCiAgICAgICAgICAgIHBhdGggPSBzZWxmLnNjb3Bl +ZF9jb21wb25lbnRfcGF0aChleGVjdXRhYmxlLCBwYXRoLCBudW1iZXIsIHJ1bnRpbWUpCiAgICAgICAgICAgIHByb2Nlc3MgPSBz +dWJwcm9jZXNzLlBvcGVuKFtwYXRoXSArIGFyZ3MsIGVudj1lbnZpcm9ubWVudCwKICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgc3RkaW49c3VicHJvY2Vzcy5ERVZOVUxMLAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICBzdGRvdXQ9c3VicHJvY2Vzcy5ERVZOVUxMLCBzdGRlcnI9c3VicHJvY2Vzcy5ERVZOVUxMKQogICAgICAgICAgICBzZWxmLnRy +YWNrX3Byb2Nlc3MobnVtYmVyLCBleGVjdXRhYmxlLCBwcm9jZXNzKQoKICAgIGRlZiBzZXRfd2FsbHBhcGVyKHNlbGYsIGVudmly +b25tZW50KToKICAgICAgICB3YWxscGFwZXIgPSBQYXRoKCIvdXNyL3NoYXJlL2JhY2tncm91bmRzL2NtdXgvd2FsbHBhcGVyLmpw +ZyIpCiAgICAgICAgaWYgd2FsbHBhcGVyLmV4aXN0cygpIGFuZCBzaHV0aWwud2hpY2goImZlaCIpOgogICAgICAgICAgICB0cnk6 +CiAgICAgICAgICAgICAgICBzdWJwcm9jZXNzLnJ1bihbImZlaCIsICItLW5vLWZlaGJnIiwgIi0tYmctZmlsbCIsIHN0cih3YWxs +cGFwZXIpXSwKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIGVudj1lbnZpcm9ubWVudCwgc3RkaW49c3VicHJvY2Vzcy5E +RVZOVUxMLAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgc3Rkb3V0PXN1YnByb2Nlc3MuREVWTlVMTCwgc3RkZXJyPXN1 +YnByb2Nlc3MuREVWTlVMTCwKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIGNoZWNrPUZhbHNlLCB0aW1lb3V0PTEwKQog +ICAgICAgICAgICAgICAgcmV0dXJuCiAgICAgICAgICAgIGV4Y2VwdCAoT1NFcnJvciwgc3VicHJvY2Vzcy5TdWJwcm9jZXNzRXJy +b3IpOgogICAgICAgICAgICAgICAgcGFzcwogICAgICAgIHhzZXRyb290ID0gc2h1dGlsLndoaWNoKCJ4c2V0cm9vdCIpCiAgICAg +ICAgaWYgeHNldHJvb3Q6CiAgICAgICAgICAgIHN1YnByb2Nlc3MucnVuKFt4c2V0cm9vdCwgIi1zb2xpZCIsICIjMWYyNDMwIl0s +IGVudj1lbnZpcm9ubWVudCwKICAgICAgICAgICAgICAgICAgICAgICAgICAgc3RkaW49c3VicHJvY2Vzcy5ERVZOVUxMLCBzdGRv +dXQ9c3VicHJvY2Vzcy5ERVZOVUxMLAogICAgICAgICAgICAgICAgICAgICAgICAgICBzdGRlcnI9c3VicHJvY2Vzcy5ERVZOVUxM +LCBjaGVjaz1GYWxzZSkKCiAgICBAc3RhdGljbWV0aG9kCiAgICBkZWYgdW50cmFja2VkX3dlYnNvY2tpZnlfcGlkcyhudW1iZXIs +IHByb2M9UGF0aCgiL3Byb2MiKSk6CiAgICAgICAgIiIiVGhpcyB1c2VyJ3Mgd2Vic29ja2lmeSBwcm9jZXNzZXMgcHJveHlpbmcg +dGhpcyBkaXNwbGF5LCBtYXRjaGVkIG9uCiAgICAgICAgZXhhY3QgYXJndiAocGdyZXAncyByZWdleCBkaWFsZWN0IGNhbm5vdCBl +eHByZXNzIHRoZSBhZGRyZXNzIHNldCkuIiIiCiAgICAgICAgbGlzdGVuID0ge2Yie2hvc3R9Ons2OTAwICsgbnVtYmVyfSIgZm9y +IGhvc3QgaW4gKCIxMjcuMC4wLjEiLCAiWzo6XSIsICIwLjAuMC4wIil9CiAgICAgICAgdGFyZ2V0ID0gZiIxMjcuMC4wLjE6ezU5 +MDAgKyBudW1iZXJ9IgogICAgICAgIHBpZHMgPSBbXQogICAgICAgIGZvciBlbnRyeSBpbiBwcm9jLml0ZXJkaXIoKToKICAgICAg +ICAgICAgaWYgbm90IGVudHJ5Lm5hbWUuaXNkaWdpdCgpIG9yIGludChlbnRyeS5uYW1lKSA9PSBvcy5nZXRwaWQoKToKICAgICAg +ICAgICAgICAgIGNvbnRpbnVlCiAgICAgICAgICAgIHRyeToKICAgICAgICAgICAgICAgIGlmIGVudHJ5LnN0YXQoKS5zdF91aWQg +IT0gb3MuZ2V0dWlkKCk6CiAgICAgICAgICAgICAgICAgICAgY29udGludWUKICAgICAgICAgICAgICAgIGFyZ3YgPSAoZW50cnkg +LyAiY21kbGluZSIpLnJlYWRfYnl0ZXMoKS5zcGxpdChiIlwwIikKICAgICAgICAgICAgZXhjZXB0IE9TRXJyb3I6CiAgICAgICAg +ICAgICAgICBjb250aW51ZQogICAgICAgICAgICBhcmdzID0gW3BhcnQuZGVjb2RlKGVycm9ycz0icmVwbGFjZSIpIGZvciBwYXJ0 +IGluIGFyZ3YgaWYgcGFydF0KICAgICAgICAgICAgaWYgYW55KFBhdGgoYXJnKS5uYW1lID09ICJ3ZWJzb2NraWZ5IiBmb3IgYXJn +IGluIGFyZ3NbOjJdKSBcCiAgICAgICAgICAgICAgICAgICAgYW5kIGxlbihhcmdzKSA+PSAyIGFuZCBhcmdzWy0xXSA9PSB0YXJn +ZXQgYW5kIGFyZ3NbLTJdIGluIGxpc3RlbjoKICAgICAgICAgICAgICAgIHBpZHMuYXBwZW5kKGludChlbnRyeS5uYW1lKSkKICAg +ICAgICByZXR1cm4gcGlkcwoKICAgIEBjbGFzc21ldGhvZAogICAgZGVmIHRlcm1pbmF0ZV91bnRyYWNrZWRfd2Vic29ja2lmeShj +bHMsIG51bWJlcik6CiAgICAgICAgIiIiU3RvcHMgd2Vic29ja2lmeSBsaXN0ZW5lcnMgZm9yIHRoaXMgZGlzcGxheSBsZWZ0IGJ5 +IGFuIG9sZGVyIGhlbHBlcgogICAgICAgIChhIHJlc3RhcnRlZCBzZXJ2aWNlIGtlZXBzIHRoZSBYIHNlc3Npb24gYnV0IG5vdCBp +dHMgcHJveHkgaGFuZGxlKSwgc28KICAgICAgICB0aGUgcmVwbGFjZW1lbnQgaXMgdGhlIG9ubHkgbGlzdGVuZXIgb24gdGhlIHBv +cnQuIiIiCiAgICAgICAgZm9yIHBpZCBpbiBjbHMudW50cmFja2VkX3dlYnNvY2tpZnlfcGlkcyhudW1iZXIpOgogICAgICAgICAg +ICB0cnk6CiAgICAgICAgICAgICAgICBvcy5raWxsKHBpZCwgMTUpCiAgICAgICAgICAgIGV4Y2VwdCBPU0Vycm9yOgogICAgICAg +ICAgICAgICAgcGFzcwogICAgICAgIGRlYWRsaW5lID0gdGltZS5tb25vdG9uaWMoKSArIDIKICAgICAgICB3aGlsZSBsaXN0ZW5p +bmcoNjkwMCArIG51bWJlcikgYW5kIHRpbWUubW9ub3RvbmljKCkgPCBkZWFkbGluZToKICAgICAgICAgICAgdGltZS5zbGVlcCgw +LjA1KQoKICAgIGRlZiBzdGFydF93ZWJzb2NraWZ5KHNlbGYsIG51bWJlciwgZW52aXJvbm1lbnQsIGV4ZWN1dGFibGUpOgogICAg +ICAgIHBhdGggPSBleGVjdXRhYmxlIGlmICIvIiBpbiBleGVjdXRhYmxlIGVsc2Ugc2h1dGlsLndoaWNoKGV4ZWN1dGFibGUpCiAg +ICAgICAgaWYgbm90IHBhdGg6CiAgICAgICAgICAgIHJhaXNlIE5vVk5DVW5hdmFpbGFibGUoIm1pc3NpbmcgZGlzcGxheSBjb21w +b25lbnQ6IHdlYnNvY2tpZnkiKQogICAgICAgIHNlbGYudGVybWluYXRlX3dlYnNvY2tpZnkobnVtYmVyKQogICAgICAgIHNlbGYu +dGVybWluYXRlX3VudHJhY2tlZF93ZWJzb2NraWZ5KG51bWJlcikKICAgICAgICByZmJfcG9ydCwgbm92bmNfcG9ydCA9IDU5MDAg +KyBudW1iZXIsIDY5MDAgKyBudW1iZXIKICAgICAgICBwcm9jZXNzID0gc3VicHJvY2Vzcy5Qb3BlbigKICAgICAgICAgICAgW3Bh +dGgsICItLXdlYiIsICIvdXNyL3NoYXJlL25vdm5jIiwgIi0taGVhcnRiZWF0IiwgIjMwIiwKICAgICAgICAgICAgIGYie05PVk5D +X0JJTkR9Ontub3ZuY19wb3J0fSIsIGYiMTI3LjAuMC4xOntyZmJfcG9ydH0iXSwKICAgICAgICAgICAgZW52PWVudmlyb25tZW50 +LCBzdGRpbj1zdWJwcm9jZXNzLkRFVk5VTEwsCiAgICAgICAgICAgIHN0ZG91dD1zdWJwcm9jZXNzLkRFVk5VTEwsIHN0ZGVycj1z +dWJwcm9jZXNzLkRFVk5VTEwpCiAgICAgICAgc2VsZi53ZWJzb2NraWZ5X3Byb2Nlc3Nlc1tudW1iZXJdID0gcHJvY2VzcwogICAg +ICAgIGlmIG5vdCBzZWxmLndhaXRfZm9yX3BvcnQobm92bmNfcG9ydCwgdGltZW91dD0xMCk6CiAgICAgICAgICAgIHNlbGYudGVy +bWluYXRlX3dlYnNvY2tpZnkobnVtYmVyKQogICAgICAgICAgICByYWlzZSBOb1ZOQ1VuYXZhaWxhYmxlKCJub1ZOQyBkaWQgbm90 +IGJlY29tZSByZWFkeSIpCgogICAgZGVmIHN0YXJ0X2NvbXBvbmVudHMoc2VsZiwgbnVtYmVyLCBlbnZpcm9ubWVudCwgcnVudGlt +ZSk6CiAgICAgICAgaWYgc2VsZi5zZXJ2aWNlX3JlYWR5KG51bWJlcik6CiAgICAgICAgICAgIHJldHVybgogICAgICAgIHJmYl9p +c19yZWFkeSA9IHJmYl9yZWFkeShudW1iZXIpCiAgICAgICAgdm5jID0gc2h1dGlsLndoaWNoKCJYdm5jIikgb3Igc2h1dGlsLndo +aWNoKCJYdGlnZXJ2bmMiKQogICAgICAgIHdlYnNvY2tpZnkgPSBzaHV0aWwud2hpY2goIndlYnNvY2tpZnkiKQogICAgICAgIGlm +IG5vdCB3ZWJzb2NraWZ5OgogICAgICAgICAgICByYWlzZSBOb1ZOQ1VuYXZhaWxhYmxlKCJkaXNwbGF5IHJ1bnRpbWUgdW5hdmFp +bGFibGUiKQogICAgICAgIGlmIG5vdCByZmJfaXNfcmVhZHkgYW5kIG5vdCB2bmM6CiAgICAgICAgICAgIHJhaXNlIFJ1bnRpbWVF +cnJvcigiZGlzcGxheSBydW50aW1lIHVuYXZhaWxhYmxlIikKICAgICAgICBpZiByZmJfaXNfcmVhZHkgYW5kIG51bWJlciBub3Qg +aW4gc2VsZi5uYW1lZF9wcm9jZXNzZXM6CiAgICAgICAgICAgIHNlbGYucmVjb3Zlcl9wcm9jZXNzZXMobnVtYmVyLCBydW50aW1l +LCBlbnZpcm9ubWVudCkKICAgICAgICBpZiBub3QgcmZiX2lzX3JlYWR5OgogICAgICAgICAgICBzZWxmLnRlcm1pbmF0ZShudW1i +ZXIpCiAgICAgICAgICAgIHJmYl9wb3J0ID0gNTkwMCArIG51bWJlcgogICAgICAgICAgICBzZWxmLnByb2Nlc3Nlc1tudW1iZXJd +ID0gW10KICAgICAgICAgICAgc2VsZi5uYW1lZF9wcm9jZXNzZXNbbnVtYmVyXSA9IHt9CiAgICAgICAgICAgIHNlbGYudHJhY2tf +cHJvY2VzcyhudW1iZXIsICJ4dm5jIiwgc3VicHJvY2Vzcy5Qb3BlbigKICAgICAgICAgICAgICAgIFt2bmMsIGYiOntudW1iZXJ9 +IiwgIi1nZW9tZXRyeSIsIG9zLmVudmlyb24uZ2V0KCJDTVVYX1ZOQ19HRU9NRVRSWSIsICIxNDQweDkwMCIpLAogICAgICAgICAg +ICAgICAgICItZGVwdGgiLCAiMjQiLCAiLXJmYnBvcnQiLCBzdHIocmZiX3BvcnQpLCAiLWxvY2FsaG9zdCIsCiAgICAgICAgICAg +ICAgICAgIi1TZWN1cml0eVR5cGVzIiwgIk5vbmUiLCAiLUFsd2F5c1NoYXJlZCJdLAogICAgICAgICAgICAgICAgZW52PWVudmly +b25tZW50LCBzdGRpbj1zdWJwcm9jZXNzLkRFVk5VTEwsCiAgICAgICAgICAgICAgICBzdGRvdXQ9c3VicHJvY2Vzcy5ERVZOVUxM +LCBzdGRlcnI9c3VicHJvY2Vzcy5ERVZOVUxMKSkKICAgICAgICAgICAgaWYgbm90IHNlbGYud2FpdF9mb3JfcG9ydChyZmJfcG9y +dCk6CiAgICAgICAgICAgICAgICByYWlzZSBSdW50aW1lRXJyb3IoIlh2bmMgZGlkIG5vdCBiZWNvbWUgcmVhZHkiKQogICAgICAg +ICAgICBzZWxmLnN0YXJ0X2RidXMobnVtYmVyLCBlbnZpcm9ubWVudCwgcnVudGltZSkKICAgICAgICAgICAgc2VsZi5zdGFydF9z +ZXNzaW9uX2NvbXBvbmVudHMobnVtYmVyLCBlbnZpcm9ubWVudCwgcnVudGltZSkKICAgICAgICAgICAgc2VsZi5zZXRfd2FsbHBh +cGVyKGVudmlyb25tZW50KQogICAgICAgIGVsaWYgbnVtYmVyIGluIHNlbGYubmFtZWRfcHJvY2Vzc2VzOgogICAgICAgICAgICBu +YW1lZCA9IHNlbGYubmFtZWRfcHJvY2Vzc2VzW251bWJlcl0KICAgICAgICAgICAgZGJ1cyA9IG5hbWVkLmdldCgiZGJ1cyIpCiAg +ICAgICAgICAgIGlmIGRidXMgaXMgTm9uZSBvciBub3Qgc2VsZi5wcm9jZXNzX2FsaXZlKGRidXMpOgogICAgICAgICAgICAgICAg +Zm9yIG5hbWUgaW4gKCJvcGVuYm94IiwgInRpbnQyIiwgInZuY2NvbmZpZyIpOgogICAgICAgICAgICAgICAgICAgIHNlbGYucmVt +b3ZlX3Byb2Nlc3MobnVtYmVyLCBuYW1lKQogICAgICAgICAgICAgICAgc2VsZi5zdGFydF9kYnVzKG51bWJlciwgZW52aXJvbm1l +bnQsIHJ1bnRpbWUpCiAgICAgICAgICAgIHNlbGYuc3RhcnRfc2Vzc2lvbl9jb21wb25lbnRzKG51bWJlciwgZW52aXJvbm1lbnQs +IHJ1bnRpbWUpCiAgICAgICAgICAgIHNlbGYuc2V0X3dhbGxwYXBlcihlbnZpcm9ubWVudCkKICAgICAgICB3ZWJzb2NraWZ5X3By +b2Nlc3MgPSBzZWxmLndlYnNvY2tpZnlfcHJvY2Vzc2VzLmdldChudW1iZXIpCiAgICAgICAgaWYgKHdlYnNvY2tpZnlfcHJvY2Vz +cyBpcyBOb25lIG9yIG5vdCBzZWxmLnByb2Nlc3NfYWxpdmUod2Vic29ja2lmeV9wcm9jZXNzKQogICAgICAgICAgICAgICAgb3Ig +bm90IG5vdm5jX3JlYWR5KG51bWJlcikpOgogICAgICAgICAgICBzZWxmLnN0YXJ0X3dlYnNvY2tpZnkobnVtYmVyLCBlbnZpcm9u +bWVudCwgd2Vic29ja2lmeSkKICAgICAgICAocnVudGltZSAvICJlbnYiKS53cml0ZV90ZXh0KGYiZXhwb3J0IERJU1BMQVk9Ontu +dW1iZXJ9XG4iKQoKICAgIGRlZiBzdXBlcnZpc2Uoc2VsZiwgbnVtYmVyLCBmaW5pc2hlZCk6CiAgICAgICAgcnVudGltZSA9IHNl +bGYucnVudGltZSAvIHN0cihudW1iZXIpCiAgICAgICAgcnVudGltZS5ta2Rpcihtb2RlPTBvNzAwLCBwYXJlbnRzPVRydWUsIGV4 +aXN0X29rPVRydWUpCiAgICAgICAgZW52aXJvbm1lbnQgPSBkaWN0KG9zLmVudmlyb24sIERJU1BMQVk9ZiI6e251bWJlcn0iLCBD +TVVYX0RFU0tUT1BfUlVOVElNRV9ESVI9c3RyKHJ1bnRpbWUpKQogICAgICAgIGVudmlyb25tZW50WyJDSFJPTUVfQ09ORklHX0hP +TUUiXSA9IHN0cihydW50aW1lIC8gImNocm9tZS1jb25maWciKQogICAgICAgIGVudmlyb25tZW50WyJYREdfQ09ORklHX0hPTUUi +XSA9IHN0cihydW50aW1lIC8gInhkZy1jb25maWciKQogICAgICAgIGZvciBrZXkgaW4gKCJOT1RJRllfU09DS0VUIiwgIkRCVVNf +U0VTU0lPTl9CVVNfQUREUkVTUyIsICJEQlVTX1NFU1NJT05fQlVTX1BJRCIsCiAgICAgICAgICAgICAgICAgICAgIkFUX1NQSV9C +VVNfQUREUkVTUyIsICJBVF9TUElfQlVTIik6CiAgICAgICAgICAgIGVudmlyb25tZW50LnBvcChrZXksIE5vbmUpCiAgICAgICAg +d2hpbGUgbm90IHNlbGYuc2h1dGRvd24uaXNfc2V0KCk6CiAgICAgICAgICAgIHRyeToKICAgICAgICAgICAgICAgICMgVGhlIHN1 +cGVydmlzb3IgaXMgYSBoZWFsdGggY2hlY2ssIG5vdCBhIHRpbWVyLWRyaXZlbiByZXN0YXJ0LgogICAgICAgICAgICAgICAgIyBQ +cmVzZXJ2ZSBhbiBhY3RpdmUgUkZCL25vVk5DIHNlc3Npb24gdW50aWwgaXRzIG93bmVkIGVuZHBvaW50cyBmYWlsLgogICAgICAg +ICAgICAgICAgaWYgbm90IHNlbGYuc2VydmljZV9yZWFkeShudW1iZXIpOgogICAgICAgICAgICAgICAgICAgIHNlbGYuc3RhcnRf +Y29tcG9uZW50cyhudW1iZXIsIGVudmlyb25tZW50LCBydW50aW1lKQogICAgICAgICAgICAgICAgc3RhdGUgPSAicnVubmluZyIg +aWYgc2VsZi5zZXJ2aWNlX3JlYWR5KG51bWJlcikgZWxzZSAidW5hdmFpbGFibGUiCiAgICAgICAgICAgIGV4Y2VwdCBOb1ZOQ1Vu +YXZhaWxhYmxlOgogICAgICAgICAgICAgICAgc2VsZi50ZXJtaW5hdGVfd2Vic29ja2lmeShudW1iZXIpCiAgICAgICAgICAgICAg +ICBzdGF0ZSA9ICJ1bmF2YWlsYWJsZSIKICAgICAgICAgICAgZXhjZXB0IChPU0Vycm9yLCBSdW50aW1lRXJyb3IsIHN1YnByb2Nl +c3MuU3VicHJvY2Vzc0Vycm9yKToKICAgICAgICAgICAgICAgIHNlbGYudGVybWluYXRlKG51bWJlcikKICAgICAgICAgICAgICAg +IHN0YXRlID0gInVuYXZhaWxhYmxlIgogICAgICAgICAgICB3aXRoIHNlbGYubG9jazoKICAgICAgICAgICAgICAgIHNlbGYuc3Rh +dGVzW251bWJlcl0gPSBzdGF0ZQogICAgICAgICAgICBmaW5pc2hlZC5zZXQoKQogICAgICAgICAgICBzZWxmLnNodXRkb3duLndh +aXQoMzApCiAgICAgICAgc2VsZi50ZXJtaW5hdGUobnVtYmVyKQoKICAgIGRlZiBzbmFwc2hvdChzZWxmKToKICAgICAgICBwcmlt +YXJ5X3N0YXRlID0gInJ1bm5pbmciIGlmIHJlYWR5KDEpIGVsc2UgInVuYXZhaWxhYmxlIgogICAgICAgIHdpdGggc2VsZi5sb2Nr +OgogICAgICAgICAgICByZXR1cm4geyJ2ZXJzaW9uIjogMSwgImNhbkNyZWF0ZSI6IGxlbihzZWxmLmpvYnMpIDwgTUFYX0RJU1BM +QVlTIC0gMSwKICAgICAgICAgICAgICAgICAgICAiZGlzcGxheXMiOiBbZGVzY3JpcHRvcigxLCBwcmltYXJ5X3N0YXRlKV0KICAg +ICAgICAgICAgICAgICAgICArIFtkZXNjcmlwdG9yKG4sIHNlbGYuc3RhdGVzLmdldChuLCAibGF1bmNoaW5nIikpIGZvciBuIGlu +IHNlbGYuY2F0YWxvZy5udW1iZXJzKCldfQoKICAgIGRlZiBoYW5kbGUoc2VsZiwgcmVxdWVzdCk6CiAgICAgICAgaWYgcmVxdWVz +dC5nZXQoImFjdGlvbiIpID09ICJsaXN0IjoKICAgICAgICAgICAgcmV0dXJuIHNlbGYuc25hcHNob3QoKQogICAgICAgIGlmIHJl +cXVlc3QuZ2V0KCJhY3Rpb24iKSAhPSAiY3JlYXRlIjoKICAgICAgICAgICAgcmFpc2UgVmFsdWVFcnJvcigidW5zdXBwb3J0ZWQg +ZGlzcGxheSBhY3Rpb24iKQogICAgICAgIG51bWJlciA9IHNlbGYuY2F0YWxvZy5hbGxvY2F0ZShyZXF1ZXN0LmdldCgicmVxdWVz +dCIpKQogICAgICAgIHNlbGYuc3RhcnQobnVtYmVyKS53YWl0KDU1KQogICAgICAgIHJlc3VsdCA9IHNlbGYuc25hcHNob3QoKQog +ICAgICAgIHJlc3VsdFsiY3JlYXRlZCJdID0gZiJkaXNwbGF5OntudW1iZXJ9IgogICAgICAgIGlmIHNlbGYuc3RhdGVzLmdldChu +dW1iZXIpICE9ICJydW5uaW5nIjoKICAgICAgICAgICAgcmVzdWx0WyJlcnJvciJdID0gImRpc3BsYXlfc3RhcnRfZmFpbGVkIgog +ICAgICAgIHJldHVybiByZXN1bHQKCgpkZWYgc2VydmUocnVudGltZSk6CiAgICBydW50aW1lID0gUGF0aChydW50aW1lKQogICAg +cnVudGltZS5ta2Rpcihtb2RlPTBvNzAwLCBwYXJlbnRzPVRydWUsIGV4aXN0X29rPVRydWUpCiAgICB3aXRoIChydW50aW1lIC8g +ImRpc3BsYXktY29udHJvbC5sb2NrIikub3BlbigidyIpIGFzIGxvY2s6CiAgICAgICAgdHJ5OgogICAgICAgICAgICBmY250bC5m +bG9jayhsb2NrLCBmY250bC5MT0NLX0VYIHwgZmNudGwuTE9DS19OQikKICAgICAgICBleGNlcHQgQmxvY2tpbmdJT0Vycm9yOgog +ICAgICAgICAgICByZXR1cm4KICAgICAgICBpZiBub3QgYWxsKHNodXRpbC53aGljaChuYW1lKSBmb3IgbmFtZSBpbiAoIndlYnNv +Y2tpZnkiLCAib3BlbmJveCIsICJkYnVzLWxhdW5jaCIpKToKICAgICAgICAgICAgcmFpc2UgVmFsdWVFcnJvcigiZGlzcGxheSBy +dW50aW1lIHVuYXZhaWxhYmxlIikKICAgICAgICBpZiBub3QgKHNodXRpbC53aGljaCgiWHZuYyIpIG9yIHNodXRpbC53aGljaCgi +WHRpZ2Vydm5jIikpOgogICAgICAgICAgICByYWlzZSBWYWx1ZUVycm9yKCJkaXNwbGF5IHJ1bnRpbWUgdW5hdmFpbGFibGUiKQog +ICAgICAgIHNlcnZpY2UgPSBEaXNwbGF5U2VydmljZShEaXNwbGF5Q2F0YWxvZyhQYXRoLmhvbWUoKSAvICIuY211eCIgLyAiZGlz +cGxheXMiKSwgcnVudGltZSAvICJkaXNwbGF5cyIpCiAgICAgICAgZm9yIG51bWJlciBpbiBzZXJ2aWNlLmNhdGFsb2cubnVtYmVy +cygpOgogICAgICAgICAgICBzZXJ2aWNlLnN0YXJ0KG51bWJlcikKICAgICAgICBwYXRoID0gcnVudGltZSAvICJkaXNwbGF5LWNv +bnRyb2wuc29jayIKICAgICAgICBpZiBwYXRoLmV4aXN0cygpOgogICAgICAgICAgICBwYXRoLnVubGluaygpCgogICAgICAgIGNs +YXNzIEhhbmRsZXIoc29ja2V0c2VydmVyLlN0cmVhbVJlcXVlc3RIYW5kbGVyKToKICAgICAgICAgICAgZGVmIGhhbmRsZShzZWxm +KToKICAgICAgICAgICAgICAgIHNlbGYuY29ubmVjdGlvbi5zZXR0aW1lb3V0KDYwKQogICAgICAgICAgICAgICAgdHJ5OgogICAg +ICAgICAgICAgICAgICAgIHJhdyA9IHNlbGYucmZpbGUucmVhZGxpbmUoNDA5NykKICAgICAgICAgICAgICAgICAgICBpZiBsZW4o +cmF3KSA+IDQwOTY6CiAgICAgICAgICAgICAgICAgICAgICAgIHJhaXNlIFZhbHVlRXJyb3IoImRpc3BsYXkgcmVxdWVzdCB0b28g +bGFyZ2UiKQogICAgICAgICAgICAgICAgICAgIHJlc3VsdCA9IHNlcnZpY2UuaGFuZGxlKGpzb24ubG9hZHMocmF3KSkKICAgICAg +ICAgICAgICAgIGV4Y2VwdCAoVmFsdWVFcnJvciwgS2V5RXJyb3IsIFR5cGVFcnJvciwgT1NFcnJvcik6CiAgICAgICAgICAgICAg +ICAgICAgcmVzdWx0ID0geyJlcnJvciI6ICJkaXNwbGF5X3JlcXVlc3RfZmFpbGVkIn0KICAgICAgICAgICAgICAgIHRyeToKICAg +ICAgICAgICAgICAgICAgICBzZWxmLndmaWxlLndyaXRlKGpzb24uZHVtcHMocmVzdWx0KS5lbmNvZGUoKSArIGIiXG4iKQogICAg +ICAgICAgICAgICAgZXhjZXB0IE9TRXJyb3I6CiAgICAgICAgICAgICAgICAgICAgcGFzcyAgIyBUaGUgcGVyc2lzdGVkIHJlY2Vp +cHQgcmVtYWlucyByZXBsYXlhYmxlIGFmdGVyIGRpc2Nvbm5lY3QuCgogICAgICAgIGNsYXNzIFNlcnZlcihzb2NrZXRzZXJ2ZXIu +VGhyZWFkaW5nVW5peFN0cmVhbVNlcnZlcik6CiAgICAgICAgICAgIGRhZW1vbl90aHJlYWRzID0gVHJ1ZQoKICAgICAgICBwcmV2 +aW91c191bWFzayA9IG9zLnVtYXNrKDBvMDc3KQogICAgICAgIHRyeToKICAgICAgICAgICAgd2l0aCBTZXJ2ZXIoc3RyKHBhdGgp +LCBIYW5kbGVyKSBhcyBzZXJ2ZXI6CiAgICAgICAgICAgICAgICBvcy51bWFzayhwcmV2aW91c191bWFzaykKICAgICAgICAgICAg +ICAgIHNlcnZlci5zZXJ2ZV9mb3JldmVyKCkKICAgICAgICBmaW5hbGx5OgogICAgICAgICAgICBvcy51bWFzayhwcmV2aW91c191 +bWFzaykKICAgICAgICAgICAgc2VydmljZS5zaHV0ZG93bi5zZXQoKQogICAgICAgICAgICBwYXRoLnVubGluayhtaXNzaW5nX29r +PVRydWUpCgoKZGVmIG1haW4oKToKICAgIHBhcnNlciA9IGFyZ3BhcnNlLkFyZ3VtZW50UGFyc2VyKGRlc2NyaXB0aW9uPV9fZG9j +X18pCiAgICBwYXJzZXIuYWRkX2FyZ3VtZW50KCJhY3Rpb24iLCBjaG9pY2VzPVsibGlzdCIsICJjcmVhdGUiLCAic2VydmUiXSkK +ICAgIHBhcnNlci5hZGRfYXJndW1lbnQoIi0tcmVxdWVzdC1pZCIpCiAgICBhcmdzID0gcGFyc2VyLnBhcnNlX2FyZ3MoKQogICAg +cnVudGltZSA9IG9zLmVudmlyb24uZ2V0KCJDTVVYX0RFU0tUT1BfUlVOVElNRV9ESVIiLCAiL3J1bi9jbXV4LWRlc2t0b3AiKQog +ICAgaWYgYXJncy5hY3Rpb24gPT0gInNlcnZlIjoKICAgICAgICBzZXJ2ZShydW50aW1lKQogICAgICAgIHJldHVybiAwCiAgICBp +ZiBhcmdzLmFjdGlvbiA9PSAiY3JlYXRlIiBhbmQgbm90IGFyZ3MucmVxdWVzdF9pZDoKICAgICAgICBwYXJzZXIuZXJyb3IoImNy +ZWF0ZSByZXF1aXJlcyAtLXJlcXVlc3QtaWQgKHJldGFpbiBpdCB3aGVuIHJldHJ5aW5nKSIpCiAgICB0cnk6CiAgICAgICAgd2l0 +aCBzb2NrZXQuc29ja2V0KHNvY2tldC5BRl9VTklYLCBzb2NrZXQuU09DS19TVFJFQU0pIGFzIGNsaWVudDoKICAgICAgICAgICAg +Y2xpZW50LnNldHRpbWVvdXQoNjApCiAgICAgICAgICAgIGNsaWVudC5jb25uZWN0KHN0cihQYXRoKHJ1bnRpbWUpIC8gImRpc3Bs +YXktY29udHJvbC5zb2NrIikpCiAgICAgICAgICAgIGNsaWVudC5zZW5kYWxsKGpzb24uZHVtcHMoeyJhY3Rpb24iOiBhcmdzLmFj +dGlvbiwgInJlcXVlc3QiOiBhcmdzLnJlcXVlc3RfaWR9KS5lbmNvZGUoKSArIGIiXG4iKQogICAgICAgICAgICB3aXRoIGNsaWVu +dC5tYWtlZmlsZSgicmIiKSBhcyBzdHJlYW06CiAgICAgICAgICAgICAgICByZXN1bHQgPSBqc29uLmxvYWRzKHN0cmVhbS5yZWFk +bGluZSg2NTUzNikpCiAgICBleGNlcHQgKE9TRXJyb3IsIFZhbHVlRXJyb3IpOgogICAgICAgIHJlc3VsdCA9IHsiZXJyb3IiOiAi +ZGlzcGxheV9ydW50aW1lX3VuYXZhaWxhYmxlIn0KICAgIHByaW50KGpzb24uZHVtcHMocmVzdWx0KSkKICAgIHJldHVybiAxIGlm +ICJlcnJvciIgaW4gcmVzdWx0IGVsc2UgMAoKCmlmIF9fbmFtZV9fID09ICJfX21haW5fXyI6CiAgICByYWlzZSBTeXN0ZW1FeGl0 +KG1haW4oKSkK """ private static let path = "$HOME/.cmux/cmux-display" @@ -316,11 +347,36 @@ Il9fbWFpbl9fIjoKICAgIHJhaXNlIFN5c3RlbUV4aXQobWFpbigpKQo= set -eu path=\"\(path)\" mkdir -p \"$HOME/.cmux\" - if [ ! -x \"$path\" ]; then printf %s \"\(encodedSource)\" | base64 -d > \"$path\"; chmod 700 \"$path\"; fi + helper_updated=0 + candidate=\"$(mktemp \"$HOME/.cmux/cmux-display.XXXXXX\")\" + printf %s \"\(encodedSource)\" | base64 -d > \"$candidate\" + chmod 700 \"$candidate\" + if [ ! -x \"$path\" ] || ! cmp -s \"$candidate\" \"$path\"; then + mv -f \"$candidate\" \"$path\" + helper_updated=1 + else + rm -f \"$candidate\" + fi + if [ \"$helper_updated\" = 1 ] && pgrep -u \"$(id -u)\" -f \"$path serve\" >/dev/null 2>&1; then + pkill -TERM -u \"$(id -u)\" -f \"$path serve\" >/dev/null 2>&1 || true + for attempt in $(seq 1 100); do + pgrep -u \"$(id -u)\" -f \"$path serve\" >/dev/null 2>&1 || break + sleep 0.1 + done + pgrep -u \"$(id -u)\" -f \"$path serve\" >/dev/null 2>&1 && exit 1 + fi if ! pgrep -u \"$(id -u)\" -f \"$path serve\" >/dev/null 2>&1; then nohup \"$path\" serve > \"$HOME/.cmux/display-service.log\" 2>&1 & fi - \"$path\" list > /dev/null 2>&1 || exit 1 + service_ready=0 + for attempt in $(seq 1 100); do + if \"$path\" list > /dev/null 2>&1; then + service_ready=1 + break + fi + sleep 0.1 + done + [ \"$service_ready\" = 1 ] || exit 1 \"$path\" \(action)\(argument) """ return "runuser -u cmux -- env HOME=/home/cmux USER=cmux /bin/bash -lc '\(body)'" diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/CloudMachinesUsage.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/CloudMachinesUsage.swift index 8004efa56399..121f22643ea4 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/CloudMachinesUsage.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/CloudMachinesUsage.swift @@ -9,10 +9,12 @@ public struct CloudMachinesUsage: Equatable, Sendable { /// - activeCount: Number of machines currently counted against the plan. /// - maxActiveVms: Maximum active machines, or nil for an uncapped plan. /// - isPaidPlan: Whether limit help should omit the free-plan upgrade prompt. - public init(activeCount: Int, maxActiveVms: Int? = nil, isPaidPlan: Bool) { + /// - resourcePool: The shared vCPU and memory pool, or nil when the plan has none. + public init(activeCount: Int, maxActiveVms: Int? = nil, isPaidPlan: Bool, resourcePool: CloudVMResourcePool? = nil) { self.activeCount = activeCount self.maxActiveVms = maxActiveVms self.isPaidPlan = isPaidPlan + self.resourcePool = resourcePool } /// Number of machines currently counted against the plan. @@ -21,6 +23,8 @@ public struct CloudMachinesUsage: Equatable, Sendable { public let maxActiveVms: Int? /// Whether this usage belongs to a paid plan. public let isPaidPlan: Bool + /// The vCPU and memory pool the active machines share; nil without a pool. + public let resourcePool: CloudVMResourcePool? /// An uncapped plan is never at the limit. public var isAtLimit: Bool { @@ -28,6 +32,12 @@ public struct CloudMachinesUsage: Equatable, Sendable { return activeCount >= maxActiveVms } + /// The header tints its count when the plan cannot start another machine: + /// the machine ceiling is reached, or the pool cannot fit the smallest size. + public var isWarning: Bool { + isAtLimit || (resourcePool?.isExhausted ?? false) + } + /// The ceiling the header renders as a fraction. A cap of zero or less is /// not a quota the count can sit inside: the server closes provisioning /// for the plan entirely, so "3/0" reads as arithmetic nonsense while the @@ -68,8 +78,14 @@ public struct CloudMachinesUsage: Equatable, Sendable { return String(format: format, activeCount, maxActiveVms) } - /// Explains the count; at a free plan's ceiling it names the way out. + /// Explains the count; at a free plan's ceiling it names the way out. A + /// plan with a shared pool adds its usage on a second line. public var help: String { + guard let resourcePool else { return countHelp } + return countHelp + "\n" + resourcePool.usageText + } + + private var countHelp: String { if isAtLimit && !isPaidPlan, let maxActiveVms { if isSingleMachinePlan { return String( diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/CloudVMResourcePool.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/CloudVMResourcePool.swift new file mode 100644 index 000000000000..f7fc252e7968 --- /dev/null +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/CloudVMResourcePool.swift @@ -0,0 +1,143 @@ +import Foundation + +/// The plan's shared Cloud VM resource pool and how much of it active machines +/// use (`GET /api/vm` `limits.poolVcpus`, `poolMemoryMb`, `usedVcpus`, +/// `usedMemoryMb`). Every provisioning and running machine draws from one +/// pool; paused machines do not. The server enforces the pool; this type only +/// lets the client explain it before a create fails. +public struct CloudVMResourcePool: Equatable, Sendable { + /// Creates a pool readout. + /// - Parameters: + /// - poolVcpus: vCPUs the plan shares across its machines. + /// - poolMemoryMb: Memory the plan shares across its machines, in MB. + /// - usedVcpus: vCPUs held by provisioning and running machines. + /// - usedMemoryMb: Memory held by provisioning and running machines, in MB. + public init(poolVcpus: Int, poolMemoryMb: Int, usedVcpus: Int, usedMemoryMb: Int) { + self.poolVcpus = poolVcpus + self.poolMemoryMb = poolMemoryMb + self.usedVcpus = usedVcpus + self.usedMemoryMb = usedMemoryMb + } + + /// Reads the pool from a `limits` object. Nil when the plan has no pool + /// (Go, free) or the control plane predates the fields. + /// - Parameter limits: The decoded `limits` JSON object. + public init?(limits: [String: Any]) { + guard let poolVcpus = Self.positiveInt(limits["poolVcpus"]), + let poolMemoryMb = Self.positiveInt(limits["poolMemoryMb"]) else { return nil } + self.init( + poolVcpus: poolVcpus, + poolMemoryMb: poolMemoryMb, + usedVcpus: Self.nonNegativeInt(limits["usedVcpus"]) ?? 0, + usedMemoryMb: Self.nonNegativeInt(limits["usedMemoryMb"]) ?? 0 + ) + } + + /// vCPUs the plan shares across its machines. + public let poolVcpus: Int + /// Memory the plan shares across its machines, in MB. + public let poolMemoryMb: Int + /// vCPUs held by provisioning and running machines. + public let usedVcpus: Int + /// Memory held by provisioning and running machines, in MB. + public let usedMemoryMb: Int + + /// vCPUs a new or resumed machine can still take. + public var freeVcpus: Int { max(0, poolVcpus - usedVcpus) } + /// Memory a new or resumed machine can still take, in MB. + public var freeMemoryMb: Int { max(0, poolMemoryMb - usedMemoryMb) } + + /// Why a machine of a given shape does not fit the remaining pool. + public enum Shortfall: Equatable, Sendable { + /// The machine needs more memory than the pool has free (all in MB). + case memory(requestedMb: Int, freeMb: Int, poolMb: Int) + /// The machine needs more vCPUs than the pool has free. + case vcpus(requested: Int, free: Int, pool: Int) + } + + /// The first dimension a machine of this shape would overflow, memory + /// first because that is the number people pick a size by. + /// - Parameters: + /// - vcpus: The machine's vCPUs. + /// - memoryMb: The machine's memory, in MB. + /// - Returns: The overflow, or nil when the machine fits. + public func shortfall(vcpus: Int, memoryMb: Int) -> Shortfall? { + if memoryMb > freeMemoryMb { + return .memory(requestedMb: memoryMb, freeMb: freeMemoryMb, poolMb: poolMemoryMb) + } + if vcpus > freeVcpus { + return .vcpus(requested: vcpus, free: freeVcpus, pool: poolVcpus) + } + return nil + } + + /// "16 of 20 vCPUs · 32 of 40 GB RAM in use". + public var usageText: String { + String( + format: String( + localized: "cloud.machines.pool.usage", + defaultValue: "%1$lld of %2$lld vCPUs · %3$lld of %4$lld GB RAM in use" + ), + Int64(usedVcpus), Int64(poolVcpus), + Int64(Self.gigabytes(usedMemoryMb)), Int64(Self.gigabytes(poolMemoryMb)) + ) + } + + /// Whether even the smallest machine (2 vCPU / 4 GB) no longer fits. + public var isExhausted: Bool { + shortfall(vcpus: 2, memoryMb: 4096) != nil + } + + /// Explains a shortfall in the New Machine sheet. + /// - Parameters: + /// - shortfall: The overflow from ``shortfall(vcpus:memoryMb:)``. + /// - offersUpgrade: Whether a larger plan (Max) is available to the caller. + /// - Returns: The localized sentence naming the request and what is free. + public static func shortfallText(_ shortfall: Shortfall, offersUpgrade: Bool) -> String { + switch shortfall { + case .memory(let requestedMb, let freeMb, let poolMb): + let format = offersUpgrade + ? String( + localized: "cloud.newMachine.pool.doesNotFit", + defaultValue: "This size needs %1$lld GB RAM, but only %2$lld GB of your %3$lld GB pool is free. Pause or delete a VM, or upgrade to Max." + ) + : String( + localized: "cloud.newMachine.pool.doesNotFitMax", + defaultValue: "This size needs %1$lld GB RAM, but only %2$lld GB of your %3$lld GB pool is free. Pause or delete a VM." + ) + return String( + format: format, + Int64(gigabytes(requestedMb)), Int64(gigabytes(freeMb)), Int64(gigabytes(poolMb)) + ) + case .vcpus(let requested, let free, let pool): + let format = offersUpgrade + ? String( + localized: "cloud.newMachine.pool.doesNotFitVcpus", + defaultValue: "This size needs %1$lld vCPUs, but only %2$lld of your %3$lld vCPUs are free. Pause or delete a VM, or upgrade to Max." + ) + : String( + localized: "cloud.newMachine.pool.doesNotFitVcpusMax", + defaultValue: "This size needs %1$lld vCPUs, but only %2$lld of your %3$lld vCPUs are free. Pause or delete a VM." + ) + return String(format: format, Int64(requested), Int64(free), Int64(pool)) + } + } + + /// Whole gigabytes, rounded down so free space is never overstated. + static func gigabytes(_ memoryMb: Int) -> Int { max(0, memoryMb) / 1024 } + + private static func positiveInt(_ raw: Any?) -> Int? { + guard let value = nonNegativeInt(raw), value > 0 else { return nil } + return value + } + + private static func nonNegativeInt(_ raw: Any?) -> Int? { + let value: Int? + if let int = raw as? Int { value = int } + else if let number = raw as? NSNumber, number.doubleValue.isFinite { value = Int(exactly: number.doubleValue) } + else if let double = raw as? Double, double.isFinite { value = Int(exactly: double) } + else { value = nil } + guard let value, value >= 0 else { return nil } + return value + } +} diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachinePlanSnapshot.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachinePlanSnapshot.swift index 6d2587f415d4..67aacfe57421 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachinePlanSnapshot.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachinePlanSnapshot.swift @@ -8,7 +8,8 @@ public struct MachinePlanSnapshot: Equatable, Sendable { planId: String, freeAccessWindowDays: Int = 0, freeAccessExpiresAt: Date? = nil, - freeAccessBanner: FreeAccessBanner = .none + freeAccessBanner: FreeAccessBanner = .none, + resourcePool: CloudVMResourcePool? = nil ) { self.activeCount = activeCount self.maxActiveVms = maxActiveVms @@ -16,6 +17,7 @@ public struct MachinePlanSnapshot: Equatable, Sendable { self.freeAccessWindowDays = freeAccessWindowDays self.freeAccessExpiresAt = freeAccessExpiresAt self.freeAccessBanner = freeAccessBanner + self.resourcePool = resourcePool } public let activeCount: Int @@ -27,10 +29,13 @@ public struct MachinePlanSnapshot: Equatable, Sendable { /// Earliest free-access expiry across the fleet (server value when present). public var freeAccessExpiresAt: Date? = nil public var freeAccessBanner: FreeAccessBanner = .none + /// The vCPU and memory pool every active machine shares; nil for plans + /// without a pool (Go, free) and control planes that predate it. + public var resourcePool: CloudVMResourcePool? = nil /// The count the Cloud Machines header shows, and whether it is at the ceiling. public var usage: CloudMachinesUsage { - CloudMachinesUsage(activeCount: activeCount, maxActiveVms: maxActiveVms, isPaidPlan: isPaidPlan) + CloudMachinesUsage(activeCount: activeCount, maxActiveVms: maxActiveVms, isPaidPlan: isPaidPlan, resourcePool: resourcePool) } /// An uncapped plan is never at the limit. public var isAtLimit: Bool { usage.isAtLimit } diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachineSnapshotBuilder.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachineSnapshotBuilder.swift index d9f43810de24..ab39c85f4a3a 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachineSnapshotBuilder.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachineSnapshotBuilder.swift @@ -205,7 +205,8 @@ public enum MachineSnapshotBuilder: Sendable { planId: limits.planId, freeAccessWindowDays: limits.freeAccessWindowDays, freeAccessExpiresAt: expiresAt, - freeAccessBanner: freeAccessBanner(expiresAt: expiresAt, isPaidPlan: isPaidPlan, now: now) + freeAccessBanner: freeAccessBanner(expiresAt: expiresAt, isPaidPlan: isPaidPlan, now: now), + resourcePool: limits.resourcePool ) } } diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/SurfaceCatalogSnapshot+DeviceVisibility.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/SurfaceCatalogSnapshot+DeviceVisibility.swift deleted file mode 100644 index 9770354ca0e3..000000000000 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Surfaces/SurfaceCatalogSnapshot+DeviceVisibility.swift +++ /dev/null @@ -1,26 +0,0 @@ -import CmuxSurfaceCatalogModel -import Foundation - -extension SurfaceCatalogSnapshot { - /// Hides sidebar rows without destroying providers or already-open remote panes. - public func applyingDeviceVisibility( - includesCloud: Bool, - includesDevices: Bool, - hiddenMacIDs: Set - ) -> SurfaceCatalogSnapshot { - let visibleMachines = machines.filter { machine in - if let instance = machine.id.deviceInstance { - return includesDevices && !hiddenMacIDs.contains(instance.deviceID) - } - return includesCloud - } - let machineIDs = Set(visibleMachines.map(\.id)) - let visibleResources = resources.filter { machineIDs.contains($0.machine) } - let resourceIDs = Set(visibleResources.map(\.id)) - return SurfaceCatalogSnapshot( - machines: visibleMachines, - resources: visibleResources, - projections: projections.filter { resourceIDs.contains($0.resource) } - ) - } -} diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeGroupCount.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeGroupCount.swift index 29e53322ead7..3d5916b68919 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeGroupCount.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeGroupCount.swift @@ -23,14 +23,15 @@ public struct CloudTreeGroupCount: Equatable, Sendable { } /// Plan usage: VoiceOver hears "1 of 50 machines", and the count turns - /// orange at the ceiling, where a free plan's help names the upgrade. + /// orange at the ceiling or a full resource pool, where a free plan's help + /// names the upgrade. /// - Parameter usage: Current machine usage and plan limits. public init(usage: CloudMachinesUsage) { self.init( text: usage.compactCount, accessibilityLabel: usage.countLabel, help: usage.help, - isWarning: usage.isAtLimit + isWarning: usage.isWarning ) } diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient+CloudActivation.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient+CloudActivation.swift index 4dbe91e75e83..588a258633a9 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient+CloudActivation.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient+CloudActivation.swift @@ -40,7 +40,8 @@ extension VMClient { memoryUpgradePlansByMb: rawLimits["memoryUpgradePlansByMb"] as? [String: String], vcpusByMemoryMb: Self.decodePositiveIntMap(rawLimits["vcpusByMemoryMb"]), activeVmCount: rawLimits["activeVmCount"] as? Int, - imageKinds: Self.decodeImageKinds(rawLimits["imageKinds"]) + imageKinds: Self.decodeImageKinds(rawLimits["imageKinds"]), + resourcePool: CloudVMResourcePool(limits: rawLimits) ) } let vms = try items.enumerated().map { index, dict -> VMSummary in diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient.swift index fa2a9f2267df..f6f4df819dc1 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/VMClient/VMClient.swift @@ -58,8 +58,12 @@ public func formattedCloudVMHTTPError(status: Int, body: String) -> String { ?? cloudVMString(object["reason"]) ?? defaultCloudVMMessage(status: status) let displayMessage = cloudVMString(ui?["message"]) ?? message - let action = cloudVMString(object["action"]) - ?? defaultCloudVMAction(status: status, errorCode: errorCode, response: object) + // A full resource pool gets the client's action so a Max upgrade carries + // the attributed checkout link; the server's message still names the numbers. + let action = errorCode == "vm_resource_pool_exceeded" + ? defaultCloudVMAction(status: status, errorCode: errorCode, response: object) + : cloudVMString(object["action"]) + ?? defaultCloudVMAction(status: status, errorCode: errorCode, response: object) let retryAfterSeconds = cloudVMInt(object["retryAfterSeconds"]) ?? cloudVMInt(ui?["retryAfterSeconds"]) let details = cloudVMDetails(from: object) @@ -146,6 +150,15 @@ public func defaultCloudVMAction(status: Int, errorCode: String, response: [Stri localized: "cloudVM.error.memoryRequiresPlan.action", defaultValue: "Larger machines need cmux Max. Upgrade at %@, or choose a smaller machine." ), checkout.absoluteString) + case "vm_resource_pool_exceeded": + guard cloudVMResourcePoolUpgradePlanID(response) == CheckoutPlan.max.rawValue else { + return String(localized: "cloudVM.error.resourcePool.maxAction", defaultValue: "Pause or delete a VM.") + } + let checkout = CheckoutAttribution.checkoutURL(source: .vmResourcePoolExceededError, plan: .max) + return String( + localized: "cloudVM.error.resourcePool.action", + defaultValue: "Pause or delete a VM, or upgrade to Max." + ) + "\n" + checkout.absoluteString case "vm_create_credits_insufficient": return "Ask a team admin to upgrade the plan or grant more Cloud VM create credits, then retry." default: @@ -159,6 +172,13 @@ public func defaultCloudVMAction(status: Int, errorCode: String, response: [Stri } } +/// The plan that would make a full resource pool fit: `"max"`, or nil when the +/// caller already has the largest pool. Read from the top level, then `details`. +func cloudVMResourcePoolUpgradePlanID(_ response: [String: Any]) -> String? { + let details = response["details"] as? [String: Any] + return (cloudVMString(response["upgradePlanId"]) ?? cloudVMString(details?["upgradePlanId"]))?.lowercased() +} + private func cloudVMDetails(from object: [String: Any]) -> [String] { let allowedKeys = Set([ "amount", @@ -171,13 +191,20 @@ private func cloudVMDetails(from object: [String: Any]) -> [String] { "limit", "operation", "phase", + "poolMemoryMb", + "poolVcpus", "provider", "providerCode", "providerMessage", + "requestedMemoryMb", + "requestedVcpus", + "resource", "retryable", "retryAfterSeconds", "status", "type", + "usedMemoryMb", + "usedVcpus", "vmId", ]) var details: [String: Any] = [:] @@ -386,7 +413,8 @@ public struct VMPlanLimits: Sendable { memoryUpgradePlansByMb: [String: String]? = nil, vcpusByMemoryMb: [String: Int]? = nil, activeVmCount: Int? = nil, - imageKinds: [VMImageKindOption] = [] + imageKinds: [VMImageKindOption] = [], + resourcePool: CloudVMResourcePool? = nil ) { self.maxActiveVms = maxActiveVms self.planId = planId @@ -399,6 +427,7 @@ public struct VMPlanLimits: Sendable { self.vcpusByMemoryMb = vcpusByMemoryMb self.activeVmCount = activeVmCount self.imageKinds = imageKinds + self.resourcePool = resourcePool } /// Active-machine ceiling; nil when the plan has no cap (every paid plan). @@ -411,7 +440,7 @@ public struct VMPlanLimits: Sendable { public var freeAccessExpiresAt: Int64? /// Memory sizes the server accepts for new machines, in MB. public var memoryOptionsMb: [Int] = [] - /// Ladder sizes the plan cannot start (`[32768, 65536]` on Pro, `[]` on + /// Ladder sizes the plan cannot start (`[65536]` on Pro, `[]` on /// Max); nil when the control plane predates the field and the client /// mirror decides. public var lockedMemoryOptionsMb: [Int]? = nil @@ -423,6 +452,9 @@ public struct VMPlanLimits: Sendable { /// The kinds the default provider can serve and the image each resolves to; /// informational (`vm.limits` echoes it): one snapshot serves every kind. public var imageKinds: [VMImageKindOption] = [] + /// The vCPU and memory pool every active machine shares, with what is in + /// use; nil for plans without a pool (Go, free) and older control planes. + public var resourcePool: CloudVMResourcePool? = nil } public struct VMListPage: Sendable { diff --git a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudGuestDisplayScriptTests.swift b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudGuestDisplayScriptTests.swift new file mode 100644 index 000000000000..98bd5e7bfdf3 --- /dev/null +++ b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudGuestDisplayScriptTests.swift @@ -0,0 +1,20 @@ +import Testing +@testable import CmuxCloud + +/// `CloudGuestDisplayScript` is main-actor isolated, so its tests run there too. +@MainActor +@Suite("Cloud guest display helper") +struct CloudGuestDisplayScriptTests { + @Test("refreshes an already-installed helper before creating a display") + func refreshesInstalledHelper() { + let command = CloudGuestDisplayScript.command(action: "create") + + #expect(command.contains("candidate=\"$(mktemp \"$HOME/.cmux/cmux-display.XXXXXX\")\"")) + #expect(command.contains("cmp -s \"$candidate\" \"$path\"")) + #expect(command.contains("pkill -TERM -u \"$(id -u)\" -f \"$path serve\"")) + #expect(command.contains("service_ready=0")) + #expect(command.contains("for attempt in $(seq 1 100)")) + #expect(command.contains("[ \"$service_ready\" = 1 ] || exit 1")) + #expect(command.contains("\"$path\" create")) + } +} diff --git a/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudVMResourcePoolTests.swift b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudVMResourcePoolTests.swift new file mode 100644 index 000000000000..454a1c573bd7 --- /dev/null +++ b/Packages/macOS/CmuxCloud/Tests/CmuxCloudTests/CloudVMResourcePoolTests.swift @@ -0,0 +1,111 @@ +@testable import CmuxCloud +import Foundation +import Testing + +@Suite +struct CloudVMResourcePoolTests { + private static let proLimits: [String: Any] = [ + "planId": "pro", + "poolVcpus": 20, + "poolMemoryMb": 40960, + "usedVcpus": 16, + "usedMemoryMb": 32768, + ] + + @Test + func decodesThePoolFromListLimits() throws { + let pool = try #require(CloudVMResourcePool(limits: Self.proLimits)) + #expect(pool == CloudVMResourcePool(poolVcpus: 20, poolMemoryMb: 40960, usedVcpus: 16, usedMemoryMb: 32768)) + #expect(pool.freeVcpus == 4) + #expect(pool.freeMemoryMb == 8192) + #expect(!pool.isExhausted) + } + + @Test + func plansWithoutAPoolAndOlderServersDecodeNoPool() { + #expect(CloudVMResourcePool(limits: ["planId": "go", "poolVcpus": NSNull(), "poolMemoryMb": NSNull()]) == nil) + #expect(CloudVMResourcePool(limits: ["planId": "pro", "maxActiveVms": 5]) == nil) + // A pool without usage reads as nothing in use. + #expect(CloudVMResourcePool(limits: ["poolVcpus": 80, "poolMemoryMb": 163840])?.usedMemoryMb == 0) + } + + @Test + func memoryOverflowIsReportedBeforeVcpus() throws { + let pool = try #require(CloudVMResourcePool(limits: Self.proLimits)) + #expect(pool.shortfall(vcpus: 4, memoryMb: 8192) == nil) + #expect(pool.shortfall(vcpus: 8, memoryMb: 16384) == .memory(requestedMb: 16384, freeMb: 8192, poolMb: 40960)) + let vcpuBound = CloudVMResourcePool(poolVcpus: 20, poolMemoryMb: 40960, usedVcpus: 18, usedMemoryMb: 8192) + #expect(vcpuBound.shortfall(vcpus: 4, memoryMb: 8192) == .vcpus(requested: 4, free: 2, pool: 20)) + #expect(vcpuBound.isExhausted == false) + #expect(CloudVMResourcePool(poolVcpus: 20, poolMemoryMb: 40960, usedVcpus: 20, usedMemoryMb: 40960).isExhausted) + } + + @Test + func shortfallTextNamesTheNumbersAndOffersMaxOnlyBelowMax() { + let shortfall = CloudVMResourcePool.Shortfall.memory(requestedMb: 16384, freeMb: 8192, poolMb: 40960) + let pro = CloudVMResourcePool.shortfallText(shortfall, offersUpgrade: true) + #expect(pro.contains("16 GB")) + #expect(pro.contains("8 GB")) + #expect(pro.contains("40 GB")) + #expect(pro.contains("Max")) + let max = CloudVMResourcePool.shortfallText(shortfall, offersUpgrade: false) + #expect(!max.contains("Max")) + } + + @Test + func usageCarriesThePoolIntoTheHeaderTooltip() throws { + let pool = try #require(CloudVMResourcePool(limits: Self.proLimits)) + #expect(pool.usageText.contains("16")) + #expect(pool.usageText.contains("20")) + #expect(pool.usageText.contains("32")) + #expect(pool.usageText.contains("40")) + let limits = VMPlanLimits(maxActiveVms: 5, planId: "pro", freeAccessWindowDays: 0, resourcePool: pool) + let plan = try #require(MachineSnapshotBuilder.planSnapshot(activeCount: 2, limits: limits)) + #expect(plan.resourcePool == pool) + #expect(plan.usage.help.hasSuffix(pool.usageText)) + let full = CloudMachinesUsage( + activeCount: 2, + maxActiveVms: 5, + isPaidPlan: true, + resourcePool: CloudVMResourcePool(poolVcpus: 20, poolMemoryMb: 40960, usedVcpus: 20, usedMemoryMb: 40960) + ) + #expect(!full.isAtLimit) + #expect(CloudTreeGroupCount(usage: full).isWarning) + } + + @Test + func poolErrorActionLinksMaxCheckoutOnlyWhenTheServerOffersMax() { + let upgrade = defaultCloudVMAction( + status: 402, + errorCode: "vm_resource_pool_exceeded", + response: ["upgradePlanId": "max"] + ) + #expect(upgrade.contains("cmux_source=\(ProUpgradeSource.vmResourcePoolExceededError.rawValue)")) + #expect(upgrade.contains("plan=max")) + let nested = defaultCloudVMAction( + status: 402, + errorCode: "vm_resource_pool_exceeded", + response: ["details": ["upgradePlanId": "max"]] + ) + #expect(nested.contains("plan=max")) + let onMax = defaultCloudVMAction( + status: 402, + errorCode: "vm_resource_pool_exceeded", + response: ["upgradePlanId": NSNull()] + ) + #expect(!onMax.contains("checkout")) + } + + @Test + func formattedPoolErrorKeepsTheServerMessageAndAddsTheCheckoutLink() { + let body = """ + {"error":"vm_resource_pool_exceeded","message":"Your VMs already use 32 of 40 GB RAM. This VM needs 16 GB.", + "action":"Pause or delete a VM, or upgrade to Max.","upgradePlanId":"max", + "details":{"resource":"memoryMb","poolMemoryMb":40960,"usedMemoryMb":32768,"requestedMemoryMb":16384}} + """ + let text = formattedCloudVMHTTPError(status: 402, body: body) + #expect(text.contains("Your VMs already use 32 of 40 GB RAM.")) + #expect(text.contains("cmux_source=\(ProUpgradeSource.vmResourcePoolExceededError.rawValue)")) + #expect(text.contains("poolMemoryMb: 40960")) + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+DebugV1.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+DebugV1.swift index 374744f92464..3081652d7eda 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+DebugV1.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+DebugV1.swift @@ -72,6 +72,9 @@ extension ControlCommandCoordinator { case "flash_count": return debugContext?.controlDebugFlashCount(surfaceArgument: args) ?? Self.debugContextUnavailableResponse + case "browser_discard": + return debugContext?.controlDebugBrowserDiscard(arguments: args) + ?? Self.debugContextUnavailableResponse case "reset_flash_counts": return debugContext?.controlDebugResetFlashCounts() ?? Self.debugContextUnavailableResponse diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift index 9fd6ed497b20..c39e0bd8a530 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift @@ -132,6 +132,15 @@ public protocol ControlDebugContext: AnyObject { /// - Returns: The raw v1 response (`"OK "` or an `ERROR:` line). func controlDebugFlashCount(surfaceArgument: String) -> String + /// Runs the v1 `browser_discard` test command: unloads a browser tab's + /// page the way cmux hibernates hidden tabs to save memory. + /// + /// - Parameter arguments: `"[ force]"`. Without `force` the + /// discard respects every blocker (visible, loading, driven by a REPL + /// session, ...); with it the page is unloaded anyway. + /// - Returns: `"OK discarded"` or an `ERROR:` line naming the blockers. + func controlDebugBrowserDiscard(arguments: String) -> String + /// Runs the shared v1 `reset_flash_counts` body for `debug.flash.reset`. /// /// - Returns: The raw v1 response. diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index 23b08ed3d1cb..f866b7ac6fd2 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -279,6 +279,9 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { "browser.reload", "browser.snapshot", "browser.eval", + "browser.repl.eval", + "browser.repl.reset", + "browser.repl.list", "browser.wait", "browser.click", "browser.dblclick", diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift index e83a975ee68a..68c2424d2392 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift @@ -27,6 +27,7 @@ extension ControlDebugContext { func controlDebugResetEmptyPanelCount() -> String { "ERROR: not implemented" } func controlDebugFocusNotification(arguments: String) -> String { "ERROR: not implemented" } func controlDebugFlashCount(surfaceArgument: String) -> String { "ERROR: not implemented" } + func controlDebugBrowserDiscard(arguments: String) -> String { "ERROR: not implemented" } func controlDebugResetFlashCounts() -> String { "ERROR: not implemented" } func controlDebugPanelSnapshot(arguments: String) -> String { "ERROR: not implemented" } func controlDebugPanelSnapshotReset(surfaceArgument: String) -> String { "ERROR: not implemented" } diff --git a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteRelayBrowserReplPolicyTests.swift b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteRelayBrowserReplPolicyTests.swift new file mode 100644 index 000000000000..0a9ac7541c74 --- /dev/null +++ b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteRelayBrowserReplPolicyTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import CmuxRemoteWorkspace + +/// `browser.repl.*` evaluates arbitrary JavaScript that drives the local +/// user's browser tabs, reads their cookies, and reads and writes files under +/// a caller-chosen directory on the local Mac. A remote relay must never reach +/// it, with or without selectors that name the relay's own workspace. +@Suite("Remote relay denies the browser REPL") +struct RemoteRelayBrowserReplPolicyTests { + private let workspace = UUID() + + @Test("REPL methods are denied for every parameter shape", arguments: [ + "browser.repl.eval", "browser.repl.reset", "browser.repl.list" + ]) + func replMethodsAreDenied(method: String) throws { + let attempts: [[String: Any]] = [ + [:], + ["workspace_id": workspace.uuidString], + ["code": "await tabs.open('https://example.com')", "cwd": "/tmp"], + ["code": "console.log(1)", "caller_workspace_id": workspace.uuidString], + ["code": "console.log(1)", "workspace_id": workspace.uuidString, "session": "s1"], + ["session": "s1"], + ] + for params in attempts { + let line = try JSONSerialization.data(withJSONObject: ["id": "r1", "method": method, "params": params]) + #expect(RemoteRelayCommandPolicy().evaluate( + commandLine: line, + workspaceAliases: [workspace: workspace], + surfaceAliases: [:] + ) != .allow) + } + } + + @Test("Capability discovery never advertises the REPL to a relay") + func replIsNotDiscoverable() { + let methods = RemoteRelayCommandPolicy().permittedMethods(from: [ + "system.ping", "browser.repl.eval", "browser.repl.reset", "browser.repl.list" + ]) + #expect(methods == ["system.ping"]) + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift index 7647f3066d12..cb984b2e0740 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift @@ -70,7 +70,7 @@ struct ProUpgradeCard: View { } return String( localized: "settings.account.pro.subtitle", - defaultValue: "Up to 5 Cloud VMs, up to 4 vCPUs and 8 GB RAM per VM, and the iOS app. $50/month." + defaultValue: "Up to 5 Cloud VMs sharing 20 vCPUs and 40 GB RAM, plus the iOS app. $50/month." ) } diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Layout/CustomSidebarDataContextBuilder.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Layout/CustomSidebarDataContextBuilder.swift index 5a96bf905d68..6b9d30190ed1 100644 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Layout/CustomSidebarDataContextBuilder.swift +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Layout/CustomSidebarDataContextBuilder.swift @@ -90,6 +90,7 @@ public struct CustomSidebarDataContextBuilder { "ports": .array(workspace.listeningPorts.map { .int($0) }), "portCount": .int(workspace.listeningPorts.count), "unread": .int(workspace.unreadCount), + "status": .string(workspace.taskStatus), "tabs": .array(workspace.surfaces.map(surfaceValue(_:))), "tabCount": .int(workspace.surfaceCount), ] diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Layout/CustomSidebarWorkspaceSnapshot.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Layout/CustomSidebarWorkspaceSnapshot.swift index 4a9d57f4ab52..2776fc20f803 100644 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Layout/CustomSidebarWorkspaceSnapshot.swift +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Layout/CustomSidebarWorkspaceSnapshot.swift @@ -59,6 +59,12 @@ public struct CustomSidebarWorkspaceSnapshot: Sendable, Equatable { public let listeningPorts: [Int] /// The workspace's unread count (`workspaces[i].unread`). public let unreadCount: Int + /// The workspace's resolved task-status lane (`workspaces[i].status`): + /// the raw wire value of `WorkspaceTaskStatus` — `todo`, `working`, + /// `needs-attention`, `review` or `done`. Carried as a string so this + /// package does not depend on the app-side enum, the same way + /// `ControlWorkspaceTodoStatusSnapshot` crosses its seam. + public let taskStatus: String /// Surfaces in pane order (`workspaces[i].tabs`). public let surfaces: [CustomSidebarSurfaceSnapshot] /// Total surface count across panes (`workspaces[i].tabCount`). @@ -114,8 +120,10 @@ public struct CustomSidebarWorkspaceSnapshot: Sendable, Equatable { latestSubmittedAt: Date?, remote: Remote?, agents: [CustomSidebarAgentSnapshot] = [], - groupId: UUID? = nil + groupId: UUID? = nil, + taskStatus: String ) { + self.taskStatus = taskStatus self.groupId = groupId self.id = id self.title = title diff --git a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/CustomSidebarDataContextBuilderTests.swift b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/CustomSidebarDataContextBuilderTests.swift index 4571db54ef63..80a1d8aa8482 100644 --- a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/CustomSidebarDataContextBuilderTests.swift +++ b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/CustomSidebarDataContextBuilderTests.swift @@ -28,7 +28,8 @@ struct CustomSidebarDataContextBuilderTests { private func minimalWorkspace( id: UUID = UUID(), index: Int = 0, - surfaces: [CustomSidebarSurfaceSnapshot] = [] + surfaces: [CustomSidebarSurfaceSnapshot] = [], + taskStatus: String = "todo" ) -> CustomSidebarWorkspaceSnapshot { CustomSidebarWorkspaceSnapshot( id: id, @@ -50,7 +51,8 @@ struct CustomSidebarDataContextBuilderTests { latestConversationMessage: nil, latestSubmittedMessage: nil, latestSubmittedAt: nil, - remote: nil + remote: nil, + taskStatus: taskStatus ) } @@ -141,7 +143,8 @@ struct CustomSidebarDataContextBuilderTests { latestConversationMessage: nil, latestSubmittedMessage: nil, latestSubmittedAt: nil, - remote: nil + remote: nil, + taskStatus: "todo" ) let value = builder.workspaceValue(workspace) @@ -188,7 +191,8 @@ struct CustomSidebarDataContextBuilderTests { latestConversationMessage: "", latestSubmittedMessage: "", latestSubmittedAt: nil, - remote: nil + remote: nil, + taskStatus: "todo" ) let value = builder.workspaceValue(workspace) @@ -223,7 +227,8 @@ struct CustomSidebarDataContextBuilderTests { latestConversationMessage: "hi", latestSubmittedMessage: "do it", latestSubmittedAt: Date(timeIntervalSince1970: 100), - remote: .init(target: "host", stateRawValue: "connected", isConnected: true) + remote: .init(target: "host", stateRawValue: "connected", isConnected: true), + taskStatus: "todo" ) let value = builder.workspaceValue(workspace) @@ -268,7 +273,8 @@ struct CustomSidebarDataContextBuilderTests { latestConversationMessage: nil, latestSubmittedMessage: nil, latestSubmittedAt: nil, - remote: nil + remote: nil, + taskStatus: "todo" ) let progress = builder.workspaceValue(workspace).member("progress") @@ -449,7 +455,8 @@ struct CustomSidebarDataContextBuilderTests { latestSubmittedMessage: nil, latestSubmittedAt: nil, remote: nil, - agents: [minimal] + agents: [minimal], + taskStatus: "todo" ) let agents = builder.workspaceValue(workspace).member("agents") #expect(agents?.iterationValues?.count == 1) @@ -531,4 +538,26 @@ struct CustomSidebarDataContextBuilderTests { #expect(builder.surfaceValue(blank).member("latestPrompt") == nil) } + + @Test("Workspace status projects the resolved lane") + func workspaceStatusProjects() { + let builder = CustomSidebarDataContextBuilder() + + for lane in ["todo", "working", "needs-attention", "review", "done"] { + let workspace = minimalWorkspace(taskStatus: lane) + #expect(builder.workspaceValue(workspace).member("status") == .string(lane)) + } + } + + // `status` is an always-present key, not an optional one: a sidebar reads + // it without `if let`, so it must survive a workspace that carries nothing + // else worth showing. + @Test("Workspace status is present even on an otherwise empty workspace") + func workspaceStatusAlwaysPresent() { + let builder = CustomSidebarDataContextBuilder() + let value = builder.workspaceValue(minimalWorkspace()) + + #expect(value.member("status") == .string("todo")) + #expect(value.member("branch") == nil) + } } diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+DeviceVisibility.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+DeviceVisibility.swift new file mode 100644 index 000000000000..77920b4e2fee --- /dev/null +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot+DeviceVisibility.swift @@ -0,0 +1,38 @@ +import Foundation + +extension SurfaceCatalogSnapshot { + /// Hides sidebar rows without destroying providers or already-open remote panes. + /// + /// Filters a copy so per-machine state (display creation, staleness, display + /// memberships, pending intents) survives for the machines that stay visible. + /// Rebuilding the value from scratch dropped `displayCreationMachines`, which + /// made every desktop VM's New Display row report it was unavailable. + public func applyingDeviceVisibility( + includesCloud: Bool, + includesDevices: Bool, + hiddenMacIDs: Set + ) -> SurfaceCatalogSnapshot { + let visibleMachines = machines.filter { machine in + if let instance = machine.id.deviceInstance { + return includesDevices && !hiddenMacIDs.contains(instance.deviceID) + } + return includesCloud + } + let machineIDs = Set(visibleMachines.map(\.id)) + let visibleResources = resources.filter { machineIDs.contains($0.machine) } + let resourceIDs = Set(visibleResources.map(\.id)) + var result = self + result.machines = visibleMachines + result.resources = visibleResources + result.projections = projections.filter { resourceIDs.contains($0.resource) } + result.staleMachineIDs = staleMachineIDs.intersection(machineIDs) + let creation = displayCreationMachines?.intersection(machineIDs) + result.displayCreationMachines = creation?.isEmpty == false ? creation : nil + let pending = pendingDisplayCreations?.intersection(machineIDs) + result.pendingDisplayCreations = pending?.isEmpty == false ? pending : nil + result.cloudDisplayMemberships = cloudDisplayMemberships.filter { machineIDs.contains($0.machine) } + result.pendingWorkspaceCreations = pendingWorkspaceCreations?.filter { machineIDs.contains($0.key) } + result.pendingWorkspaceDeletions = pendingWorkspaceDeletions?.filter { machineIDs.contains($0.key) } + return result + } +} diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot.swift index 350082d79dad..1f0b3564952e 100644 --- a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot.swift +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogSnapshot.swift @@ -11,6 +11,8 @@ public struct SurfaceCatalogSnapshot: Hashable, Codable, Sendable { public var projections: [SurfaceProjection] public var staleMachineIDs: Set = [] public var displayCreationMachines: Set? = nil + /// Machines with a guest display creation in flight, shown optimistically. + public var pendingDisplayCreations: Set? = nil public var cloudDisplayMemberships: [CloudVMDisplayMembership] = [] public static let empty = SurfaceCatalogSnapshot(machines: [], resources: [], projections: []) @@ -36,6 +38,7 @@ public struct SurfaceCatalogSnapshot: Hashable, Codable, Sendable { projections: [SurfaceProjection], staleMachineIDs: Set = [], displayCreationMachines: Set? = nil, + pendingDisplayCreations: Set? = nil, cloudDisplayMemberships: [CloudVMDisplayMembership] = [] ) { self.pendingWorkspaceCreations = pendingWorkspaceCreations @@ -45,13 +48,14 @@ public struct SurfaceCatalogSnapshot: Hashable, Codable, Sendable { self.projections = projections self.staleMachineIDs = staleMachineIDs self.displayCreationMachines = displayCreationMachines + self.pendingDisplayCreations = pendingDisplayCreations self.cloudDisplayMemberships = cloudDisplayMemberships } } extension SurfaceCatalogSnapshot { private enum CodingKeys: String, CodingKey { - case pendingWorkspaceCreations, pendingWorkspaceDeletions, machines, resources, projections, staleMachineIDs, displayCreationMachines, cloudDisplayMemberships + case pendingWorkspaceCreations, pendingWorkspaceDeletions, machines, resources, projections, staleMachineIDs, displayCreationMachines, pendingDisplayCreations, cloudDisplayMemberships } public init(from decoder: Decoder) throws { @@ -63,6 +67,7 @@ extension SurfaceCatalogSnapshot { projections = try values.decode([SurfaceProjection].self, forKey: .projections) staleMachineIDs = try values.decodeIfPresent(Set.self, forKey: .staleMachineIDs) ?? [] displayCreationMachines = try values.decodeIfPresent(Set.self, forKey: .displayCreationMachines) + pendingDisplayCreations = try values.decodeIfPresent(Set.self, forKey: .pendingDisplayCreations) cloudDisplayMemberships = try values.decodeIfPresent([CloudVMDisplayMembership].self, forKey: .cloudDisplayMemberships) ?? [] } } diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Tests/CmuxSurfaceCatalogModelTests/SurfaceCatalogDeviceVisibilityTests.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Tests/CmuxSurfaceCatalogModelTests/SurfaceCatalogDeviceVisibilityTests.swift new file mode 100644 index 000000000000..8e91fc014995 --- /dev/null +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Tests/CmuxSurfaceCatalogModelTests/SurfaceCatalogDeviceVisibilityTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing +@testable import CmuxSurfaceCatalogModel + +@Suite("Surface catalog device visibility") +struct SurfaceCatalogDeviceVisibilityTests { + private let vm = SurfaceMachineID.cloud("vm-desktop") + + @Test("the sidebar filter keeps per-machine catalog state for visible machines") + func visibilityKeepsMachineState() { + let membership = CloudVMDisplayMembership( + machine: vm, + workspaceID: "ws-1", + displayID: "display:1", + clientID: "client", + viewID: "view" + ) + let snapshot = SurfaceCatalogSnapshot( + machines: [SurfaceMachineInfo(id: vm, name: "Desktop", status: "running", hasDesktop: true, linkState: .connected)], + resources: [], + projections: [], + staleMachineIDs: [vm], + displayCreationMachines: [vm], + pendingDisplayCreations: [vm], + cloudDisplayMemberships: [membership] + ) + + let visible = snapshot.applyingDeviceVisibility(includesCloud: true, includesDevices: true, hiddenMacIDs: []) + + // The sidebar's New Display row reads this set. Dropping it made every + // desktop VM report "Additional displays are unavailable". + #expect(visible.displayCreationMachines == [vm]) + // The optimistic "Starting display…" row reads this set. + #expect(visible.pendingDisplayCreations == [vm]) + #expect(visible.staleMachineIDs == [vm]) + #expect(visible.cloudDisplayMemberships == [membership]) + } + + @Test("hidden machines do not leak display creation state") + func hiddenMachinesAreFiltered() { + let snapshot = SurfaceCatalogSnapshot( + machines: [SurfaceMachineInfo(id: vm, name: "Desktop", status: "running", hasDesktop: true, linkState: .connected)], + resources: [], + projections: [], + displayCreationMachines: [vm] + ) + + let visible = snapshot.applyingDeviceVisibility(includesCloud: false, includesDevices: true, hiddenMacIDs: []) + + #expect(visible.machines.isEmpty) + #expect(visible.displayCreationMachines == nil) + } +} diff --git a/Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator+UserFunctions.swift b/Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator+UserFunctions.swift index d4c2d5e02169..10840381747f 100644 --- a/Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator+UserFunctions.swift +++ b/Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator+UserFunctions.swift @@ -99,7 +99,7 @@ extension ExpressionEvaluator { } else { resolved = nil } - guard let value = resolved else { return false } + guard let value = resolved, value != .null else { return false } if let name { scope.define(name, value) } } else if let expr = element.condition.as(ExprSyntax.self) { if !(eval(expr, scope)?.isTruthy ?? false) { return false } diff --git a/Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator.swift b/Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator.swift index e3bb1000ab06..897235fe2bae 100644 --- a/Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator.swift +++ b/Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator.swift @@ -23,6 +23,9 @@ public struct ExpressionEvaluator: Sendable { if let literal = expr.as(BooleanLiteralExprSyntax.self) { return .bool(literal.literal.text == "true") } + if expr.is(NilLiteralExprSyntax.self) { + return .null + } if let literal = expr.as(StringLiteralExprSyntax.self) { return .string(evalString(literal, env)) } @@ -162,6 +165,18 @@ public struct ExpressionEvaluator: Sendable { private func evalInfix(_ node: InfixOperatorExprSyntax, _ env: EvalEnvironment) -> SwiftValue? { guard let op = node.operator.as(BinaryOperatorExprSyntax.self)?.operator.text else { return nil } + + // Equality must tolerate an absent optional object field, but not every + // evaluation failure. Preserve failed conversions, invalid subscripts, + // unsupported syntax, and budget exhaustion as `nil` so callers skip. + if op == "==" || op == "!=" { + guard let lhs = evalEqualityOperand(node.leftOperand, env).nilComparableValue, + let rhs = evalEqualityOperand(node.rightOperand, env).nilComparableValue else { + return nil + } + return .bool(op == "==" ? lhs == rhs : lhs != rhs) + } + guard let lhs = eval(node.leftOperand, env) else { return nil } // Short-circuit logical operators on the left operand before forcing the @@ -184,8 +199,6 @@ public struct ExpressionEvaluator: Sendable { case "..<", "...": guard case let .int(l) = lhs, case let .int(r) = rhs else { return nil } return .range(lower: l, upper: r, inclusive: op == "...") - case "==": return .bool(lhs == rhs) - case "!=": return .bool(lhs != rhs) default: break } @@ -221,6 +234,62 @@ public struct ExpressionEvaluator: Sendable { } } + private enum EqualityOperand { + case value(SwiftValue) + case missingOptional + case failed + + var nilComparableValue: SwiftValue? { + switch self { + case let .value(value): return value + case .missingOptional: return .null + case .failed: return nil + } + } + } + + private func evalEqualityOperand(_ expr: ExprSyntax, _ env: EvalEnvironment) -> EqualityOperand { + env.budget.enter() + defer { env.budget.leave() } + guard !env.budget.exceeded else { return .failed } + + if expr.is(NilLiteralExprSyntax.self) { + return .value(.null) + } + + if let tuple = expr.as(TupleExprSyntax.self), tuple.elements.count == 1, + let inner = tuple.elements.first?.expression { + return evalEqualityOperand(inner, env) + } + + if let member = expr.as(MemberAccessExprSyntax.self), let baseExpr = member.base { + let baseOperand = evalEqualityOperand(baseExpr, env) + guard case let .value(base) = baseOperand else { return baseOperand } + let name = member.declName.baseName.text + if case let .object(fields) = base { + return fields[name].map(EqualityOperand.value) ?? .missingOptional + } + return base.member(name).map(EqualityOperand.value) ?? .failed + } + + if let subscriptCall = expr.as(SubscriptCallExprSyntax.self), + let indexExpr = subscriptCall.arguments.first?.expression { + let baseOperand = evalEqualityOperand(subscriptCall.calledExpression, env) + guard case let .value(base) = baseOperand else { return baseOperand } + guard let index = eval(indexExpr, env) else { return .failed } + switch (base, index) { + case let (.array(values), .int(i)): + return (i >= 0 && i < values.count) ? .value(values[i]) : .failed + case let (.object(fields), .string(key)): + return fields[key].map(EqualityOperand.value) ?? .missingOptional + default: + return .failed + } + } + + return eval(expr, env).map(EqualityOperand.value) ?? .failed + } + private func numericPair(_ lhs: SwiftValue, _ rhs: SwiftValue) -> (Double?, Double?, Bool) { func num(_ v: SwiftValue) -> (Double?, Bool) { switch v { diff --git a/Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/SwiftValue.swift b/Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/SwiftValue.swift index e5945b9dfb38..d3eebe4cf730 100644 --- a/Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/SwiftValue.swift +++ b/Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/SwiftValue.swift @@ -11,6 +11,10 @@ public enum SwiftValue: Codable, Sendable, Equatable { case double(Double) case string(String) case bool(Bool) + /// The result of the `nil` literal and of comparing an absent optional + /// field against `nil`. Distinct from a host `SwiftValue?` of `nil`, which + /// means "expression could not be evaluated / no value". + case null case range(lower: Int, upper: Int, inclusive: Bool) indirect case array([SwiftValue]) indirect case object([String: SwiftValue]) @@ -22,6 +26,7 @@ public enum SwiftValue: Codable, Sendable, Equatable { case let .double(value): return String(value) case let .string(value): return value case let .bool(value): return String(value) + case .null: return "nil" case let .range(lower, upper, inclusive): return "\(lower)\(inclusive ? "..." : "..<")\(upper)" case let .array(values): return "[" + values.map(\.displayString).joined(separator: ", ") + "]" case let .object(fields): return "{" + fields.keys.sorted().map { "\($0): \(fields[$0]!.displayString)" }.joined(separator: ", ") + "}" diff --git a/Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/SwiftViewInterpreter.swift b/Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/SwiftViewInterpreter.swift index e48f6b3d9b3f..8a489dd7dd75 100644 --- a/Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/SwiftViewInterpreter.swift +++ b/Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/SwiftViewInterpreter.swift @@ -680,7 +680,7 @@ public struct SwiftViewInterpreter: Sendable { } else { resolved = nil } - guard let value = resolved else { return false } + guard let value = resolved, value != .null else { return false } if let name { scope.define(name, value) } } else if let expr = element.condition.as(ExprSyntax.self) { if !(expressions.eval(expr, scope)?.isTruthy ?? false) { return false } diff --git a/Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/SwiftViewInterpreterTests.swift b/Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/SwiftViewInterpreterTests.swift index ed1bccd534c4..29ba59dbc89d 100644 --- a/Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/SwiftViewInterpreterTests.swift +++ b/Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/SwiftViewInterpreterTests.swift @@ -221,6 +221,75 @@ import Testing #expect(node?.children.map(\.text) == ["alpha", "▸ beta"]) } + // Regression for #7943: comparing an optional field against `nil` must + // evaluate to a Bool — `true` when the field is present, `false` when it + // is absent — across interpolation, ternaries, and `if` guards. Before the + // fix it evaluated to "nothing", so interpolation rendered empty, ternaries + // always took the else branch, and `if x != nil` guards were never taken. + @Test func nilComparisonEvaluatesToBoolForPresentAndAbsentFields() { + let workspaces = SwiftValue.array([ + .object(["title": .string("has"), "note": .string("hello")]), + .object(["title": .string("missing")]), // no `note` field + ]) + let node = interp.evaluate(""" + VStack { + ForEach(workspaces) { w in + Text("present: \\(w.note != nil)") + Text(w.note != nil ? "ternary: THEN" : "ternary: ELSE") + if w.note != nil { Text("guard: TAKEN") } else { Text("guard: NOT TAKEN") } + Text("absent: \\(w.note == nil)") + } + } + """, state: ["workspaces": workspaces]) + #expect(node?.children.map(\.text) == [ + "present: true", "ternary: THEN", "guard: TAKEN", "absent: false", + "present: false", "ternary: ELSE", "guard: NOT TAKEN", "absent: true", + ]) + } + + @Test func nilComparisonDoesNotMaskEvaluationFailures() { + let workspaces = SwiftValue.array([ + .object(["title": .string("alpha")]), + .object(["title": .string("beta"), "meta": .object(["note": .string("hello")])]), + ]) + let node = interp.evaluate(""" + VStack { + Text("missing field: \\(workspaces[0].note == nil)") + Text("nested missing: \\(workspaces[0].meta.note == nil)") + Text("nested present: \\(workspaces[1].meta.note != nil)") + Text("paren missing: \\((workspaces[0].note) == nil)") + Text("paren present: \\((workspaces[1].meta.note) != nil)") + Text("bad subscript: \\(workspaces[3] == nil)") + Text("paren bad subscript: \\((workspaces[3]) == nil)") + Text("bad conversion: \\(Int("nope") == nil)") + Text("unsupported: \\(unknownThing() == nil)") + } + """, state: ["workspaces": workspaces]) + #expect(node?.children.map(\.text) == [ + "missing field: true", + "nested missing: true", + "nested present: true", + "paren missing: true", + "paren present: true", + "bad subscript: ", + "paren bad subscript: ", + "bad conversion: ", + "unsupported: ", + ]) + } + + @Test func deeplyNestedNilComparisonRespectsRecursionBudget() { + let nested = String(repeating: "(", count: 450) + + "workspaces[0].note" + + String(repeating: ")", count: 450) + let node = interp.evaluate(""" + VStack { + Text("budget: \\(\(nested) == nil)") + } + """, state: ["workspaces": .array([.object(["title": .string("alpha")])])]) + #expect(node?.children.first?.text == "budget: ") + } + @Test func labelFormButtonCapturesActionAndLabel() { let node = interp.evaluate(""" VStack { @@ -660,12 +729,14 @@ import Testing Text(tint(a)).foregroundColor(tint(a)) Text(name(a)) Text(name(b)) + Text(name(c)) } """, state: [ "a": .object(["state": .string("queued"), "title": .string("Alpha")]), "b": .object(["state": .string("running")]), + "c": .object(["state": .string("running"), "title": .null]), ]) - #expect(node?.children.map(\.text) == ["#7AA2F7", "Alpha", "untitled"]) + #expect(node?.children.map(\.text) == ["#7AA2F7", "Alpha", "untitled", "untitled"]) } @Test func ifLetOptionalBindingRendersWhenPresent() { @@ -684,6 +755,14 @@ import Testing } """, state: ["ws": absent]) #expect(node2?.children.first?.text == "no branch") + + let explicitNil = SwiftValue.object(["branch": .null]) + let node3 = interp.evaluate(""" + VStack { + if let b = ws.branch { Text("on \\(b)") } else { Text("no branch") } + } + """, state: ["ws": explicitNil]) + #expect(node3?.children.first?.text == "no branch") } @Test func switchSelectsMatchingCase() { diff --git a/Packages/macOS/CmuxSwiftRenderUI/Sources/CmuxSwiftRenderUI/Runtime/SidebarJSRuntime.swift b/Packages/macOS/CmuxSwiftRenderUI/Sources/CmuxSwiftRenderUI/Runtime/SidebarJSRuntime.swift index 72ea93efd0d3..dead1b7c8734 100644 --- a/Packages/macOS/CmuxSwiftRenderUI/Sources/CmuxSwiftRenderUI/Runtime/SidebarJSRuntime.swift +++ b/Packages/macOS/CmuxSwiftRenderUI/Sources/CmuxSwiftRenderUI/Runtime/SidebarJSRuntime.swift @@ -218,6 +218,7 @@ public final class SidebarJSRuntime { return ["lower": lower, "upper": upper, "inclusive": inclusive] case let .array(values): return values.map { jsonObject($0) } case let .object(fields): return fields.mapValues { jsonObject($0) } + case .null: return NSNull() } } } diff --git a/Packages/macOS/CmuxSwiftRenderUI/Sources/CmuxSwiftRenderUI/Sidebar/CustomSidebarValidator.swift b/Packages/macOS/CmuxSwiftRenderUI/Sources/CmuxSwiftRenderUI/Sidebar/CustomSidebarValidator.swift index 1d3b3f5d394e..049c5838ef2f 100644 --- a/Packages/macOS/CmuxSwiftRenderUI/Sources/CmuxSwiftRenderUI/Sidebar/CustomSidebarValidator.swift +++ b/Packages/macOS/CmuxSwiftRenderUI/Sources/CmuxSwiftRenderUI/Sidebar/CustomSidebarValidator.swift @@ -174,6 +174,7 @@ public struct CustomSidebarValidator { "ports": .array([.int(3000)]), "portCount": .int(1), "unread": .int(0), + "status": .string("working"), "tabs": .array([]), "tabCount": .int(0), "description": .string(""), diff --git a/Packages/macOS/CmuxSwiftRenderUI/Tests/CmuxSwiftRenderUITests/CustomSidebarValidationTests.swift b/Packages/macOS/CmuxSwiftRenderUI/Tests/CmuxSwiftRenderUITests/CustomSidebarValidationTests.swift index 91def132d550..3ccbaf12ce1e 100644 --- a/Packages/macOS/CmuxSwiftRenderUI/Tests/CmuxSwiftRenderUITests/CustomSidebarValidationTests.swift +++ b/Packages/macOS/CmuxSwiftRenderUI/Tests/CmuxSwiftRenderUITests/CustomSidebarValidationTests.swift @@ -185,6 +185,7 @@ struct CustomSidebarValidationTests { "ports": .array([.int(3801), .int(5173)]), "portCount": .int(2), "unread": .int(2), + "status": .string("needs-attention"), "tabCount": .int(2), "description": .string("Crash fix"), "color": .string("#0A84FF"), @@ -292,6 +293,7 @@ struct CustomSidebarValidationTests { "ports": .array([]), "portCount": .int(0), "unread": .int(2), + "status": .string("review"), "tabCount": .int(1), "description": .string("Review branch"), "branch": .string("main"), @@ -333,6 +335,7 @@ struct CustomSidebarValidationTests { "ports": .array([]), "portCount": .int(0), "unread": .int(0), + "status": .string("todo"), "tabCount": .int(0), "description": .string(""), "branch": .string("main"), diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 61b1e7b52134..caa2d7a1ac1a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -235781,381 +235781,6 @@ } } }, - "machines.new.plan.loading": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "جارٍ تحميل خطة أجهزة Cloud…" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Dein Cloud-Maschinenplan wird geladen…" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Cargando tu plan de máquinas Cloud…" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Chargement de votre forfait de machines Cloud…" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Cloud マシンプランを読み込んでいます…" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "Cloud 머신 요금제를 불러오는 중…" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "正在加载您的 Cloud 机器套餐…" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "正在載入您的 Cloud 機器方案…" - } - } - } - }, - "machines.new.plan.retry": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "إعادة المحاولة" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Erneut versuchen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Reintentar" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Réessayer" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "再試行" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "다시 시도" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重试" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "重試" - } - } - } - }, - "machines.new.plan.error": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "لا تزال تفاصيل خطة Cloud قيد التحميل. أعد المحاولة بعد لحظة." - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Die Cloud-Planangaben werden noch geladen. Versuche es gleich erneut." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Los detalles del plan de Cloud todavía se están cargando. Vuelve a intentarlo en un momento." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Les détails du forfait Cloud se chargent encore. Réessayez dans un instant." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Cloud プランの詳細を読み込んでいます。しばらくしてから再試行してください。" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "Cloud 요금제 세부 정보를 아직 불러오는 중입니다. 잠시 후 다시 시도하세요." - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "Cloud 套餐详情仍在加载。请稍后重试。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "Cloud 方案詳細資料仍在載入中。請稍後重試。" - } - } - } - }, "machines.new": { "extractionState": "manual", "localizations": { @@ -271855,58 +271480,124 @@ "pricing.native.compare.concurrent.team": { "extractionState": "manual", "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "5 po plaćenom mjestu" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "5 pr. betalt plads" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "5 per postazione a pagamento" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "5 ក្នុងមួយកៅអីបង់ប្រាក់" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "5 per betalt plass" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "5 na płatne miejsce" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "5 por assento pago" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "5 на оплаченное место" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "5 ต่อที่นั่งแบบชำระเงิน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ücretli koltuk başına 5" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "5 на оплачене місце" + } + }, "en": { "stringUnit": { "state": "translated", - "value": "5 per user" + "value": "5 per paid seat" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ユーザーごとに 5" + "value": "有料シートごとに 5" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "每用户 5 个" + "value": "每个付费席位 5 个" } }, "de": { "stringUnit": { "state": "translated", - "value": "5 pro Benutzer" + "value": "5 pro bezahltem Platz" } }, "fr": { "stringUnit": { "state": "translated", - "value": "5 par utilisateur" + "value": "5 par siège payant" } }, "ar": { "stringUnit": { "state": "translated", - "value": "5 لكل مستخدم" + "value": "5 لكل مقعد مدفوع" } }, "es": { "stringUnit": { "state": "translated", - "value": "5 por usuario" + "value": "5 por puesto de pago" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "每位使用者 5 個" + "value": "每個付費席位 5 個" } }, "ko": { "stringUnit": { "state": "translated", - "value": "사용자당 5개" + "value": "유료 시트당 5개" } } } @@ -274325,58 +274016,124 @@ "pricing.native.pro.feature.hours": { "extractionState": "manual", "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "Do 5 Cloud VM-ova koji dijele 20 vCPU-a i 40 GB RAM-a" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Op til 5 Cloud VMs, der deler 20 vCPU'er og 40 GB RAM" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Fino a 5 Cloud VM che condividono 20 vCPU e 40 GB di RAM" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "Cloud VMs រហូតដល់ 5 ដែលចែករំលែក vCPU 20 និង RAM 40 GB" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Opptil 5 Cloud VMs som deler 20 vCPU-er og 40 GB RAM" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Do 5 Cloud VMs, które współdzielą 20 vCPU i 40 GB RAM" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Até 5 Cloud VMs que compartilham 20 vCPUs e 40 GB de RAM" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "До 5 Cloud VMs с общими 20 vCPU и 40 ГБ RAM" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Cloud VMs สูงสุด 5 เครื่อง ใช้ 20 vCPU และ RAM 40 GB ร่วมกัน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU ve 40 GB RAM'i paylaşan en fazla 5 Cloud VM" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "До 5 Cloud VMs зі спільними 20 vCPU і 40 ГБ RAM" + } + }, "en": { "stringUnit": { "state": "translated", - "value": "Up to 5 Cloud VMs, up to 4 vCPUs and 8 GB RAM per VM" + "value": "Up to 5 Cloud VMs sharing 20 vCPUs and 40 GB RAM" } }, "ja": { "stringUnit": { "state": "translated", - "value": "最大 5 台の Cloud VM、VM あたり最大 4 vCPU と 8 GB RAM" + "value": "最大 5 台の Cloud VM で 20 vCPU と 40 GB RAM を共有" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "最多 5 台云端虚拟机,每台最多 4 个虚拟 CPU 和 8 GB 内存" + "value": "最多 5 台云端虚拟机,共享 20 个虚拟 CPU 和 40 GB 内存" } }, "de": { "stringUnit": { "state": "translated", - "value": "Bis zu 5 Cloud-VMs mit bis zu 4 vCPUs und 8 GB RAM pro VM" + "value": "Bis zu 5 Cloud-VMs, die sich 20 vCPUs und 40 GB RAM teilen" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Jusqu’à 5 VM cloud, jusqu’à 4 vCPU et 8 Go de RAM par VM" + "value": "Jusqu’à 5 VM cloud partageant 20 vCPU et 40 Go de RAM" } }, "ar": { "stringUnit": { "state": "translated", - "value": "ما يصل إلى 5 أجهزة افتراضية سحابية، بحد أقصى 4 وحدات معالجة مركزية افتراضية و8 جيجابايت من الذاكرة لكل جهاز" + "value": "ما يصل إلى 5 أجهزة افتراضية سحابية تتشارك 20 وحدة معالجة مركزية افتراضية و40 جيجابايت من الذاكرة" } }, "es": { "stringUnit": { "state": "translated", - "value": "Hasta 5 VM en la nube, con hasta 4 vCPU y 8 GB de RAM por VM" + "value": "Hasta 5 VM en la nube que comparten 20 vCPU y 40 GB de RAM" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "最多 5 台雲端虛擬機,每台最多 4 個虛擬 CPU 與 8 GB 記憶體" + "value": "最多 5 台雲端虛擬機,共用 20 個虛擬 CPU 與 40 GB 記憶體" } }, "ko": { "stringUnit": { "state": "translated", - "value": "최대 5개의 클라우드 VM, VM당 최대 vCPU 4개와 RAM 8 GB" + "value": "최대 5개의 클라우드 VM이 vCPU 20개와 RAM 40 GB를 공유" } } } @@ -274933,121 +274690,121 @@ "en": { "stringUnit": { "state": "translated", - "value": "Pro includes up to 5 Cloud VMs, with up to 4 vCPUs and 8 GB RAM per VM. Team includes the same limits per user. There is no metering or overage billing." + "value": "Pro includes up to 5 Cloud VMs sharing 20 vCPUs and 40 GB RAM. Team adds the same pool for each paid seat, shared across the team. Your VMs draw from one pool. Run one large VM or five small ones. Paused VMs do not use the pool. There is no metering or overage billing." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Pro には最大 5 台の Cloud VM が含まれ、VM あたり最大 4 vCPU と 8 GB RAM を利用できます。Team ではユーザーごとに同じ上限が適用されます。従量課金や超過料金はありません。" + "value": "Pro には最大 5 台の Cloud VM が含まれ、20 vCPU と 40 GB RAM を共有します。Team では有料シートごとに同じプールが加わり、チーム全体で共有します。VM はすべて 1 つのプールからリソースを使います。大きな VM を 1 台動かすことも、小さな VM を 5 台動かすこともできます。一時停止中の VM はプールを使いません。従量課金や超過料金はありません。" } }, "ar": { "stringUnit": { "state": "translated", - "value": "تتضمن باقة Pro ما يصل إلى 5 أجهزة افتراضية سحابية، بحد أقصى 4 وحدات معالجة مركزية افتراضية و8 جيجابايت من الذاكرة لكل جهاز. وتمنح باقة Team الحدود نفسها لكل مستخدم. لا توجد فوترة حسب الاستهلاك أو رسوم إضافية لتجاوز الحدود." + "value": "تتضمن باقة Pro ما يصل إلى 5 أجهزة افتراضية سحابية تتشارك 20 وحدة معالجة مركزية افتراضية و40 جيجابايت من الذاكرة. وتضيف باقة Team المجموعة نفسها لكل مقعد مدفوع، ويتشاركها الفريق بأكمله. تستمد أجهزتك الافتراضية مواردها من مجموعة واحدة، فيمكنك تشغيل جهاز كبير واحد أو خمسة أجهزة صغيرة. ولا تستهلك الأجهزة المتوقفة مؤقتًا شيئًا من المجموعة. لا توجد فوترة حسب الاستهلاك أو رسوم إضافية لتجاوز الحدود." } }, "bs": { "stringUnit": { "state": "translated", - "value": "Pro uključuje do 5 Cloud VM-ova, s najviše 4 vCPU-a i 8 GB RAM-a po VM-u. Team uključuje ista ograničenja po korisniku. Nema naplate prema potrošnji niti naplate prekoračenja." + "value": "Pro uključuje do 5 Cloud VM-ova koji dijele 20 vCPU-a i 40 GB RAM-a. Team dodaje isti skup resursa za svako plaćeno mjesto, zajednički za cijeli tim. Vaši VM-ovi koriste jedan zajednički skup. Pokrenite jedan veliki VM ili pet malih. Pauzirani VM-ovi ne koriste skup. Nema naplate prema potrošnji niti naplate prekoračenja." } }, "da": { "stringUnit": { "state": "translated", - "value": "Pro inkluderer op til 5 Cloud VMs med op til 4 vCPU'er og 8 GB RAM pr. VM. Team inkluderer de samme grænser pr. bruger. Der er ingen forbrugsafregning eller betaling for overforbrug." + "value": "Pro inkluderer op til 5 Cloud VMs, der deler 20 vCPU'er og 40 GB RAM. Team tilføjer den samme pulje for hver betalt plads, delt på tværs af teamet. Dine VMs trækker på én fælles pulje. Kør én stor VM eller fem små. VMs på pause bruger ikke af puljen. Der er ingen forbrugsafregning eller betaling for overforbrug." } }, "de": { "stringUnit": { "state": "translated", - "value": "Pro beinhaltet bis zu 5 Cloud-VMs mit bis zu 4 vCPUs und 8 GB RAM pro VM. Team beinhaltet dieselben Limits pro Benutzer. Es gibt keine verbrauchsabhängige Abrechnung oder Nachberechnung." + "value": "Pro beinhaltet bis zu 5 Cloud-VMs, die sich 20 vCPUs und 40 GB RAM teilen. Team fügt denselben Pool für jeden bezahlten Platz hinzu, gemeinsam für das ganze Team. Deine VMs nutzen einen gemeinsamen Pool. Betreibe eine große VM oder fünf kleine. Pausierte VMs belegen nichts vom Pool. Es gibt keine verbrauchsabhängige Abrechnung oder Nachberechnung." } }, "es": { "stringUnit": { "state": "translated", - "value": "Pro incluye hasta 5 VM en la nube, con hasta 4 vCPU y 8 GB de RAM por VM. Team incluye los mismos límites por usuario. No hay facturación por consumo ni por exceso." + "value": "Pro incluye hasta 5 VM en la nube que comparten 20 vCPU y 40 GB de RAM. Team añade el mismo grupo de recursos por cada puesto de pago, compartido por todo el equipo. Tus VM usan un único grupo de recursos. Ejecuta una VM grande o cinco pequeñas. Las VM en pausa no usan el grupo. No hay facturación por consumo ni por exceso." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Pro inclut jusqu'à 5 VM Cloud, avec jusqu'à 4 vCPU et 8 Go de RAM par VM. Team inclut les mêmes limites par utilisateur. Il n'y a pas de facturation à l'utilisation ou de dépassement." + "value": "Pro inclut jusqu'à 5 VM Cloud qui partagent 20 vCPU et 40 Go de RAM. Team ajoute la même réserve pour chaque siège payant, partagée par toute l'équipe. Vos VM puisent dans une réserve commune. Lancez une grande VM ou cinq petites. Les VM en pause n'utilisent pas la réserve. Il n'y a pas de facturation à l'utilisation ou de dépassement." } }, "it": { "stringUnit": { "state": "translated", - "value": "Pro include fino a 5 Cloud VM, con fino a 4 vCPU e 8 GB di RAM per VM. Team include gli stessi limiti per utente. Non è prevista fatturazione a consumo né per eccedenze." + "value": "Pro include fino a 5 Cloud VM che condividono 20 vCPU e 40 GB di RAM. Team aggiunge lo stesso pool per ogni postazione a pagamento, condiviso da tutto il team. Le tue VM attingono a un unico pool. Esegui una VM grande o cinque piccole. Le VM in pausa non usano il pool. Non è prevista fatturazione a consumo né per eccedenze." } }, "km": { "stringUnit": { "state": "translated", - "value": "Pro រួមបញ្ចូល Cloud VMs រហូតដល់ 5 ដោយមាន vCPU រហូតដល់ 4 និង RAM 8 GB ក្នុងមួយ VM។ Team រួមបញ្ចូលដែនកំណត់ដូចគ្នាសម្រាប់អ្នកប្រើម្នាក់ៗ។ មិនមានការគិតថ្លៃតាមការប្រើប្រាស់ ឬថ្លៃលើសកំណត់ទេ។" + "value": "Pro រួមបញ្ចូល Cloud VMs រហូតដល់ 5 ដែលចែករំលែក vCPU 20 និង RAM 40 GB។ Team បន្ថែមបណ្តុំធនធានដូចគ្នាសម្រាប់កៅអីបង់ប្រាក់នីមួយៗ ដែលក្រុមទាំងមូលប្រើរួមគ្នា។ VMs របស់អ្នកប្រើធនធានពីបណ្តុំតែមួយ។ ដំណើរការ VM ធំមួយ ឬ VM តូចប្រាំ។ VMs ដែលបានផ្អាកមិនប្រើបណ្តុំទេ។ មិនមានការគិតថ្លៃតាមការប្រើប្រាស់ ឬថ្លៃលើសកំណត់ទេ។" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Pro는 최대 5개의 클라우드 VM을 포함하며, VM당 최대 vCPU 4개와 RAM 8 GB를 사용할 수 있습니다. Team은 사용자마다 동일한 한도가 적용됩니다. 사용량 기반 과금이나 초과 요금은 없습니다." + "value": "Pro는 최대 5개의 클라우드 VM을 포함하며, VM들이 vCPU 20개와 RAM 40 GB를 공유합니다. Team은 유료 시트마다 동일한 풀이 추가되며 팀 전체가 공유합니다. VM은 하나의 풀에서 리소스를 사용합니다. 큰 VM 1개를 실행하거나 작은 VM 5개를 실행할 수 있습니다. 일시 중지된 VM은 풀을 사용하지 않습니다. 사용량 기반 과금이나 초과 요금은 없습니다." } }, "nb": { "stringUnit": { "state": "translated", - "value": "Pro inkluderer opptil 5 Cloud VMs med opptil 4 vCPU-er og 8 GB RAM per VM. Team inkluderer de samme grensene per bruker. Det er ingen forbruksbasert fakturering eller betaling for overforbruk." + "value": "Pro inkluderer opptil 5 Cloud VMs som deler 20 vCPU-er og 40 GB RAM. Team legger til den samme ressurspoolen for hver betalt plass, delt på tvers av teamet. VM-ene dine bruker én felles ressurspool. Kjør én stor VM eller fem små. VM-er på pause bruker ikke av ressurspoolen. Det er ingen forbruksbasert fakturering eller betaling for overforbruk." } }, "pl": { "stringUnit": { "state": "translated", - "value": "Pro obejmuje do 5 Cloud VMs, z maksymalnie 4 vCPU i 8 GB RAM na VM. Team obejmuje te same limity na użytkownika. Nie ma rozliczeń za zużycie ani opłat za przekroczenie limitów." + "value": "Pro obejmuje do 5 Cloud VMs, które współdzielą 20 vCPU i 40 GB RAM. Team dodaje tę samą pulę za każde płatne miejsce, wspólną dla całego zespołu. Twoje VM korzystają z jednej wspólnej puli. Uruchom jedną dużą VM albo pięć małych. Wstrzymane VM nie zużywają puli. Nie ma rozliczeń za zużycie ani opłat za przekroczenie limitów." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "O Pro inclui até 5 Cloud VMs, com até 4 vCPUs e 8 GB de RAM por VM. O Team inclui os mesmos limites por usuário. Não há cobrança por uso nem por excedente." + "value": "O Pro inclui até 5 Cloud VMs que compartilham 20 vCPUs e 40 GB de RAM. O Team adiciona o mesmo pool para cada assento pago, compartilhado por toda a equipe. Suas VMs usam um único pool de recursos. Execute uma VM grande ou cinco pequenas. VMs pausadas não usam o pool. Não há cobrança por uso nem por excedente." } }, "ru": { "stringUnit": { "state": "translated", - "value": "Pro включает до 5 Cloud VMs, до 4 vCPU и 8 ГБ RAM на каждую VM. Team включает те же лимиты на пользователя. Нет оплаты по факту использования или за превышение." + "value": "Pro включает до 5 Cloud VMs с общим пулом из 20 vCPU и 40 ГБ RAM. Team добавляет такой же пул за каждое оплаченное место, общий для всей команды. Все ваши VM берут ресурсы из одного пула. Запустите одну большую VM или пять маленьких. Приостановленные VM не расходуют пул. Нет оплаты по факту использования или за превышение." } }, "th": { "stringUnit": { "state": "translated", - "value": "Pro มี Cloud VMs ได้สูงสุด 5 เครื่อง แต่ละ VM มีได้สูงสุด 4 vCPU และ RAM 8 GB Team มีขีดจำกัดเดียวกันต่อผู้ใช้ ไม่มีการคิดค่าบริการตามการใช้งานหรือค่าใช้งานส่วนเกิน" + "value": "Pro มี Cloud VMs ได้สูงสุด 5 เครื่อง ซึ่งใช้ 20 vCPU และ RAM 40 GB ร่วมกัน Team เพิ่มพูลเดียวกันนี้ต่อที่นั่งแบบชำระเงินแต่ละที่นั่ง โดยใช้ร่วมกันทั้งทีม VM ทั้งหมดของคุณใช้ทรัพยากรจากพูลเดียว จะรัน VM ใหญ่เครื่องเดียวหรือ VM เล็กห้าเครื่องก็ได้ VM ที่หยุดชั่วคราวไม่ใช้พูล ไม่มีการคิดค่าบริการตามการใช้งานหรือค่าใช้งานส่วนเกิน" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Pro, VM başına en fazla 4 vCPU ve 8 GB RAM ile en fazla 5 Cloud VM içerir. Team, kullanıcı başına aynı sınırları içerir. Kullanıma göre ücretlendirme veya aşım ücreti yoktur." + "value": "Pro, 20 vCPU ve 40 GB RAM'i paylaşan en fazla 5 Cloud VM içerir. Team, her ücretli koltuk için aynı havuzu ekler ve bu havuz tüm ekip tarafından paylaşılır. VM'leriniz tek bir havuzu kullanır. Tek bir büyük VM ya da beş küçük VM çalıştırın. Duraklatılmış VM'ler havuzu kullanmaz. Kullanıma göre ücretlendirme veya aşım ücreti yoktur." } }, "uk": { "stringUnit": { "state": "translated", - "value": "Pro включає до 5 Cloud VMs, до 4 vCPU і 8 ГБ RAM на кожну VM. Team включає ті самі ліміти на користувача. Немає оплати за фактичне використання чи за перевищення." + "value": "Pro включає до 5 Cloud VMs зі спільним пулом із 20 vCPU і 40 ГБ RAM. Team додає такий самий пул за кожне оплачене місце, спільний для всієї команди. Усі ваші VM беруть ресурси з одного пулу. Запустіть одну велику VM або п'ять малих. Призупинені VM не використовують пул. Немає оплати за фактичне використання чи за перевищення." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Pro 最多包含 5 台云端虚拟机,每台最多 4 个虚拟 CPU 和 8 GB 内存。Team 的每位用户享有相同上限。不按用量计费,也不收取超额费用。" + "value": "Pro 最多包含 5 台云端虚拟机,共享 20 个虚拟 CPU 和 40 GB 内存。Team 为每个付费席位增加相同的资源池,由整个团队共享。你的虚拟机共用一个资源池,可以运行一台大型虚拟机,也可以运行五台小型虚拟机。已暂停的虚拟机不占用资源池。不按用量计费,也不收取超额费用。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Pro 包含最多 5 台雲端虛擬機,每台最多 4 個虛擬 CPU 與 8 GB 記憶體。Team 的每位使用者享有相同上限。不按用量計費,也不收取超額費用。" + "value": "Pro 包含最多 5 台雲端虛擬機,共用 20 個虛擬 CPU 與 40 GB 記憶體。Team 為每個付費席位增加相同的資源池,由整個團隊共用。你的虛擬機共用一個資源池,可以執行一台大型虛擬機,也可以執行五台小型虛擬機。已暫停的虛擬機不會占用資源池。不按用量計費,也不收取超額費用。" } } } @@ -275711,58 +275468,124 @@ "pricing.native.team.feature.compute": { "extractionState": "manual", "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "Do 5 Cloud VM-ova po plaćenom mjestu, koji dijele 20 vCPU-a i 40 GB RAM-a po plaćenom mjestu u cijelom timu" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Op til 5 Cloud VMs pr. betalt plads, der deler 20 vCPU'er og 40 GB RAM pr. betalt plads på tværs af teamet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Fino a 5 Cloud VM per postazione a pagamento, che condividono 20 vCPU e 40 GB di RAM per postazione a pagamento in tutto il team" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "Cloud VMs រហូតដល់ 5 ក្នុងមួយកៅអីបង់ប្រាក់ ដែលប្រើ vCPU 20 និង RAM 40 GB ក្នុងមួយកៅអីបង់ប្រាក់ រួមគ្នាទូទាំងក្រុម" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Opptil 5 Cloud VMs per betalt plass, som deler 20 vCPU-er og 40 GB RAM per betalt plass på tvers av teamet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Do 5 Cloud VMs na płatne miejsce, współdzielących 20 vCPU i 40 GB RAM na płatne miejsce w całym zespole" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Até 5 Cloud VMs por assento pago, que compartilham 20 vCPUs e 40 GB de RAM por assento pago em toda a equipe" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "До 5 Cloud VMs на оплаченное место с общими для всей команды 20 vCPU и 40 ГБ RAM на оплаченное место" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Cloud VMs สูงสุด 5 เครื่องต่อที่นั่งแบบชำระเงิน ใช้ 20 vCPU และ RAM 40 GB ต่อที่นั่งแบบชำระเงินร่วมกันทั้งทีม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ücretli koltuk başına en fazla 5 Cloud VM; ücretli koltuk başına 20 vCPU ve 40 GB RAM tüm ekip tarafından paylaşılır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "До 5 Cloud VMs на оплачене місце зі спільними для всієї команди 20 vCPU і 40 ГБ RAM на оплачене місце" + } + }, "en": { "stringUnit": { "state": "translated", - "value": "Up to 5 Cloud VMs per user, up to 4 vCPUs and 8 GB RAM per VM" + "value": "Up to 5 Cloud VMs per paid seat, sharing 20 vCPUs and 40 GB RAM per paid seat across the team" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ユーザーごとに最大 5 台の Cloud VM、VM あたり最大 4 vCPU と 8 GB RAM" + "value": "有料シートごとに最大 5 台の Cloud VM、有料シートごとの 20 vCPU と 40 GB RAM をチーム全体で共有" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "每位用户最多 5 台云端虚拟机,每台最多 4 个虚拟 CPU 和 8 GB 内存" + "value": "每个付费席位最多 5 台云端虚拟机,每个付费席位的 20 个虚拟 CPU 和 40 GB 内存由整个团队共享" } }, "de": { "stringUnit": { "state": "translated", - "value": "Bis zu 5 Cloud-VMs pro Benutzer mit bis zu 4 vCPUs und 8 GB RAM pro VM" + "value": "Bis zu 5 Cloud-VMs pro bezahltem Platz, die sich im ganzen Team 20 vCPUs und 40 GB RAM pro bezahltem Platz teilen" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Jusqu’à 5 VM cloud par utilisateur, jusqu’à 4 vCPU et 8 Go de RAM par VM" + "value": "Jusqu’à 5 VM cloud par siège payant, partageant 20 vCPU et 40 Go de RAM par siège payant dans toute l’équipe" } }, "ar": { "stringUnit": { "state": "translated", - "value": "ما يصل إلى 5 أجهزة افتراضية سحابية لكل مستخدم، بحد أقصى 4 وحدات معالجة مركزية افتراضية و8 جيجابايت من الذاكرة لكل جهاز" + "value": "ما يصل إلى 5 أجهزة افتراضية سحابية لكل مقعد مدفوع، تتشارك 20 وحدة معالجة مركزية افتراضية و40 جيجابايت من الذاكرة لكل مقعد مدفوع على مستوى الفريق" } }, "es": { "stringUnit": { "state": "translated", - "value": "Hasta 5 VM en la nube por usuario, con hasta 4 vCPU y 8 GB de RAM por VM" + "value": "Hasta 5 VM en la nube por puesto de pago, que comparten 20 vCPU y 40 GB de RAM por puesto de pago en todo el equipo" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "每位使用者最多 5 台雲端虛擬機,每台最多 4 個虛擬 CPU 與 8 GB 記憶體" + "value": "每個付費席位最多 5 台雲端虛擬機,每個付費席位的 20 個虛擬 CPU 與 40 GB 記憶體由整個團隊共用" } }, "ko": { "stringUnit": { "state": "translated", - "value": "사용자당 최대 5개의 클라우드 VM, VM당 최대 vCPU 4개와 RAM 8 GB" + "value": "유료 시트당 최대 5개의 클라우드 VM, 유료 시트당 vCPU 20개와 RAM 40 GB를 팀 전체가 공유" } } } @@ -293164,58 +292987,124 @@ "settings.account.pro.subtitle": { "extractionState": "manual", "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "Do 5 Cloud VM-ova koji dijele 20 vCPU-a i 40 GB RAM-a, plus iOS aplikacija. 50 $ mjesečno." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Op til 5 Cloud VMs, der deler 20 vCPU'er og 40 GB RAM plus iOS-appen. 50 $/måned." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Fino a 5 Cloud VM che condividono 20 vCPU e 40 GB di RAM, più l'app iOS. 50 $/mese." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "Cloud VMs រហូតដល់ 5 ដែលចែករំលែក vCPU 20 និង RAM 40 GB បូកទាំងកម្មវិធី iOS។ $50/ខែ។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Opptil 5 Cloud VMs som deler 20 vCPU-er og 40 GB RAM, pluss iOS-appen. 50 $/måned." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Do 5 Cloud VMs, które współdzielą 20 vCPU i 40 GB RAM, a do tego aplikacja iOS. 50 $/miesiąc." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Até 5 Cloud VMs que compartilham 20 vCPUs e 40 GB de RAM, além do app para iOS. US$50/mês." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "До 5 Cloud VMs с общими 20 vCPU и 40 ГБ RAM, а также приложение для iOS. $50 в месяц." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Cloud VMs สูงสุด 5 เครื่อง ใช้ 20 vCPU และ RAM 40 GB ร่วมกัน พร้อมแอป iOS $50/เดือน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU ve 40 GB RAM'i paylaşan en fazla 5 Cloud VM, ayrıca iOS uygulaması. Aylık 50 $." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "До 5 Cloud VMs зі спільними 20 vCPU і 40 ГБ RAM, а також застосунок для iOS. $50 на місяць." + } + }, "en": { "stringUnit": { "state": "translated", - "value": "Up to 5 Cloud VMs, up to 4 vCPUs and 8 GB RAM per VM, and the iOS app. $50/month." + "value": "Up to 5 Cloud VMs sharing 20 vCPUs and 40 GB RAM, plus the iOS app. $50/month." } }, "ja": { "stringUnit": { "state": "translated", - "value": "最大 5 台の Cloud VM(VM あたり最大 4 vCPU・8 GB RAM)と iOS アプリ。月額$50。" + "value": "20 vCPU と 40 GB RAM を共有する最大 5 台の Cloud VM と iOS アプリ。月額$50。" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "最多 5 台云端虚拟机(每台最多 4 个虚拟 CPU 和 8 GB 内存)与 iOS 应用。每月 $50。" + "value": "最多 5 台云端虚拟机(共享 20 个虚拟 CPU 和 40 GB 内存),外加 iOS 应用。每月 $50。" } }, "de": { "stringUnit": { "state": "translated", - "value": "Bis zu 5 Cloud-VMs mit bis zu 4 vCPUs und 8 GB RAM pro VM sowie die iOS-App. 50 $/Monat." + "value": "Bis zu 5 Cloud-VMs, die sich 20 vCPUs und 40 GB RAM teilen, plus die iOS-App. 50 $/Monat." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Jusqu’à 5 VM cloud, jusqu’à 4 vCPU et 8 Go de RAM par VM, et l’application iOS. 50 $/mois." + "value": "Jusqu’à 5 VM cloud partageant 20 vCPU et 40 Go de RAM, plus l’application iOS. 50 $/mois." } }, "ar": { "stringUnit": { "state": "translated", - "value": "ما يصل إلى 5 أجهزة افتراضية سحابية، بحد أقصى 4 وحدات معالجة مركزية افتراضية و8 جيجابايت من الذاكرة لكل جهاز، وتطبيق iOS. 50$ شهريًا." + "value": "ما يصل إلى 5 أجهزة افتراضية سحابية تتشارك 20 وحدة معالجة مركزية افتراضية و40 جيجابايت من الذاكرة، بالإضافة إلى تطبيق iOS. 50$ شهريًا." } }, "es": { "stringUnit": { "state": "translated", - "value": "Hasta 5 VM en la nube, con hasta 4 vCPU y 8 GB de RAM por VM, y la aplicación para iOS. $50 al mes." + "value": "Hasta 5 VM en la nube que comparten 20 vCPU y 40 GB de RAM, además de la aplicación para iOS. $50 al mes." } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "最多 5 台雲端虛擬機(每台最多 4 個虛擬 CPU 與 8 GB 記憶體)與 iOS 應用程式。每月 50 美元。" + "value": "最多 5 台雲端虛擬機(共用 20 個虛擬 CPU 與 40 GB 記憶體),外加 iOS 應用程式。每月 50 美元。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "최대 5개의 클라우드 VM(VM당 최대 vCPU 4개, RAM 8 GB)과 iOS 앱. 월 $50." + "value": "vCPU 20개와 RAM 40 GB를 공유하는 최대 5개의 클라우드 VM과 iOS 앱. 월 $50." } } } @@ -522026,58 +521915,124 @@ "pricing.native.sizes.max": { "extractionState": "manual", "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "Max uključuje do 5 Cloud VM-ova koji dijele 80 vCPU-a i 160 GB RAM-a, a jedan VM može koristiti do 64 GB RAM-a." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Max inkluderer op til 5 Cloud VMs, der deler 80 vCPU'er og 160 GB RAM, og én VM kan bruge op til 64 GB RAM." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Max include fino a 5 Cloud VM che condividono 80 vCPU e 160 GB di RAM, e una singola VM può usare fino a 64 GB di RAM." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "Max រួមបញ្ចូល Cloud VMs រហូតដល់ 5 ដែលចែករំលែក vCPU 80 និង RAM 160 GB ហើយ VM មួយអាចប្រើ RAM រហូតដល់ 64 GB។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Max inkluderer opptil 5 Cloud VMs som deler 80 vCPU-er og 160 GB RAM, og én VM kan bruke opptil 64 GB RAM." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Max obejmuje do 5 Cloud VMs, które współdzielą 80 vCPU i 160 GB RAM, a jedna VM może używać do 64 GB RAM." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O Max inclui até 5 Cloud VMs que compartilham 80 vCPUs e 160 GB de RAM, e uma VM pode usar até 64 GB de RAM." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Max включает до 5 Cloud VMs с общими 80 vCPU и 160 ГБ RAM, а одна VM может использовать до 64 ГБ RAM." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Max มี Cloud VMs ได้สูงสุด 5 เครื่อง ซึ่งใช้ 80 vCPU และ RAM 160 GB ร่วมกัน และ VM หนึ่งเครื่องใช้ RAM ได้สูงสุด 64 GB" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Max, 80 vCPU ve 160 GB RAM'i paylaşan en fazla 5 Cloud VM içerir ve tek bir VM en fazla 64 GB RAM kullanabilir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Max включає до 5 Cloud VMs зі спільними 80 vCPU і 160 ГБ RAM, а одна VM може використовувати до 64 ГБ RAM." + } + }, "en": { "stringUnit": { "state": "translated", - "value": "Max includes up to 5 Cloud VMs, with up to 16 vCPUs and 32 GB RAM per VM." + "value": "Max includes up to 5 Cloud VMs sharing 80 vCPUs and 160 GB RAM, and one VM can use up to 64 GB RAM." } }, "de": { "stringUnit": { "state": "translated", - "value": "Max beinhaltet bis zu 5 Cloud-VMs mit bis zu 16 vCPUs und 32 GB RAM pro VM." + "value": "Max beinhaltet bis zu 5 Cloud-VMs, die sich 80 vCPUs und 160 GB RAM teilen; eine einzelne VM kann bis zu 64 GB RAM nutzen." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Max inclut jusqu'à 5 VM Cloud, avec jusqu'à 16 vCPU et 32 Go de RAM par VM." + "value": "Max inclut jusqu'à 5 VM Cloud qui partagent 80 vCPU et 160 Go de RAM, et une seule VM peut utiliser jusqu'à 64 Go de RAM." } }, "ar": { "stringUnit": { "state": "translated", - "value": "تتضمن باقة Max ما يصل إلى 5 أجهزة افتراضية سحابية، بحد أقصى 16 وحدة معالجة مركزية افتراضية و32 جيجابايت من الذاكرة لكل جهاز." + "value": "تتضمن باقة Max ما يصل إلى 5 أجهزة افتراضية سحابية تتشارك 80 وحدة معالجة مركزية افتراضية و160 جيجابايت من الذاكرة، ويمكن للجهاز الواحد استخدام ما يصل إلى 64 جيجابايت من الذاكرة." } }, "es": { "stringUnit": { "state": "translated", - "value": "Max incluye hasta 5 VM en la nube, con hasta 16 vCPU y 32 GB de RAM por VM." + "value": "Max incluye hasta 5 VM en la nube que comparten 80 vCPU y 160 GB de RAM, y una sola VM puede usar hasta 64 GB de RAM." } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Max 包含最多 5 台雲端虛擬機,每台最多 16 個虛擬 CPU 與 32 GB 記憶體。" + "value": "Max 包含最多 5 台雲端虛擬機,共用 80 個虛擬 CPU 與 160 GB 記憶體,單台虛擬機最多可使用 64 GB 記憶體。" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Max 最多包含 5 台云端虚拟机,每台最多 16 个虚拟 CPU 和 32 GB 内存。" + "value": "Max 最多包含 5 台云端虚拟机,共享 80 个虚拟 CPU 和 160 GB 内存,单台虚拟机最多可使用 64 GB 内存。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Max는 최대 5개의 클라우드 VM을 포함하며, VM당 최대 vCPU 16개와 RAM 32 GB를 사용할 수 있습니다." + "value": "Max는 최대 5개의 클라우드 VM을 포함하며, VM들이 vCPU 80개와 RAM 160 GB를 공유합니다. VM 1개는 최대 RAM 64 GB까지 사용할 수 있습니다." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Max には最大 5 台の Cloud VM が含まれ、VM あたり最大 16 vCPU と 32 GB RAM を利用できます。" + "value": "Max には最大 5 台の Cloud VM が含まれ、80 vCPU と 160 GB RAM を共有します。1 台の VM で最大 64 GB RAM を利用できます。" } } } @@ -522852,58 +522807,124 @@ "pricing.native.max.feature.sizes": { "extractionState": "manual", "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "Do 5 Cloud VM-ova koji dijele 80 vCPU-a i 160 GB RAM-a" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Op til 5 Cloud VMs, der deler 80 vCPU'er og 160 GB RAM" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Fino a 5 Cloud VM che condividono 80 vCPU e 160 GB di RAM" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "Cloud VMs រហូតដល់ 5 ដែលចែករំលែក vCPU 80 និង RAM 160 GB" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Opptil 5 Cloud VMs som deler 80 vCPU-er og 160 GB RAM" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Do 5 Cloud VMs, które współdzielą 80 vCPU i 160 GB RAM" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Até 5 Cloud VMs que compartilham 80 vCPUs e 160 GB de RAM" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "До 5 Cloud VMs с общими 80 vCPU и 160 ГБ RAM" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Cloud VMs สูงสุด 5 เครื่อง ใช้ 80 vCPU และ RAM 160 GB ร่วมกัน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "80 vCPU ve 160 GB RAM'i paylaşan en fazla 5 Cloud VM" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "До 5 Cloud VMs зі спільними 80 vCPU і 160 ГБ RAM" + } + }, "en": { "stringUnit": { "state": "translated", - "value": "Up to 5 Cloud VMs, up to 16 vCPUs and 32 GB RAM per VM" + "value": "Up to 5 Cloud VMs sharing 80 vCPUs and 160 GB RAM" } }, "de": { "stringUnit": { "state": "translated", - "value": "Bis zu 5 Cloud-VMs mit bis zu 16 vCPUs und 32 GB RAM pro VM" + "value": "Bis zu 5 Cloud-VMs, die sich 80 vCPUs und 160 GB RAM teilen" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Jusqu’à 5 VM cloud, jusqu’à 16 vCPU et 32 Go de RAM par VM" + "value": "Jusqu’à 5 VM cloud partageant 80 vCPU et 160 Go de RAM" } }, "ar": { "stringUnit": { "state": "translated", - "value": "ما يصل إلى 5 أجهزة افتراضية سحابية، بحد أقصى 16 وحدة معالجة مركزية افتراضية و32 جيجابايت من الذاكرة لكل جهاز" + "value": "ما يصل إلى 5 أجهزة افتراضية سحابية تتشارك 80 وحدة معالجة مركزية افتراضية و160 جيجابايت من الذاكرة" } }, "es": { "stringUnit": { "state": "translated", - "value": "Hasta 5 VM en la nube, con hasta 16 vCPU y 32 GB de RAM por VM" + "value": "Hasta 5 VM en la nube que comparten 80 vCPU y 160 GB de RAM" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "最多 5 台雲端虛擬機,每台最多 16 個虛擬 CPU 與 32 GB 記憶體" + "value": "最多 5 台雲端虛擬機,共用 80 個虛擬 CPU 與 160 GB 記憶體" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "最多 5 台云端虚拟机,每台最多 16 个虚拟 CPU 和 32 GB 内存" + "value": "最多 5 台云端虚拟机,共享 80 个虚拟 CPU 和 160 GB 内存" } }, "ko": { "stringUnit": { "state": "translated", - "value": "최대 5개의 클라우드 VM, VM당 최대 vCPU 16개와 RAM 32 GB" + "value": "최대 5개의 클라우드 VM이 vCPU 80개와 RAM 160 GB를 공유" } }, "ja": { "stringUnit": { "state": "translated", - "value": "最大 5 台の Cloud VM、VM あたり最大 16 vCPU と 32 GB RAM" + "value": "最大 5 台の Cloud VM で 80 vCPU と 160 GB RAM を共有" } } } @@ -523029,58 +523050,124 @@ "pricing.native.compare.largestVm.max": { "extractionState": "manual", "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "64 GB RAM-a" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "64 GB RAM" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "64 GB di RAM" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "RAM 64 GB" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "64 GB RAM" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "64 GB RAM" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "64 GB de RAM" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "64 ГБ RAM" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "RAM 64 GB" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "64 GB RAM" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "64 ГБ RAM" + } + }, "en": { "stringUnit": { "state": "translated", - "value": "32 GB RAM" + "value": "64 GB RAM" } }, "de": { "stringUnit": { "state": "translated", - "value": "32 GB Arbeitsspeicher" + "value": "64 GB Arbeitsspeicher" } }, "fr": { "stringUnit": { "state": "translated", - "value": "32 Go de RAM" + "value": "64 Go de RAM" } }, "ar": { "stringUnit": { "state": "translated", - "value": "32 غيغابايت من الذاكرة" + "value": "64 غيغابايت من الذاكرة" } }, "es": { "stringUnit": { "state": "translated", - "value": "32 GB de RAM" + "value": "64 GB de RAM" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "32 GB 記憶體" + "value": "64 GB 記憶體" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "32 GB 内存" + "value": "64 GB 内存" } }, "ko": { "stringUnit": { "state": "translated", - "value": "32 GB 메모리" + "value": "64 GB 메모리" } }, "ja": { "stringUnit": { "state": "translated", - "value": "32 GB メモリ" + "value": "64 GB メモリ" } } } @@ -523678,58 +523765,124 @@ "pricing.native.compare.largestVm.standard": { "extractionState": "manual", "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "32 GB RAM-a" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "32 GB RAM" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "32 GB di RAM" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "RAM 32 GB" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "32 GB RAM" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "32 GB RAM" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "32 GB de RAM" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "32 ГБ RAM" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "RAM 32 GB" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "32 GB RAM" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "32 ГБ RAM" + } + }, "en": { "stringUnit": { "state": "translated", - "value": "8 GB RAM" + "value": "32 GB RAM" } }, "de": { "stringUnit": { "state": "translated", - "value": "8 GB Arbeitsspeicher" + "value": "32 GB Arbeitsspeicher" } }, "fr": { "stringUnit": { "state": "translated", - "value": "8 Go de RAM" + "value": "32 Go de RAM" } }, "ar": { "stringUnit": { "state": "translated", - "value": "8 غيغابايت من الذاكرة" + "value": "32 غيغابايت من الذاكرة" } }, "es": { "stringUnit": { "state": "translated", - "value": "8 GB de RAM" + "value": "32 GB de RAM" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "8 GB 記憶體" + "value": "32 GB 記憶體" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "8 GB 内存" + "value": "32 GB 内存" } }, "ko": { "stringUnit": { "state": "translated", - "value": "8 GB 메모리" + "value": "32 GB 메모리" } }, "ja": { "stringUnit": { "state": "translated", - "value": "8 GB メモリ" + "value": "32 GB メモリ" } } } @@ -611552,131 +611705,6 @@ } } }, - "cloud.enable.planNote": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Requires a cmux Pro plan." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "يتطلب خطة cmux مدفوعة." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Erfordert einen kostenpflichtigen cmux-Tarif." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Requiere un plan de pago de cmux." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Nécessite une offre cmux payante." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "cmux の有料プランが必要です。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "유료 cmux 요금제가 필요합니다." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "需要付费的 cmux 套餐。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "需要付費的 cmux 方案。" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Requires a cmux Pro plan." - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Requires a cmux Pro plan." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Requires a cmux Pro plan." - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "Requires a cmux Pro plan." - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Requires a cmux Pro plan." - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Requires a cmux Pro plan." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Requires a cmux Pro plan." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Requires a cmux Pro plan." - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "Requires a cmux Pro plan." - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Requires a cmux Pro plan." - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Requires a cmux Pro plan." - } - } - } - }, "cloud.enable.cancelled.subtitle": { "extractionState": "manual", "localizations": { @@ -618986,6 +619014,2957 @@ } } }, + "debug.menu.showCloudWelcome": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Cloud Welcome…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إظهار ترحيب Cloud…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud-Willkommen anzeigen…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar bienvenida de Cloud…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher l’accueil Cloud…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud のようこそ画面を表示…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cloud 환영 화면 보기…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示 Cloud 欢迎界面…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示 Cloud 歡迎畫面…" + } + } + } + }, + "cloud.welcome.note.available": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud is available on cmux Pro and Max." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "Cloud متاح في cmux Pro وMax." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud ist mit cmux Pro und Max verfügbar." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cloud está disponible en cmux Pro y Max." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cloud est disponible avec cmux Pro et Max." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud は cmux Pro と Max でご利用いただけます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cloud는 cmux Pro 및 Max에서 사용할 수 있습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Cloud 适用于 cmux Pro 和 Max 方案。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Cloud 適用於 cmux Pro 與 Max 方案。" + } + } + } + }, + "cloud.welcome.note.included": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Included in your plan." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مضمَّن في خطتك." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In Ihrem Tarif enthalten." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Incluido en tu plan." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Inclus dans votre forfait." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ご利用のプランに含まれています。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 요금제에 포함되어 있습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已包含在你的方案中。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已包含在您的方案中。" + } + } + } + }, + "cloud.welcome.setUp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Set Up Cloud" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعداد Cloud" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud einrichten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Configurar Cloud" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Configurer Cloud" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud を設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cloud 설정" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置 Cloud" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設定 Cloud" + } + } + } + }, + "cloud.welcome.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Introducing cmux cloud" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقدّم لكم cmux cloud" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wir stellen vor: cmux cloud" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Presentamos cmux cloud" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Découvrez cmux cloud" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux cloud のご紹介" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux cloud를 소개합니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隆重推出 cmux cloud" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "隆重推出 cmux cloud" + } + } + } + }, + "cloud.welcome.newBadge": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جديد" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nuevo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouveau" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新機能" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "신규" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新" + } + } + } + }, + "cloud.welcome.title.eyebrow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Introducing" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقدّم لكم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wir stellen vor" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Presentamos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Découvrez" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ご紹介" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "소개합니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隆重推出" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "隆重推出" + } + } + } + }, + "cloud.enable.reason.anyMac": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Opens on any Mac" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يفتح على أي جهاز Mac" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffnet auf jedem Mac" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se abre en cualquier Mac" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "S’ouvre sur n’importe quel Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "どの Mac でも開ける" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "어느 Mac에서나 열기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可在任何 Mac 上打开" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "可在任何 Mac 上開啟" + } + } + } + }, + "cloud.enable.reason.away": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Runs while you’re away" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يعمل أثناء غيابك" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Läuft weiter, während du weg bist" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sigue funcionando mientras no estás" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Continue pendant votre absence" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "離れている間も動き続ける" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자리를 비워도 계속 실행" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "离开时持续运行" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "離開時持續執行" + } + } + } + }, + "cloud.enable.reason.setUp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stays set up" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يبقى جاهزًا" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bleibt eingerichtet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sigue configurada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Reste configurée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セットアップはそのまま" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정이 유지됨" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配置保持不变" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設定保持不變" + } + } + } + }, + "cloud.enable.reason.team": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shared with your team" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مشترك مع فريقك" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mit deinem Team geteilt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Compartida con tu equipo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Partagée avec votre équipe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チームと共有" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀과 공유" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "与团队共享" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "與團隊共用" + } + } + } + }, + "cloud.enable.reason.team.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invite teammates, and share any port with a private link." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ادعُ زملاءك وشارك أي منفذ برابط خاص." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lade Teammitglieder ein und teile jeden Port über einen privaten Link." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Invita a tu equipo y comparte cualquier puerto con un enlace privado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Invitez vos coéquipiers et partagez n’importe quel port avec un lien privé." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チームメンバーを招待して、どのポートもプライベートリンクで共有できます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀원을 초대하고 비공개 링크로 어떤 포트든 공유하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "邀请团队成员,并通过私有链接分享任意端口。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "邀請團隊成員,並透過私人連結分享任何連接埠。" + } + } + } + }, + "cloud.enable.intro.requiresPro": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Upgrade to use cmux cloud" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "قم بالترقية لاستخدام cmux cloud" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Upgrade, um cmux cloud zu nutzen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mejora tu plan para usar cmux cloud" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Passez à Pro pour utiliser cmux cloud" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップグレードして cmux cloud を使う" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업그레이드하여 cmux cloud 사용하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "升级以使用 cmux cloud" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "升級以使用 cmux cloud" + } + } + } + }, + "cloud.enable.intro.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use cmux cloud" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "استخدم cmux cloud" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux cloud verwenden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Usar cmux cloud" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utiliser cmux cloud" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux cloud を使う" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux cloud 사용하기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用 cmux cloud" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用 cmux cloud" + } + } + } + }, + "cloudVM.error.resourcePool.action": { + "extractionState": "manual", + "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pauzirajte ili izbrišite VM, ili pređite na Max." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sæt en VM på pause eller slet den, eller opgrader til Max." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Metti in pausa o elimina una VM, oppure passa a Max." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ផ្អាក ឬលុប VM មួយ ឬដំឡើងទៅ Max។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sett en VM på pause eller slett den, eller oppgrader til Max." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wstrzymaj lub usuń VM albo przejdź na Max." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Pause ou exclua uma VM, ou faça upgrade para o Max." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Приостановите или удалите VM либо перейдите на Max." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หยุด VM ชั่วคราวหรือลบ VM หรืออัปเกรดเป็น Max" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bir VM'yi duraklatın veya silin ya da Max'e yükseltin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Призупиніть або видаліть VM чи перейдіть на Max." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Pause or delete a VM, or upgrade to Max." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pausiere oder lösche eine VM, oder führe ein Upgrade auf Max durch." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mettez en pause ou supprimez une VM, ou passez à Max." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أوقف جهازًا افتراضيًا مؤقتًا أو احذفه، أو قم بالترقية إلى Max." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pausa o elimina una VM, o actualiza a Max." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請暫停或刪除一台虛擬機,或升級至 Max。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请暂停或删除一台虚拟机,或升级到 Max。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "VM을 일시 중지하거나 삭제하거나 Max로 업그레이드하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "VM を一時停止するか削除する、または Max にアップグレードしてください。" + } + } + } + }, + "cloudVM.error.resourcePool.maxAction": { + "extractionState": "manual", + "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pauzirajte ili izbrišite VM." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sæt en VM på pause eller slet den." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Metti in pausa o elimina una VM." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ផ្អាក ឬលុប VM មួយ។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Sett en VM på pause eller slett den." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wstrzymaj lub usuń VM." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Pause ou exclua uma VM." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Приостановите или удалите VM." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หยุด VM ชั่วคราวหรือลบ VM" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bir VM'yi duraklatın veya silin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Призупиніть або видаліть VM." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Pause or delete a VM." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pausiere oder lösche eine VM." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mettez en pause ou supprimez une VM." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أوقف جهازًا افتراضيًا مؤقتًا أو احذفه." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pausa o elimina una VM." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請暫停或刪除一台虛擬機。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请暂停或删除一台虚拟机。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "VM을 일시 중지하거나 삭제하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "VM を一時停止するか削除してください。" + } + } + } + }, + "cloud.machines.pool.usage": { + "extractionState": "manual", + "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "%1$lld od %2$lld vCPU-a · %3$lld od %4$lld GB RAM-a u upotrebi" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%1$lld af %2$lld vCPU'er · %3$lld af %4$lld GB RAM i brug" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%1$lld di %2$lld vCPU · %3$lld di %4$lld GB di RAM in uso" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងប្រើ vCPU %1$lld ក្នុងចំណោម %2$lld · RAM %3$lld ក្នុងចំណោម %4$lld GB" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%1$lld av %2$lld vCPU-er · %3$lld av %4$lld GB RAM i bruk" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "W użyciu: %1$lld z %2$lld vCPU · %3$lld z %4$lld GB RAM" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%1$lld de %2$lld vCPUs · %3$lld de %4$lld GB de RAM em uso" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Используется %1$lld из %2$lld vCPU · %3$lld из %4$lld ГБ RAM" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ใช้อยู่ %1$lld จาก %2$lld vCPU · RAM %3$lld จาก %4$lld GB" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%2$lld vCPU'nun %1$lld tanesi · %4$lld GB RAM'in %3$lld GB'ı kullanımda" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Використано %1$lld з %2$lld vCPU · %3$lld з %4$lld ГБ RAM" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$lld of %2$lld vCPUs · %3$lld of %4$lld GB RAM in use" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$lld von %2$lld vCPUs · %3$lld von %4$lld GB RAM belegt" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$lld vCPU sur %2$lld · %3$lld Go de RAM sur %4$lld utilisés" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "قيد الاستخدام: %1$lld من %2$lld وحدة معالجة مركزية افتراضية · %3$lld من %4$lld جيجابايت من الذاكرة" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%1$lld de %2$lld vCPU · %3$lld de %4$lld GB de RAM en uso" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已使用 %1$lld / %2$lld 個虛擬 CPU · %3$lld / %4$lld GB 記憶體" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已使用 %1$lld / %2$lld 个虚拟 CPU · %3$lld / %4$lld GB 内存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용 중: vCPU %1$lld/%2$lld개 · RAM %3$lld/%4$lld GB" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用中: %1$lld / %2$lld vCPU · %3$lld / %4$lld GB RAM" + } + } + } + }, + "cloud.newMachine.pool.doesNotFit": { + "extractionState": "manual", + "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ova veličina treba %1$lld GB RAM-a, ali je od vašeg skupa od %3$lld GB slobodno samo %2$lld GB. Pauzirajte ili izbrišite VM, ili pređite na Max." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Denne størrelse kræver %1$lld GB RAM, men kun %2$lld GB af din pulje på %3$lld GB er ledig. Sæt en VM på pause eller slet den, eller opgrader til Max." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa dimensione richiede %1$lld GB di RAM, ma del tuo pool da %3$lld GB sono liberi solo %2$lld GB. Metti in pausa o elimina una VM, oppure passa a Max." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ទំហំនេះត្រូវការ RAM %1$lld GB ប៉ុន្តែក្នុងបណ្តុំ %3$lld GB របស់អ្នក នៅទំនេរតែ %2$lld GB ប៉ុណ្ណោះ។ ផ្អាក ឬលុប VM មួយ ឬដំឡើងទៅ Max។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Denne størrelsen trenger %1$lld GB RAM, men bare %2$lld GB av ressurspoolen din på %3$lld GB er ledig. Sett en VM på pause eller slett den, eller oppgrader til Max." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ten rozmiar wymaga %1$lld GB RAM, ale z Twojej puli %3$lld GB wolne jest tylko %2$lld GB. Wstrzymaj lub usuń VM albo przejdź na Max." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Este tamanho precisa de %1$lld GB de RAM, mas só %2$lld GB do seu pool de %3$lld GB estão livres. Pause ou exclua uma VM, ou faça upgrade para o Max." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Для этого размера нужно %1$lld ГБ RAM, но в вашем пуле на %3$lld ГБ свободно только %2$lld ГБ. Приостановите или удалите VM либо перейдите на Max." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ขนาดนี้ต้องใช้ RAM %1$lld GB แต่พูล %3$lld GB ของคุณว่างอยู่เพียง %2$lld GB หยุด VM ชั่วคราวหรือลบ VM หรืออัปเกรดเป็น Max" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu boyut %1$lld GB RAM gerektiriyor, ancak %3$lld GB'lık havuzunuzun yalnızca %2$lld GB'ı boş. Bir VM'yi duraklatın veya silin ya da Max'e yükseltin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Для цього розміру потрібно %1$lld ГБ RAM, але у вашому пулі на %3$lld ГБ вільно лише %2$lld ГБ. Призупиніть або видаліть VM чи перейдіть на Max." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This size needs %1$lld GB RAM, but only %2$lld GB of your %3$lld GB pool is free. Pause or delete a VM, or upgrade to Max." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Größe benötigt %1$lld GB RAM, aber von deinem %3$lld-GB-Pool sind nur %2$lld GB frei. Pausiere oder lösche eine VM, oder führe ein Upgrade auf Max durch." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette taille nécessite %1$lld Go de RAM, mais seuls %2$lld Go de votre réserve de %3$lld Go sont libres. Mettez en pause ou supprimez une VM, ou passez à Max." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يحتاج هذا الحجم إلى %1$lld جيجابايت من الذاكرة، لكن المتاح من مجموعتك البالغة %3$lld جيجابايت هو %2$lld جيجابايت فقط. أوقف جهازًا افتراضيًا مؤقتًا أو احذفه، أو قم بالترقية إلى Max." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Este tamaño necesita %1$lld GB de RAM, pero solo quedan libres %2$lld GB de tu grupo de %3$lld GB. Pausa o elimina una VM, o actualiza a Max." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此規格需要 %1$lld GB 記憶體,但你的 %3$lld GB 資源池只剩 %2$lld GB 可用。請暫停或刪除一台虛擬機,或升級至 Max。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此规格需要 %1$lld GB 内存,但你的 %3$lld GB 资源池只剩 %2$lld GB 可用。请暂停或删除一台虚拟机,或升级到 Max。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 크기에는 RAM %1$lld GB가 필요하지만 %3$lld GB 풀 중 %2$lld GB만 남아 있습니다. VM을 일시 중지하거나 삭제하거나 Max로 업그레이드하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このサイズには %1$lld GB RAM が必要ですが、%3$lld GB のプールのうち空きは %2$lld GB だけです。VM を一時停止するか削除する、または Max にアップグレードしてください。" + } + } + } + }, + "cloud.newMachine.pool.doesNotFitMax": { + "extractionState": "manual", + "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ova veličina treba %1$lld GB RAM-a, ali je od vašeg skupa od %3$lld GB slobodno samo %2$lld GB. Pauzirajte ili izbrišite VM." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Denne størrelse kræver %1$lld GB RAM, men kun %2$lld GB af din pulje på %3$lld GB er ledig. Sæt en VM på pause eller slet den." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa dimensione richiede %1$lld GB di RAM, ma del tuo pool da %3$lld GB sono liberi solo %2$lld GB. Metti in pausa o elimina una VM." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ទំហំនេះត្រូវការ RAM %1$lld GB ប៉ុន្តែក្នុងបណ្តុំ %3$lld GB របស់អ្នក នៅទំនេរតែ %2$lld GB ប៉ុណ្ណោះ។ ផ្អាក ឬលុប VM មួយ។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Denne størrelsen trenger %1$lld GB RAM, men bare %2$lld GB av ressurspoolen din på %3$lld GB er ledig. Sett en VM på pause eller slett den." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ten rozmiar wymaga %1$lld GB RAM, ale z Twojej puli %3$lld GB wolne jest tylko %2$lld GB. Wstrzymaj lub usuń VM." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Este tamanho precisa de %1$lld GB de RAM, mas só %2$lld GB do seu pool de %3$lld GB estão livres. Pause ou exclua uma VM." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Для этого размера нужно %1$lld ГБ RAM, но в вашем пуле на %3$lld ГБ свободно только %2$lld ГБ. Приостановите или удалите VM." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ขนาดนี้ต้องใช้ RAM %1$lld GB แต่พูล %3$lld GB ของคุณว่างอยู่เพียง %2$lld GB หยุด VM ชั่วคราวหรือลบ VM" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu boyut %1$lld GB RAM gerektiriyor, ancak %3$lld GB'lık havuzunuzun yalnızca %2$lld GB'ı boş. Bir VM'yi duraklatın veya silin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Для цього розміру потрібно %1$lld ГБ RAM, але у вашому пулі на %3$lld ГБ вільно лише %2$lld ГБ. Призупиніть або видаліть VM." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This size needs %1$lld GB RAM, but only %2$lld GB of your %3$lld GB pool is free. Pause or delete a VM." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Größe benötigt %1$lld GB RAM, aber von deinem %3$lld-GB-Pool sind nur %2$lld GB frei. Pausiere oder lösche eine VM." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette taille nécessite %1$lld Go de RAM, mais seuls %2$lld Go de votre réserve de %3$lld Go sont libres. Mettez en pause ou supprimez une VM." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يحتاج هذا الحجم إلى %1$lld جيجابايت من الذاكرة، لكن المتاح من مجموعتك البالغة %3$lld جيجابايت هو %2$lld جيجابايت فقط. أوقف جهازًا افتراضيًا مؤقتًا أو احذفه." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Este tamaño necesita %1$lld GB de RAM, pero solo quedan libres %2$lld GB de tu grupo de %3$lld GB. Pausa o elimina una VM." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此規格需要 %1$lld GB 記憶體,但你的 %3$lld GB 資源池只剩 %2$lld GB 可用。請暫停或刪除一台虛擬機。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此规格需要 %1$lld GB 内存,但你的 %3$lld GB 资源池只剩 %2$lld GB 可用。请暂停或删除一台虚拟机。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 크기에는 RAM %1$lld GB가 필요하지만 %3$lld GB 풀 중 %2$lld GB만 남아 있습니다. VM을 일시 중지하거나 삭제하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このサイズには %1$lld GB RAM が必要ですが、%3$lld GB のプールのうち空きは %2$lld GB だけです。VM を一時停止するか削除してください。" + } + } + } + }, + "cloud.newMachine.pool.doesNotFitVcpus": { + "extractionState": "manual", + "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ova veličina treba %1$lld vCPU-a, ali je od vaših %3$lld vCPU-a slobodno samo %2$lld. Pauzirajte ili izbrišite VM, ili pređite na Max." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Denne størrelse kræver %1$lld vCPU'er, men kun %2$lld af dine %3$lld vCPU'er er ledige. Sæt en VM på pause eller slet den, eller opgrader til Max." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa dimensione richiede %1$lld vCPU, ma delle tue %3$lld vCPU ne sono libere solo %2$lld. Metti in pausa o elimina una VM, oppure passa a Max." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ទំហំនេះត្រូវការ vCPU %1$lld ប៉ុន្តែក្នុងចំណោម vCPU %3$lld របស់អ្នក នៅទំនេរតែ %2$lld ប៉ុណ្ណោះ។ ផ្អាក ឬលុប VM មួយ ឬដំឡើងទៅ Max។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Denne størrelsen trenger %1$lld vCPU-er, men bare %2$lld av dine %3$lld vCPU-er er ledige. Sett en VM på pause eller slett den, eller oppgrader til Max." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ten rozmiar wymaga %1$lld vCPU, ale z Twoich %3$lld vCPU wolne jest tylko %2$lld. Wstrzymaj lub usuń VM albo przejdź na Max." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Este tamanho precisa de %1$lld vCPUs, mas só %2$lld das suas %3$lld vCPUs estão livres. Pause ou exclua uma VM, ou faça upgrade para o Max." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Для этого размера нужно %1$lld vCPU, но из ваших %3$lld vCPU свободно только %2$lld. Приостановите или удалите VM либо перейдите на Max." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ขนาดนี้ต้องใช้ %1$lld vCPU แต่จาก %3$lld vCPU ของคุณว่างอยู่เพียง %2$lld หยุด VM ชั่วคราวหรือลบ VM หรืออัปเกรดเป็น Max" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu boyut %1$lld vCPU gerektiriyor, ancak %3$lld vCPU'nuzun yalnızca %2$lld tanesi boş. Bir VM'yi duraklatın veya silin ya da Max'e yükseltin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Для цього розміру потрібно %1$lld vCPU, але з ваших %3$lld vCPU вільно лише %2$lld. Призупиніть або видаліть VM чи перейдіть на Max." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This size needs %1$lld vCPUs, but only %2$lld of your %3$lld vCPUs are free. Pause or delete a VM, or upgrade to Max." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Größe benötigt %1$lld vCPUs, aber von deinen %3$lld vCPUs sind nur %2$lld frei. Pausiere oder lösche eine VM, oder führe ein Upgrade auf Max durch." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette taille nécessite %1$lld vCPU, mais seuls %2$lld de vos %3$lld vCPU sont libres. Mettez en pause ou supprimez une VM, ou passez à Max." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يحتاج هذا الحجم إلى %1$lld من وحدات المعالجة المركزية الافتراضية، لكن المتاح منها %2$lld فقط من أصل %3$lld. أوقف جهازًا افتراضيًا مؤقتًا أو احذفه، أو قم بالترقية إلى Max." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Este tamaño necesita %1$lld vCPU, pero solo quedan libres %2$lld de tus %3$lld vCPU. Pausa o elimina una VM, o actualiza a Max." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此規格需要 %1$lld 個虛擬 CPU,但你的 %3$lld 個虛擬 CPU 中只剩 %2$lld 個可用。請暫停或刪除一台虛擬機,或升級至 Max。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此规格需要 %1$lld 个虚拟 CPU,但你的 %3$lld 个虚拟 CPU 中只剩 %2$lld 个可用。请暂停或删除一台虚拟机,或升级到 Max。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 크기에는 vCPU %1$lld개가 필요하지만 vCPU %3$lld개 중 %2$lld개만 남아 있습니다. VM을 일시 중지하거나 삭제하거나 Max로 업그레이드하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このサイズには %1$lld vCPU が必要ですが、%3$lld vCPU のうち空きは %2$lld vCPU だけです。VM を一時停止するか削除する、または Max にアップグレードしてください。" + } + } + } + }, + "cloud.newMachine.pool.doesNotFitVcpusMax": { + "extractionState": "manual", + "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ova veličina treba %1$lld vCPU-a, ali je od vaših %3$lld vCPU-a slobodno samo %2$lld. Pauzirajte ili izbrišite VM." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Denne størrelse kræver %1$lld vCPU'er, men kun %2$lld af dine %3$lld vCPU'er er ledige. Sæt en VM på pause eller slet den." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa dimensione richiede %1$lld vCPU, ma delle tue %3$lld vCPU ne sono libere solo %2$lld. Metti in pausa o elimina una VM." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ទំហំនេះត្រូវការ vCPU %1$lld ប៉ុន្តែក្នុងចំណោម vCPU %3$lld របស់អ្នក នៅទំនេរតែ %2$lld ប៉ុណ្ណោះ។ ផ្អាក ឬលុប VM មួយ។" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Denne størrelsen trenger %1$lld vCPU-er, men bare %2$lld av dine %3$lld vCPU-er er ledige. Sett en VM på pause eller slett den." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ten rozmiar wymaga %1$lld vCPU, ale z Twoich %3$lld vCPU wolne jest tylko %2$lld. Wstrzymaj lub usuń VM." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Este tamanho precisa de %1$lld vCPUs, mas só %2$lld das suas %3$lld vCPUs estão livres. Pause ou exclua uma VM." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Для этого размера нужно %1$lld vCPU, но из ваших %3$lld vCPU свободно только %2$lld. Приостановите или удалите VM." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ขนาดนี้ต้องใช้ %1$lld vCPU แต่จาก %3$lld vCPU ของคุณว่างอยู่เพียง %2$lld หยุด VM ชั่วคราวหรือลบ VM" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu boyut %1$lld vCPU gerektiriyor, ancak %3$lld vCPU'nuzun yalnızca %2$lld tanesi boş. Bir VM'yi duraklatın veya silin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Для цього розміру потрібно %1$lld vCPU, але з ваших %3$lld vCPU вільно лише %2$lld. Призупиніть або видаліть VM." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This size needs %1$lld vCPUs, but only %2$lld of your %3$lld vCPUs are free. Pause or delete a VM." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Größe benötigt %1$lld vCPUs, aber von deinen %3$lld vCPUs sind nur %2$lld frei. Pausiere oder lösche eine VM." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette taille nécessite %1$lld vCPU, mais seuls %2$lld de vos %3$lld vCPU sont libres. Mettez en pause ou supprimez une VM." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يحتاج هذا الحجم إلى %1$lld من وحدات المعالجة المركزية الافتراضية، لكن المتاح منها %2$lld فقط من أصل %3$lld. أوقف جهازًا افتراضيًا مؤقتًا أو احذفه." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Este tamaño necesita %1$lld vCPU, pero solo quedan libres %2$lld de tus %3$lld vCPU. Pausa o elimina una VM." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此規格需要 %1$lld 個虛擬 CPU,但你的 %3$lld 個虛擬 CPU 中只剩 %2$lld 個可用。請暫停或刪除一台虛擬機。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此规格需要 %1$lld 个虚拟 CPU,但你的 %3$lld 个虚拟 CPU 中只剩 %2$lld 个可用。请暂停或删除一台虚拟机。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 크기에는 vCPU %1$lld개가 필요하지만 vCPU %3$lld개 중 %2$lld개만 남아 있습니다. VM을 일시 중지하거나 삭제하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このサイズには %1$lld vCPU が必要ですが、%3$lld vCPU のうち空きは %2$lld vCPU だけです。VM を一時停止するか削除してください。" + } + } + } + }, + "machines.new.plan.loading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading your Cloud machine plan…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ تحميل خطة جهاز Cloud…" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Učitavanje plana Cloud mašine…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indlæser din Cloud-maskineplan…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dein Cloud-Maschinentarif wird geladen…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cargando el plan de tu máquina Cloud…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chargement du forfait de votre machine Cloud…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Caricamento del piano della macchina Cloud…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud マシンのプランを読み込んでいます…" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងផ្ទុកគម្រោងម៉ាស៊ីន Cloud របស់អ្នក…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cloud 머신 요금제를 불러오는 중…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Laster inn Cloud-maskinplanen din…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wczytywanie planu maszyny Cloud…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Carregando o plano da sua máquina Cloud…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузка тарифа машины Cloud…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังโหลดแผนเครื่อง Cloud ของคุณ…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Cloud makine planınız yükleniyor…" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Завантаження тарифу машини Cloud…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在加载你的 Cloud 机器方案…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在載入您的 Cloud 機器方案…" + } + } + } + }, + "machines.new.plan.retry": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة المحاولة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pokušaj ponovo" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Prøv igen" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erneut versuchen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reintentar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réessayer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riprova" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再試行" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ព្យាយាមម្ដងទៀត" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재시도" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Prøv igjen" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ponów" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tentar Novamente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Повторить" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลองใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tekrar Dene" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Повторити" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重试" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重試" + } + } + } + }, + "machines.new.plan.error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud plan details are still loading. Retry in a moment." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا تزال تفاصيل خطة Cloud قيد التحميل. أعد المحاولة بعد قليل." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Detalji Cloud plana se još učitavaju. Pokušajte ponovo za trenutak." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Cloud-planens detaljer indlæses stadig. Prøv igen om et øjeblik." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Details des Cloud-Tarifs werden noch geladen. Versuche es gleich noch einmal." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Los detalles del plan de Cloud aún se están cargando. Vuelve a intentarlo en un momento." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les détails du forfait Cloud sont encore en cours de chargement. Réessayez dans un instant." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "I dettagli del piano Cloud sono ancora in caricamento. Riprova tra un momento." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud プランの詳細を読み込み中です。しばらくしてから再試行してください。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ព័ត៌មានលម្អិតនៃគម្រោង Cloud នៅតែកំពុងផ្ទុក។ សូមព្យាយាមម្ដងទៀតបន្តិចទៀត។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cloud 요금제 정보를 아직 불러오는 중입니다. 잠시 후 다시 시도하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Detaljene for Cloud-planen lastes fortsatt inn. Prøv igjen om et øyeblikk." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Szczegóły planu Cloud wciąż się wczytują. Spróbuj ponownie za chwilę." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Os detalhes do plano Cloud ainda estão carregando. Tente novamente em instantes." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сведения о тарифе Cloud ещё загружаются. Повторите попытку через мгновение." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รายละเอียดแผน Cloud ยังโหลดอยู่ โปรดลองใหม่อีกครั้งในอีกสักครู่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Cloud planı ayrıntıları hâlâ yükleniyor. Birazdan tekrar deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відомості про тариф Cloud ще завантажуються. Повторіть спробу через мить." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Cloud 方案详情仍在加载。请稍后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Cloud 方案詳細資料仍在載入。請稍後重試。" + } + } + } + }, + "settings.search.alias.setting.terminal.copy-confirmation": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse تأكيد نسخ حافظة تحديد" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse potvrda kopirano međuspremnik odabir" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse bekræftelse kopieret udklipsholder markering" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse Bestätigung kopiert Zwischenablage Auswahl" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse confirmación copiado portapapeles selección" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse confirmation copié presse-papiers sélection" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse conferma copiato appunti selezione" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse 確認 コピー クリップボード 選択" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse បញ្ជាក់ ចម្លង ក្ដារតម្រៀប ជ្រើស" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse 확인 복사됨 클립보드 선택" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse bekreftelse kopiert utklippstavle markering" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse potwierdzenie skopiowano schowek zaznaczenie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse confirmação copiado área de transferência seleção" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse подтверждение скопировано буфер обмена выделение" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse ยืนยัน คัดลอก คลิปบอร์ด เลือก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse onay kopyalandı pano seçim" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse підтвердження скопійовано буфер обміну виділення" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse 确认 已复制 剪贴板 选择" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse 確認 已複製 剪貼簿 選取" + } + } + } + }, + "settings.terminal.showCopyConfirmation": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إظهار تأكيد النسخ" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži potvrdu kopiranja" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis kopieringsbekræftelse" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kopierbestätigung anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Copy Confirmation" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar confirmación de copia" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher la confirmation de copie" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra conferma di copia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コピーの確認を表示" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បង្ហាញការបញ្ជាក់ការចម្លង" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "복사 확인 표시" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis kopieringsbekreftelse" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokazuj potwierdzenie kopiowania" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostrar confirmação de cópia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать подтверждение копирования" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงการยืนยันการคัดลอก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kopyalama onayını göster" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Показувати підтвердження копіювання" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示复制确认" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示複製確認" + } + } + } + }, + "settings.terminal.showCopyConfirmation.subtitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يعرض «تم النسخ إلى الحافظة» لفترة وجيزة أسفل الطرفية عندما يؤدي تحديد النص إلى نسخه." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kratko prikazuje „Kopirano u međuspremnik“ na dnu terminala kada se odabrani tekst kopira." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Viser kortvarigt „Kopieret til udklipsholder“ nederst i en terminal, når markering af tekst kopierer den." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeigt unten im Terminal kurz „In die Zwischenablage kopiert“ an, wenn durch Auswählen von Text kopiert wird." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Briefly shows “Copied to clipboard” at the bottom of a terminal when selecting text copies it." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Muestra brevemente «Copiado al portapapeles» en la parte inferior de una terminal cuando al seleccionar texto se copia." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Affiche brièvement « Copié dans le presse-papiers » en bas d’un terminal lorsque la sélection de texte le copie." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra brevemente «Copiato negli appunti» in fondo a un terminale quando la selezione del testo lo copia." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "テキストを選択してコピーしたときに、ターミナルの下部に「クリップボードにコピーしました」を短く表示します。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បង្ហាញ «បានចម្លងទៅក្ដារតម្រៀប» មួយភ្លែតនៅខាងក្រោម terminal នៅពេលការជ្រើសអត្ថបទចម្លងវា។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "텍스트를 선택해 복사하면 터미널 하단에 “클립보드에 복사됨”을 잠시 표시합니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Viser kort «Kopiert til utklippstavlen» nederst i en terminal når markering av tekst kopierer den." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Na chwilę wyświetla „Skopiowano do schowka” u dołu terminala, gdy zaznaczenie tekstu go kopiuje." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostra brevemente “Copiado para a área de transferência” na parte inferior do terminal quando selecionar texto o copia." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ненадолго показывает «Скопировано в буфер обмена» внизу терминала, когда выделение текста копирует его." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดง “คัดลอกไปยังคลิปบอร์ดแล้ว” ชั่วครู่ที่ด้านล่างของเทอร์มินัลเมื่อการเลือกข้อความคัดลอกข้อความนั้น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Metin seçimi onu kopyaladığında terminalin altında kısa süreliğine “Panoya kopyalandı” gösterir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ненадовго показує «Скопійовано в буфер обміну» внизу термінала, коли виділення тексту копіює його." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择文本并复制时,在终端底部短暂显示“已复制到剪贴板”。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選取文字並複製時,在終端機底部短暫顯示「已複製到剪貼簿」。" + } + } + } + }, + "pricing.native.compare.resources": { + "extractionState": "manual", + "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "Resursi Cloud VM-ova" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Cloud VM-ressourcer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Risorse delle Cloud VM" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ធនធាន Cloud VM" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Cloud VM-ressurser" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zasoby Cloud VM" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Recursos das Cloud VMs" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ресурсы Cloud VM" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทรัพยากร Cloud VM" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Cloud VM kaynakları" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ресурси Cloud VM" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud VM resources" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud-VM-Ressourcen" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ressources de VM cloud" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "موارد الجهاز الافتراضي السحابي" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Recursos de VM en la nube" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "雲端虛擬機資源" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Cloud VM 资源" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cloud VM 리소스" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud VM のリソース" + } + } + } + }, + "pricing.native.compare.resources.standard": { + "extractionState": "manual", + "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU-a i 40 GB RAM-a, zajednički" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU'er og 40 GB RAM, delt" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU e 40 GB di RAM, condivisi" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "vCPU 20 និង RAM 40 GB ប្រើរួមគ្នា" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU-er og 40 GB RAM, delt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU i 40 GB RAM, współdzielone" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "20 vCPUs e 40 GB de RAM, compartilhados" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU и 40 ГБ RAM, общие" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU และ RAM 40 GB ใช้ร่วมกัน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU ve 40 GB RAM, paylaşımlı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU і 40 ГБ RAM, спільні" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "20 vCPUs and 40 GB RAM, shared" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "20 vCPUs und 40 GB RAM, gemeinsam genutzt" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU et 40 Go de RAM partagés" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "20 وحدة معالجة مركزية افتراضية و40 جيجابايت من الذاكرة مشتركة" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU y 40 GB de RAM compartidos" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "共用 20 個虛擬 CPU 與 40 GB 記憶體" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "共享 20 个虚拟 CPU 和 40 GB 内存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "vCPU 20개와 RAM 40 GB 공유" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU と 40 GB RAM を共有" + } + } + } + }, + "pricing.native.compare.resources.team": { + "extractionState": "manual", + "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU-a i 40 GB RAM-a po plaćenom mjestu, zajednički za cijeli tim" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU'er og 40 GB RAM pr. betalt plads, delt på tværs af teamet" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU e 40 GB di RAM per postazione a pagamento, condivisi da tutto il team" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "vCPU 20 និង RAM 40 GB ក្នុងមួយកៅអីបង់ប្រាក់ ដែលក្រុមទាំងមូលប្រើរួមគ្នា" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU-er og 40 GB RAM per betalt plass, delt på tvers av teamet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU i 40 GB RAM na płatne miejsce, współdzielone w całym zespole" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "20 vCPUs e 40 GB de RAM por assento pago, compartilhados por toda a equipe" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU и 40 ГБ RAM на оплаченное место, общие для всей команды" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU และ RAM 40 GB ต่อที่นั่งแบบชำระเงิน ใช้ร่วมกันทั้งทีม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ücretli koltuk başına 20 vCPU ve 40 GB RAM, tüm ekipçe paylaşılır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU і 40 ГБ RAM на оплачене місце, спільні для всієї команди" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "20 vCPUs and 40 GB RAM per paid seat, shared across the team" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "20 vCPUs und 40 GB RAM pro bezahltem Platz, im ganzen Team gemeinsam genutzt" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU et 40 Go de RAM par siège payant, partagés dans toute l’équipe" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "20 وحدة معالجة مركزية افتراضية و40 جيجابايت من الذاكرة لكل مقعد مدفوع، مشتركة على مستوى الفريق" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "20 vCPU y 40 GB de RAM por puesto de pago, compartidos en todo el equipo" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "每個付費席位 20 個虛擬 CPU 與 40 GB 記憶體,由整個團隊共用" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "每个付费席位 20 个虚拟 CPU 和 40 GB 内存,由整个团队共享" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "유료 시트당 vCPU 20개와 RAM 40 GB, 팀 전체가 공유" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "有料シートごとに 20 vCPU と 40 GB RAM(チーム全体で共有)" + } + } + } + }, + "pricing.native.compare.resources.max": { + "extractionState": "manual", + "localizations": { + "bs": { + "stringUnit": { + "state": "translated", + "value": "80 vCPU-a i 160 GB RAM-a, zajednički" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "80 vCPU'er og 160 GB RAM, delt" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "80 vCPU e 160 GB di RAM, condivisi" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "vCPU 80 និង RAM 160 GB ប្រើរួមគ្នា" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "80 vCPU-er og 160 GB RAM, delt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "80 vCPU i 160 GB RAM, współdzielone" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "80 vCPUs e 160 GB de RAM, compartilhados" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "80 vCPU и 160 ГБ RAM, общие" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "80 vCPU และ RAM 160 GB ใช้ร่วมกัน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "80 vCPU ve 160 GB RAM, paylaşımlı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "80 vCPU і 160 ГБ RAM, спільні" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "80 vCPUs and 160 GB RAM, shared" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "80 vCPUs und 160 GB RAM, gemeinsam genutzt" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "80 vCPU et 160 Go de RAM partagés" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "80 وحدة معالجة مركزية افتراضية و160 جيجابايت من الذاكرة مشتركة" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "80 vCPU y 160 GB de RAM compartidos" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "共用 80 個虛擬 CPU 與 160 GB 記憶體" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "共享 80 个虚拟 CPU 和 160 GB 内存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "vCPU 80개와 RAM 160 GB 공유" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "80 vCPU と 160 GB RAM を共有" + } + } + } + }, "sshPane.newTerminalFailed.shortTitle": { "extractionState": "manual", "localizations": { @@ -619022,1695 +622001,2559 @@ "zh-Hant": { "stringUnit": { "state": "translated", - "value": "無法開啟 SSH 終端機" + "value": "無法開啟 SSH 終端機" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法打开 SSH 终端" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "SSH 터미널을 열 수 없습니다" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH ターミナルを開けませんでした" + } + } + } + }, + "sshPane.newTerminalFailed.recovery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check the SSH connection, then retry this request." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Prüfe die SSH-Verbindung und versuche die Anfrage erneut." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérifiez la connexion SSH, puis réessayez." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحقّق من اتصال SSH ثم أعد محاولة هذا الطلب." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comprueba la conexión SSH y vuelve a intentarlo." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請檢查 SSH 連線後再試一次。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请检查 SSH 连接,然后重试。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "SSH 연결을 확인한 후 다시 시도하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH 接続を確認してから、もう一度お試しください。" + } + } + } + }, + "sshPane.newTerminalFailed.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The SSH connection could not be established." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die SSH-Verbindung konnte nicht hergestellt werden." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La connexion SSH n’a pas pu être établie." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر إنشاء اتصال SSH." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo establecer la conexión SSH." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法建立 SSH 連線。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法建立 SSH 连接。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "SSH 연결을 설정할 수 없습니다." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH 接続を確立できませんでした." + } + } + } + }, + "terminal.copyConfirmation.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم النسخ إلى الحافظة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kopirano u međuspremnik" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kopieret til udklipsholder" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In die Zwischenablage kopiert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Copied to clipboard" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Copiado al portapapeles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copié dans le presse-papiers" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Copiato negli appunti" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリップボードにコピーしました" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បានចម្លងទៅក្ដារតម្រៀប" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클립보드에 복사됨" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kopiert til utklippstavlen" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Skopiowano do schowka" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Copiado para a área de transferência" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скопировано в буфер обмена" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คัดลอกไปยังคลิปบอร์ดแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Panoya kopyalandı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Скопійовано в буфер обміну" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已复制到剪贴板" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已複製到剪貼簿" + } + } + } + }, + "cli.hooks.setup.error.agentRequiresValue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "--agent requires a value. Usage: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتطلب --agent قيمة. الاستخدام: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "--agent erfordert einen Wert. Verwendung: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "--agent requiere un valor. Uso: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "--agent nécessite une valeur. Utilisation : cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "--agent には値が必要です。使い方: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "--agent에는 값이 필요합니다. 사용법: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "--agent 需要一个值。用法:cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "--agent 需要一個值。用法:cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + } + } + }, + "cli.hooks.setup.error.unknownOption": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown option %@. Usage: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "خيار غير معروف %@. الاستخدام: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbekannte Option %@. Verwendung: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Opción desconocida %@. Uso: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Option inconnue %@. Utilisation : cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なオプション %@。使い方: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없는 옵션 %@. 사용법: cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知选项 %@。用法:cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未知的選項 %@。用法:cmux hooks setup [agent] [--agent ] [--yes|-y]" + } + } + } + }, + "cli.hooks.setup.error.conflictingAgent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "--agent was given more than once with different values. Specify one agent." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم تمرير --agent أكثر من مرة بقيم مختلفة. حدد وكيلًا واحدًا." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "--agent wurde mehrmals mit unterschiedlichen Werten angegeben. Gib einen Agenten an." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "--agent se indicó más de una vez con valores distintos. Indica un solo agente." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "--agent a été indiqué plusieurs fois avec des valeurs différentes. Indiquez un seul agent." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "--agent が異なる値で複数回指定されました。エージェントを 1 つだけ指定してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "--agent가 서로 다른 값으로 여러 번 지정되었습니다. 에이전트를 하나만 지정하세요." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "--agent 被多次指定且值不同。请只指定一个代理。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "--agent 被多次指定且值不同。請只指定一個代理程式。" + } + } + } + }, + "cloudTree.displays.starting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Starting display…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスプレイを起動中…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Display wird gestartet…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Iniciando pantalla…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Démarrage de l’écran…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스플레이 시작 중…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在启动显示器…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在啟動顯示器…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ بدء الشاشة…" + } + } + } + }, + "surface.split.noSpace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "There is no room to split this pane." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このペインを分割する余地がありません。" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Für eine Teilung dieses Bereichs ist kein Platz." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No hay espacio para dividir este panel." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Il n’y a pas assez de place pour diviser ce volet." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 패널을 분할할 공간이 없습니다." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "无法打开 SSH 终端" + "value": "没有空间拆分此窗格。" } }, - "ko": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "SSH 터미널을 열 수 없습니다" + "value": "沒有空間分割此窗格。" } }, - "ja": { + "ar": { "stringUnit": { "state": "translated", - "value": "SSH ターミナルを開けませんでした" + "value": "لا توجد مساحة لتقسيم هذا الجزء." } } } }, - "sshPane.newTerminalFailed.recovery": { + "cloud.display.starting": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Check the SSH connection, then retry this request." + "value": "Starting display…" } }, - "de": { + "ja": { "stringUnit": { "state": "translated", - "value": "Prüfe die SSH-Verbindung und versuche die Anfrage erneut." + "value": "ディスプレイを起動中…" } }, - "fr": { + "de": { "stringUnit": { "state": "translated", - "value": "Vérifiez la connexion SSH, puis réessayez." + "value": "Display wird gestartet…" } }, - "ar": { + "es": { "stringUnit": { "state": "translated", - "value": "تحقّق من اتصال SSH ثم أعد محاولة هذا الطلب." + "value": "Iniciando pantalla…" } }, - "es": { + "fr": { "stringUnit": { "state": "translated", - "value": "Comprueba la conexión SSH y vuelve a intentarlo." + "value": "Démarrage de l’écran…" } }, - "zh-Hant": { + "ko": { "stringUnit": { "state": "translated", - "value": "請檢查 SSH 連線後再試一次。" + "value": "디스플레이 시작 중…" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "请检查 SSH 连接,然后重试。" + "value": "正在启动显示器…" } }, - "ko": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "SSH 연결을 확인한 후 다시 시도하세요." + "value": "正在啟動顯示器…" } }, - "ja": { + "ar": { "stringUnit": { "state": "translated", - "value": "SSH 接続を確認してから、もう一度お試しください。" + "value": "جارٍ بدء الشاشة…" } } } }, - "sshPane.newTerminalFailed.detail": { + "settings.shortcuts.detector.accessibilityLabel": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The SSH connection could not be established." + "value": "Find shortcut by keys" } }, - "de": { + "ar": { "stringUnit": { "state": "translated", - "value": "Die SSH-Verbindung konnte nicht hergestellt werden." + "value": "البحث عن اختصار بالمفاتيح" } }, - "fr": { + "bs": { "stringUnit": { "state": "translated", - "value": "La connexion SSH n’a pas pu être établie." + "value": "Pronađi prečicu po tipkama" } }, - "ar": { + "da": { "stringUnit": { "state": "translated", - "value": "تعذّر إنشاء اتصال SSH." + "value": "Find genvej ud fra taster" } }, - "es": { + "de": { "stringUnit": { "state": "translated", - "value": "No se pudo establecer la conexión SSH." + "value": "Tastenkürzel anhand der Tasten finden" } }, - "zh-Hant": { + "es": { "stringUnit": { "state": "translated", - "value": "無法建立 SSH 連線。" + "value": "Buscar atajo por teclas" } }, - "zh-Hans": { + "fr": { "stringUnit": { "state": "translated", - "value": "无法建立 SSH 连接。" + "value": "Trouver un raccourci par ses touches" } }, - "ko": { + "it": { "stringUnit": { "state": "translated", - "value": "SSH 연결을 설정할 수 없습니다." + "value": "Trova scorciatoia dai tasti" } }, "ja": { "stringUnit": { "state": "translated", - "value": "SSH 接続を確立できませんでした." + "value": "キーでショートカットを探す" } - } - } - }, - "cli.hooks.setup.error.agentRequiresValue": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "km": { "stringUnit": { "state": "translated", - "value": "--agent requires a value. Usage: cmux hooks setup [agent] [--agent ] [--yes|-y]" + "value": "ស្វែងរកផ្លូវកាត់តាមគ្រាប់ចុច" } }, - "ar": { + "ko": { "stringUnit": { "state": "translated", - "value": "يتطلب --agent قيمة. الاستخدام: cmux hooks setup [agent] [--agent ] [--yes|-y]" + "value": "키로 단축키 찾기" } }, - "de": { + "nb": { "stringUnit": { "state": "translated", - "value": "--agent erfordert einen Wert. Verwendung: cmux hooks setup [agent] [--agent ] [--yes|-y]" + "value": "Finn snarvei etter taster" } }, - "es": { + "pl": { "stringUnit": { "state": "translated", - "value": "--agent requiere un valor. Uso: cmux hooks setup [agent] [--agent ] [--yes|-y]" + "value": "Znajdź skrót po klawiszach" } }, - "fr": { + "pt-BR": { "stringUnit": { "state": "translated", - "value": "--agent nécessite une valeur. Utilisation : cmux hooks setup [agent] [--agent ] [--yes|-y]" + "value": "Encontrar atalho pelas teclas" } }, - "ja": { + "ru": { "stringUnit": { "state": "translated", - "value": "--agent には値が必要です。使い方: cmux hooks setup [agent] [--agent ] [--yes|-y]" + "value": "Найти сочетание по клавишам" } }, - "ko": { + "th": { "stringUnit": { "state": "translated", - "value": "--agent에는 값이 필요합니다. 사용법: cmux hooks setup [agent] [--agent ] [--yes|-y]" + "value": "ค้นหาคีย์ลัดจากปุ่ม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kısayolu tuşlarla bul" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Знайти сполучення за клавішами" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "--agent 需要一个值。用法:cmux hooks setup [agent] [--agent ] [--yes|-y]" + "value": "按按键查找快捷键" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "--agent 需要一個值。用法:cmux hooks setup [agent] [--agent ] [--yes|-y]" + "value": "依按鍵尋找快速鍵" } } } }, - "cli.hooks.setup.error.unknownOption": { + "settings.shortcuts.detector.help": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Unknown option %@. Usage: cmux hooks setup [agent] [--agent ] [--yes|-y]" + "value": "Press keys to see which shortcut uses them" } }, "ar": { "stringUnit": { "state": "translated", - "value": "خيار غير معروف %@. الاستخدام: cmux hooks setup [agent] [--agent ] [--yes|-y]" + "value": "اضغط المفاتيح لمعرفة الاختصار الذي يستخدمها" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pritisnite tipke da vidite koja ih prečica koristi" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tryk på taster for at se, hvilken genvej der bruger dem" } }, "de": { "stringUnit": { "state": "translated", - "value": "Unbekannte Option %@. Verwendung: cmux hooks setup [agent] [--agent ] [--yes|-y]" + "value": "Tasten drücken, um zu sehen, welches Tastenkürzel sie verwendet" } }, "es": { "stringUnit": { "state": "translated", - "value": "Opción desconocida %@. Uso: cmux hooks setup [agent] [--agent ] [--yes|-y]" + "value": "Pulsa las teclas para ver qué atajo las usa" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Option inconnue %@. Utilisation : cmux hooks setup [agent] [--agent ] [--yes|-y]" + "value": "Appuyez sur des touches pour voir quel raccourci les utilise" } }, - "ja": { + "it": { "stringUnit": { "state": "translated", - "value": "不明なオプション %@。使い方: cmux hooks setup [agent] [--agent ] [--yes|-y]" + "value": "Premi i tasti per vedere quale scorciatoia li usa" } }, - "ko": { + "ja": { "stringUnit": { "state": "translated", - "value": "알 수 없는 옵션 %@. 사용법: cmux hooks setup [agent] [--agent ] [--yes|-y]" + "value": "キーを押すと、そのキーを使うショートカットを表示します" } }, - "zh-Hans": { + "km": { "stringUnit": { "state": "translated", - "value": "未知选项 %@。用法:cmux hooks setup [agent] [--agent ] [--yes|-y]" + "value": "ចុចគ្រាប់ចុចដើម្បីមើលថាផ្លូវកាត់ណាប្រើវា" } }, - "zh-Hant": { + "ko": { "stringUnit": { "state": "translated", - "value": "未知的選項 %@。用法:cmux hooks setup [agent] [--agent ] [--yes|-y]" + "value": "키를 눌러 해당 키를 사용하는 단축키를 확인하세요" } - } - } - }, - "cli.hooks.setup.error.conflictingAgent": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "nb": { "stringUnit": { "state": "translated", - "value": "--agent was given more than once with different values. Specify one agent." + "value": "Trykk på taster for å se hvilken snarvei som bruker dem" } }, - "ar": { + "pl": { "stringUnit": { "state": "translated", - "value": "تم تمرير --agent أكثر من مرة بقيم مختلفة. حدد وكيلًا واحدًا." + "value": "Naciśnij klawisze, aby zobaczyć, który skrót ich używa" } }, - "de": { + "pt-BR": { "stringUnit": { "state": "translated", - "value": "--agent wurde mehrmals mit unterschiedlichen Werten angegeben. Gib einen Agenten an." + "value": "Pressione as teclas para ver qual atalho as usa" } }, - "es": { + "ru": { "stringUnit": { "state": "translated", - "value": "--agent se indicó más de una vez con valores distintos. Indica un solo agente." + "value": "Нажмите клавиши, чтобы увидеть, какое сочетание их использует" } }, - "fr": { + "th": { "stringUnit": { "state": "translated", - "value": "--agent a été indiqué plusieurs fois avec des valeurs différentes. Indiquez un seul agent." + "value": "กดปุ่มเพื่อดูว่าคีย์ลัดใดใช้ปุ่มเหล่านั้น" } }, - "ja": { + "tr": { "stringUnit": { "state": "translated", - "value": "--agent が異なる値で複数回指定されました。エージェントを 1 つだけ指定してください。" + "value": "Hangi kısayolun bu tuşları kullandığını görmek için tuşlara basın" } }, - "ko": { + "uk": { "stringUnit": { "state": "translated", - "value": "--agent가 서로 다른 값으로 여러 번 지정되었습니다. 에이전트를 하나만 지정하세요." + "value": "Натисніть клавіші, щоб побачити, яке сполучення їх використовує" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "--agent 被多次指定且值不同。请只指定一个代理。" + "value": "按下按键以查看使用它们的快捷键" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "--agent 被多次指定且值不同。請只指定一個代理程式。" + "value": "按下按鍵以查看使用它們的快速鍵" } } } }, - "settings.search.alias.setting.terminal.copy-confirmation": { + "settings.shortcuts.detector.idle": { "extractionState": "manual", "localizations": { - "ar": { + "en": { "stringUnit": { "state": "translated", - "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse تأكيد نسخ حافظة تحديد" + "value": "Detect Shortcut" } }, - "bs": { + "ar": { "stringUnit": { "state": "translated", - "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse potvrda kopirano međuspremnik odabir" + "value": "اكتشاف اختصار" } }, - "da": { + "bs": { "stringUnit": { "state": "translated", - "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse bekræftelse kopieret udklipsholder markering" + "value": "Otkrij prečicu" } }, - "de": { + "da": { "stringUnit": { "state": "translated", - "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse Bestätigung kopiert Zwischenablage Auswahl" + "value": "Registrér genvej" } }, - "en": { + "de": { "stringUnit": { "state": "translated", - "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse" + "value": "Tastenkürzel erkennen" } }, "es": { "stringUnit": { "state": "translated", - "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse confirmación copiado portapapeles selección" + "value": "Detectar atajo" } }, "fr": { "stringUnit": { "state": "translated", - "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse confirmation copié presse-papiers sélection" + "value": "Détecter un raccourci" } }, "it": { "stringUnit": { "state": "translated", - "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse conferma copiato appunti selezione" + "value": "Rileva scorciatoia" } }, "ja": { "stringUnit": { "state": "translated", - "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse 確認 コピー クリップボード 選択" + "value": "ショートカットを検出" } }, "km": { "stringUnit": { "state": "translated", - "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse បញ្ជាក់ ចម្លង ក្ដារតម្រៀប ជ្រើស" + "value": "រកឃើញផ្លូវកាត់" } }, "ko": { "stringUnit": { "state": "translated", - "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse 확인 복사됨 클립보드 선택" + "value": "단축키 감지" } }, "nb": { "stringUnit": { "state": "translated", - "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse bekreftelse kopiert utklippstavle markering" + "value": "Oppdag snarvei" } }, "pl": { "stringUnit": { "state": "translated", - "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse potwierdzenie skopiowano schowek zaznaczenie" + "value": "Wykryj skrót" } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse confirmação copiado área de transferência seleção" + "value": "Detectar atalho" } }, "ru": { "stringUnit": { "state": "translated", - "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse подтверждение скопировано буфер обмена выделение" + "value": "Определить сочетание" } }, "th": { "stringUnit": { "state": "translated", - "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse ยืนยัน คัดลอก คลิปบอร์ด เลือก" + "value": "ตรวจจับคีย์ลัด" } }, "tr": { "stringUnit": { "state": "translated", - "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse onay kopyalandı pano seçim" + "value": "Kısayolu algıla" } }, "uk": { "stringUnit": { "state": "translated", - "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse підтвердження скопійовано буфер обміну виділення" + "value": "Визначити сполучення" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse 确认 已复制 剪贴板 选择" + "value": "检测快捷键" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "terminal.showCopyConfirmation copy confirmation copied clipboard feedback indicator toast popup copy on selection select mouse 確認 已複製 剪貼簿 選取" + "value": "偵測快速鍵" } } } }, - "settings.terminal.showCopyConfirmation": { + "settings.shortcuts.detector.prompt": { "extractionState": "manual", "localizations": { - "ar": { + "en": { "stringUnit": { "state": "translated", - "value": "إظهار تأكيد النسخ" + "value": "Listening…" } }, - "bs": { + "ar": { "stringUnit": { "state": "translated", - "value": "Prikaži potvrdu kopiranja" + "value": "جارٍ الاستماع…" } }, - "da": { + "bs": { "stringUnit": { "state": "translated", - "value": "Vis kopieringsbekræftelse" + "value": "Slušam…" } }, - "de": { + "da": { "stringUnit": { "state": "translated", - "value": "Kopierbestätigung anzeigen" + "value": "Lytter…" } }, - "en": { + "de": { "stringUnit": { "state": "translated", - "value": "Show Copy Confirmation" + "value": "Warte auf Tasten …" } }, "es": { "stringUnit": { "state": "translated", - "value": "Mostrar confirmación de copia" + "value": "Escuchando…" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Afficher la confirmation de copie" + "value": "En écoute…" } }, "it": { "stringUnit": { "state": "translated", - "value": "Mostra conferma di copia" + "value": "In ascolto…" } }, "ja": { "stringUnit": { "state": "translated", - "value": "コピーの確認を表示" + "value": "入力待ち…" } }, "km": { "stringUnit": { "state": "translated", - "value": "បង្ហាញការបញ្ជាក់ការចម្លង" + "value": "កំពុងស្តាប់…" } }, "ko": { "stringUnit": { "state": "translated", - "value": "복사 확인 표시" + "value": "입력 대기 중…" } }, "nb": { "stringUnit": { "state": "translated", - "value": "Vis kopieringsbekreftelse" + "value": "Lytter…" } }, "pl": { "stringUnit": { "state": "translated", - "value": "Pokazuj potwierdzenie kopiowania" + "value": "Nasłuchiwanie…" } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Mostrar confirmação de cópia" + "value": "Ouvindo…" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Показывать подтверждение копирования" + "value": "Ожидание нажатия…" } }, "th": { "stringUnit": { "state": "translated", - "value": "แสดงการยืนยันการคัดลอก" + "value": "กำลังรอการกด…" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Kopyalama onayını göster" + "value": "Dinleniyor…" } }, "uk": { "stringUnit": { "state": "translated", - "value": "Показувати підтвердження копіювання" + "value": "Очікування натискання…" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "显示复制确认" + "value": "正在监听…" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "顯示複製確認" + "value": "正在聆聽…" } } } }, - "settings.terminal.showCopyConfirmation.subtitle": { + "settings.shortcuts.search.clear": { "extractionState": "manual", "localizations": { - "ar": { + "en": { "stringUnit": { "state": "translated", - "value": "يعرض «تم النسخ إلى الحافظة» لفترة وجيزة أسفل الطرفية عندما يؤدي تحديد النص إلى نسخه." + "value": "Clear search" } }, - "bs": { + "ar": { "stringUnit": { "state": "translated", - "value": "Kratko prikazuje „Kopirano u međuspremnik“ na dnu terminala kada se odabrani tekst kopira." + "value": "مسح البحث" } }, - "da": { + "bs": { "stringUnit": { "state": "translated", - "value": "Viser kortvarigt „Kopieret til udklipsholder“ nederst i en terminal, når markering af tekst kopierer den." + "value": "Obriši pretragu" } }, - "de": { + "da": { "stringUnit": { "state": "translated", - "value": "Zeigt unten im Terminal kurz „In die Zwischenablage kopiert“ an, wenn durch Auswählen von Text kopiert wird." + "value": "Ryd søgning" } }, - "en": { + "de": { "stringUnit": { "state": "translated", - "value": "Briefly shows “Copied to clipboard” at the bottom of a terminal when selecting text copies it." + "value": "Suche löschen" } }, "es": { "stringUnit": { "state": "translated", - "value": "Muestra brevemente «Copiado al portapapeles» en la parte inferior de una terminal cuando al seleccionar texto se copia." + "value": "Limpiar búsqueda" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Affiche brièvement « Copié dans le presse-papiers » en bas d’un terminal lorsque la sélection de texte le copie." + "value": "Effacer la recherche" } }, "it": { "stringUnit": { "state": "translated", - "value": "Mostra brevemente «Copiato negli appunti» in fondo a un terminale quando la selezione del testo lo copia." + "value": "Cancella ricerca" } }, "ja": { "stringUnit": { "state": "translated", - "value": "テキストを選択してコピーしたときに、ターミナルの下部に「クリップボードにコピーしました」を短く表示します。" + "value": "検索をクリア" } }, "km": { "stringUnit": { "state": "translated", - "value": "បង្ហាញ «បានចម្លងទៅក្ដារតម្រៀប» មួយភ្លែតនៅខាងក្រោម terminal នៅពេលការជ្រើសអត្ថបទចម្លងវា។" + "value": "សម្អាតការស្វែងរក" } }, "ko": { "stringUnit": { "state": "translated", - "value": "텍스트를 선택해 복사하면 터미널 하단에 “클립보드에 복사됨”을 잠시 표시합니다." + "value": "검색 초기화" } }, "nb": { "stringUnit": { "state": "translated", - "value": "Viser kort «Kopiert til utklippstavlen» nederst i en terminal når markering av tekst kopierer den." + "value": "Tøm søk" } }, "pl": { "stringUnit": { "state": "translated", - "value": "Na chwilę wyświetla „Skopiowano do schowka” u dołu terminala, gdy zaznaczenie tekstu go kopiuje." + "value": "Wyczyść wyszukiwanie" } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Mostra brevemente “Copiado para a área de transferência” na parte inferior do terminal quando selecionar texto o copia." + "value": "Limpar pesquisa" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Ненадолго показывает «Скопировано в буфер обмена» внизу терминала, когда выделение текста копирует его." + "value": "Очистить поиск" } }, "th": { "stringUnit": { "state": "translated", - "value": "แสดง “คัดลอกไปยังคลิปบอร์ดแล้ว” ชั่วครู่ที่ด้านล่างของเทอร์มินัลเมื่อการเลือกข้อความคัดลอกข้อความนั้น" + "value": "ล้างการค้นหา" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Metin seçimi onu kopyaladığında terminalin altında kısa süreliğine “Panoya kopyalandı” gösterir." + "value": "Aramayı temizle" } }, "uk": { "stringUnit": { "state": "translated", - "value": "Ненадовго показує «Скопійовано в буфер обміну» внизу термінала, коли виділення тексту копіює його." + "value": "Очистити пошук" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "选择文本并复制时,在终端底部短暂显示“已复制到剪贴板”。" + "value": "清除搜索" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "選取文字並複製時,在終端機底部短暫顯示「已複製到剪貼簿」。" + "value": "清除搜尋" } } } }, - "terminal.copyConfirmation.title": { + "settings.shortcuts.search.noResults": { "extractionState": "manual", "localizations": { - "ar": { + "en": { "stringUnit": { "state": "translated", - "value": "تم النسخ إلى الحافظة" + "value": "No shortcuts match" } }, - "bs": { + "ar": { "stringUnit": { "state": "translated", - "value": "Kopirano u međuspremnik" + "value": "لا توجد اختصارات مطابقة" } }, - "da": { + "bs": { "stringUnit": { "state": "translated", - "value": "Kopieret til udklipsholder" + "value": "Nema odgovarajućih prečica" } }, - "de": { + "da": { "stringUnit": { "state": "translated", - "value": "In die Zwischenablage kopiert" + "value": "Ingen matchende genveje" } }, - "en": { + "de": { "stringUnit": { "state": "translated", - "value": "Copied to clipboard" + "value": "Keine passenden Tastenkürzel" } }, "es": { "stringUnit": { "state": "translated", - "value": "Copiado al portapapeles" + "value": "No hay atajos coincidentes" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Copié dans le presse-papiers" + "value": "Aucun raccourci correspondant" } }, "it": { "stringUnit": { "state": "translated", - "value": "Copiato negli appunti" + "value": "Nessuna scorciatoia corrispondente" } }, "ja": { "stringUnit": { "state": "translated", - "value": "クリップボードにコピーしました" + "value": "一致するショートカットはありません" } }, "km": { "stringUnit": { "state": "translated", - "value": "បានចម្លងទៅក្ដារតម្រៀប" + "value": "មិនមានផ្លូវកាត់ដែលត្រូវគ្នាទេ" } }, "ko": { "stringUnit": { "state": "translated", - "value": "클립보드에 복사됨" + "value": "일치하는 단축키가 없습니다" } }, "nb": { "stringUnit": { "state": "translated", - "value": "Kopiert til utklippstavlen" + "value": "Ingen samsvarende snarveier" } }, "pl": { "stringUnit": { "state": "translated", - "value": "Skopiowano do schowka" + "value": "Brak pasujących skrótów" } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Copiado para a área de transferência" + "value": "Nenhum atalho correspondente" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Скопировано в буфер обмена" + "value": "Нет подходящих сочетаний клавиш" } }, "th": { "stringUnit": { "state": "translated", - "value": "คัดลอกไปยังคลิปบอร์ดแล้ว" + "value": "ไม่มีคีย์ลัดที่ตรงกัน" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Panoya kopyalandı" + "value": "Eşleşen kısayol yok" } }, "uk": { "stringUnit": { "state": "translated", - "value": "Скопійовано в буфер обміну" + "value": "Немає відповідних сполучень клавіш" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "已复制到剪贴板" + "value": "没有匹配的快捷键" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "已複製到剪貼簿" + "value": "沒有符合的快速鍵" } } } }, - "settings.shortcuts.detector.accessibilityLabel": { + "settings.shortcuts.search.placeholder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Find shortcut by keys" + "value": "Search shortcuts" } }, "ar": { "stringUnit": { "state": "translated", - "value": "البحث عن اختصار بالمفاتيح" + "value": "البحث في الاختصارات" } }, "bs": { "stringUnit": { "state": "translated", - "value": "Pronađi prečicu po tipkama" + "value": "Pretraži prečice" } }, "da": { "stringUnit": { "state": "translated", - "value": "Find genvej ud fra taster" + "value": "Søg i genveje" } }, "de": { "stringUnit": { "state": "translated", - "value": "Tastenkürzel anhand der Tasten finden" + "value": "Tastenkürzel suchen" } }, "es": { "stringUnit": { "state": "translated", - "value": "Buscar atajo por teclas" + "value": "Buscar atajos" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Trouver un raccourci par ses touches" + "value": "Rechercher des raccourcis" } }, "it": { "stringUnit": { "state": "translated", - "value": "Trova scorciatoia dai tasti" + "value": "Cerca scorciatoie" } }, "ja": { "stringUnit": { "state": "translated", - "value": "キーでショートカットを探す" + "value": "ショートカットを検索" } }, "km": { "stringUnit": { "state": "translated", - "value": "ស្វែងរកផ្លូវកាត់តាមគ្រាប់ចុច" + "value": "ស្វែងរកផ្លូវកាត់" } }, "ko": { "stringUnit": { "state": "translated", - "value": "키로 단축키 찾기" + "value": "단축키 검색" } }, "nb": { "stringUnit": { "state": "translated", - "value": "Finn snarvei etter taster" + "value": "Søk i snarveier" } }, "pl": { "stringUnit": { "state": "translated", - "value": "Znajdź skrót po klawiszach" + "value": "Szukaj skrótów" } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Encontrar atalho pelas teclas" + "value": "Pesquisar atalhos" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Найти сочетание по клавишам" + "value": "Поиск сочетаний клавиш" } }, "th": { "stringUnit": { "state": "translated", - "value": "ค้นหาคีย์ลัดจากปุ่ม" + "value": "ค้นหาคีย์ลัด" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Kısayolu tuşlarla bul" + "value": "Kısayollarda ara" } }, "uk": { "stringUnit": { "state": "translated", - "value": "Знайти сполучення за клавішами" + "value": "Пошук сполучень клавіш" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "按按键查找快捷键" + "value": "搜索快捷键" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "依按鍵尋找快速鍵" + "value": "搜尋快速鍵" } } } }, - "settings.shortcuts.detector.help": { + "cli.browser.help.replDescription": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Press keys to see which shortcut uses them" + "value": "Run Playwright-style JavaScript against this workspace's browser panes; see `browser repl guide`" } }, "ar": { "stringUnit": { "state": "translated", - "value": "اضغط المفاتيح لمعرفة الاختصار الذي يستخدمها" + "value": "تشغيل JavaScript بأسلوب Playwright على أجزاء المتصفح في مساحة العمل هذه؛ راجع `browser repl guide`" } }, - "bs": { + "de": { "stringUnit": { "state": "translated", - "value": "Pritisnite tipke da vidite koja ih prečica koristi" + "value": "Playwright-artiges JavaScript für die Browser-Bereiche dieses Arbeitsbereichs ausführen; siehe `browser repl guide`" } }, - "da": { + "es": { "stringUnit": { "state": "translated", - "value": "Tryk på taster for at se, hvilken genvej der bruger dem" + "value": "Ejecuta JavaScript al estilo de Playwright en los paneles de navegador de este espacio de trabajo; consulta `browser repl guide`" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Exécuter du JavaScript de style Playwright sur les volets de navigateur de cet espace de travail ; voir `browser repl guide`" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このワークスペースのブラウザペインに対して Playwright 形式の JavaScript を実行します。`browser repl guide` を参照してください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 워크스페이스의 브라우저 패널에서 Playwright 스타일 JavaScript를 실행합니다. `browser repl guide`를 참고하세요" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "对此工作区的浏览器窗格运行 Playwright 风格的 JavaScript;请参阅 `browser repl guide`" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "對此工作區的瀏覽器窗格執行 Playwright 風格的 JavaScript;請參閱 `browser repl guide`" + } + } + } + }, + "cli.browser.help.replMCPDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Serve the REPL as an MCP server on stdio (tools: eval, snapshot, screenshot, tabs, reset; its own session unless --session names one)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تشغيل REPL كخادم MCP عبر stdio (الأدوات: eval وsnapshot وscreenshot وtabs وreset؛ جلسة خاصة به ما لم يُحدِّد --session جلسة)" } }, "de": { "stringUnit": { "state": "translated", - "value": "Tasten drücken, um zu sehen, welches Tastenkürzel sie verwendet" + "value": "Den REPL als MCP-Server über stdio bereitstellen (Werkzeuge: eval, snapshot, screenshot, tabs, reset; eigene Sitzung, sofern --session keine benennt)" } }, "es": { "stringUnit": { "state": "translated", - "value": "Pulsa las teclas para ver qué atajo las usa" + "value": "Sirve el REPL como servidor MCP por stdio (herramientas: eval, snapshot, screenshot, tabs, reset; una sesión propia salvo que --session indique una)" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Appuyez sur des touches pour voir quel raccourci les utilise" + "value": "Exposer le REPL comme serveur MCP sur stdio (outils : eval, snapshot, screenshot, tabs, reset ; sa propre session sauf si --session en nomme une)" } }, - "it": { + "ja": { "stringUnit": { "state": "translated", - "value": "Premi i tasti per vedere quale scorciatoia li usa" + "value": "REPL を stdio 上の MCP サーバーとして提供します(ツール: eval、snapshot、screenshot、tabs、reset。--session で指定しない限りサーバー専用のセッション)" } }, - "ja": { + "ko": { "stringUnit": { "state": "translated", - "value": "キーを押すと、そのキーを使うショートカットを表示します" + "value": "REPL을 stdio의 MCP 서버로 제공합니다(도구: eval, snapshot, screenshot, tabs, reset, --session으로 지정하지 않으면 서버 전용 세션)" } }, - "km": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "ចុចគ្រាប់ចុចដើម្បីមើលថាផ្លូវកាត់ណាប្រើវា" + "value": "将 REPL 作为 stdio 上的 MCP 服务器提供(工具:eval、snapshot、screenshot、tabs、reset;未用 --session 指定时使用专属会话)" } }, - "ko": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "키를 눌러 해당 키를 사용하는 단축키를 확인하세요" + "value": "將 REPL 作為 stdio 上的 MCP 伺服器提供(工具:eval、snapshot、screenshot、tabs、reset;未用 --session 指定時使用專屬工作階段)" } - }, - "nb": { + } + } + }, + "cli.browser.repl.error.inputEncoding": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Trykk på taster for å se hvilken snarvei som bruker dem" + "value": "REPL input is not valid UTF-8" } }, - "pl": { + "ar": { "stringUnit": { "state": "translated", - "value": "Naciśnij klawisze, aby zobaczyć, który skrót ich używa" + "value": "مدخلات REPL ليست UTF-8 صالحة" } }, - "pt-BR": { + "de": { "stringUnit": { "state": "translated", - "value": "Pressione as teclas para ver qual atalho as usa" + "value": "Die REPL-Eingabe ist kein gültiges UTF-8" } }, - "ru": { + "es": { "stringUnit": { "state": "translated", - "value": "Нажмите клавиши, чтобы увидеть, какое сочетание их использует" + "value": "La entrada del REPL no es UTF-8 válido" } }, - "th": { + "fr": { "stringUnit": { "state": "translated", - "value": "กดปุ่มเพื่อดูว่าคีย์ลัดใดใช้ปุ่มเหล่านั้น" + "value": "L’entrée du REPL n’est pas de l’UTF-8 valide" } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "Hangi kısayolun bu tuşları kullandığını görmek için tuşlara basın" + "value": "REPL の入力が有効な UTF-8 ではありません" } }, - "uk": { + "ko": { "stringUnit": { "state": "translated", - "value": "Натисніть клавіші, щоб побачити, яке сполучення їх використовує" + "value": "REPL 입력이 올바른 UTF-8이 아닙니다" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "按下按键以查看使用它们的快捷键" + "value": "REPL 输入不是有效的 UTF-8" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "按下按鍵以查看使用它們的快速鍵" + "value": "REPL 輸入不是有效的 UTF-8" } } } }, - "settings.shortcuts.detector.idle": { + "cli.browser.repl.error.inputTooLarge": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Detect Shortcut" + "value": "REPL input is too large" } }, "ar": { "stringUnit": { "state": "translated", - "value": "اكتشاف اختصار" + "value": "مدخلات REPL كبيرة جدًا" } }, - "bs": { + "de": { "stringUnit": { "state": "translated", - "value": "Otkrij prečicu" + "value": "Die REPL-Eingabe ist zu groß" } }, - "da": { + "es": { "stringUnit": { "state": "translated", - "value": "Registrér genvej" + "value": "La entrada del REPL es demasiado grande" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L’entrée du REPL est trop volumineuse" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "REPL の入力が大きすぎます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "REPL 입력이 너무 큽니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "REPL 输入过大" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "REPL 輸入過大" + } + } + } + }, + "cli.browser.repl.error.sessionRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A session name is required" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اسم الجلسة مطلوب" } }, "de": { "stringUnit": { "state": "translated", - "value": "Tastenkürzel erkennen" + "value": "Ein Sitzungsname ist erforderlich" } }, "es": { "stringUnit": { "state": "translated", - "value": "Detectar atajo" + "value": "Se requiere un nombre de sesión" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Détecter un raccourci" + "value": "Un nom de session est requis" } }, - "it": { + "ja": { "stringUnit": { "state": "translated", - "value": "Rileva scorciatoia" + "value": "セッション名が必要です" } }, - "ja": { + "ko": { "stringUnit": { "state": "translated", - "value": "ショートカットを検出" + "value": "세션 이름이 필요합니다" } }, - "km": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "រកឃើញផ្លូវកាត់" + "value": "需要会话名称" } }, - "ko": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "단축키 감지" + "value": "需要工作階段名稱" } - }, - "nb": { + } + } + }, + "cli.browser.repl.error.timeout": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Oppdag snarvei" + "value": "--timeout must be a positive number of milliseconds" } }, - "pl": { + "ar": { "stringUnit": { "state": "translated", - "value": "Wykryj skrót" + "value": "يجب أن تكون قيمة --timeout عددًا موجبًا من أجزاء الثانية" } }, - "pt-BR": { + "de": { "stringUnit": { "state": "translated", - "value": "Detectar atalho" + "value": "--timeout muss eine positive Anzahl von Millisekunden sein" } }, - "ru": { + "es": { "stringUnit": { "state": "translated", - "value": "Определить сочетание" + "value": "--timeout debe ser un número positivo de milisegundos" } }, - "th": { + "fr": { "stringUnit": { "state": "translated", - "value": "ตรวจจับคีย์ลัด" + "value": "--timeout doit être un nombre positif de millisecondes" } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "Kısayolu algıla" + "value": "--timeout には正のミリ秒数を指定してください" } }, - "uk": { + "ko": { "stringUnit": { "state": "translated", - "value": "Визначити сполучення" + "value": "--timeout은 양수 밀리초 값이어야 합니다" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "检测快捷键" + "value": "--timeout 必须是正数的毫秒数" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "偵測快速鍵" + "value": "--timeout 必須是正數的毫秒數" } } } }, - "settings.shortcuts.detector.prompt": { + "cli.browser.repl.error.codeRequired": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Listening…" + "value": "No code to evaluate" } }, "ar": { "stringUnit": { "state": "translated", - "value": "جارٍ الاستماع…" + "value": "لا توجد شيفرة لتقييمها" } }, - "bs": { + "de": { "stringUnit": { "state": "translated", - "value": "Slušam…" + "value": "Kein Code zum Auswerten" } }, - "da": { + "es": { "stringUnit": { "state": "translated", - "value": "Lytter…" + "value": "No hay código para evaluar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun code à évaluer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "評価するコードがありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "평가할 코드가 없습니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有要求值的代码" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有要評估的程式碼" + } + } + } + }, + "cli.browser.repl.error.workspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No workspace to bind the REPL session to" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد مساحة عمل لربط جلسة REPL بها" } }, "de": { "stringUnit": { "state": "translated", - "value": "Warte auf Tasten …" + "value": "Kein Arbeitsbereich, an den die REPL-Sitzung gebunden werden kann" } }, "es": { "stringUnit": { "state": "translated", - "value": "Escuchando…" + "value": "No hay ningún espacio de trabajo al que vincular la sesión REPL" } }, "fr": { "stringUnit": { "state": "translated", - "value": "En écoute…" + "value": "Aucun espace de travail auquel lier la session REPL" } }, - "it": { + "ja": { "stringUnit": { "state": "translated", - "value": "In ascolto…" + "value": "REPL セッションを関連付けるワークスペースがありません" } }, - "ja": { + "ko": { "stringUnit": { "state": "translated", - "value": "入力待ち…" + "value": "REPL 세션을 연결할 워크스페이스가 없습니다" } }, - "km": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "កំពុងស្តាប់…" + "value": "没有可绑定 REPL 会话的工作区" } }, - "ko": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "입력 대기 중…" + "value": "沒有可綁定 REPL 工作階段的工作區" } - }, - "nb": { + } + } + }, + "cli.browser.repl.error.unknownOption": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Lytter…" + "value": "browser repl does not support this option" } }, - "pl": { + "ar": { "stringUnit": { "state": "translated", - "value": "Nasłuchiwanie…" + "value": "لا يدعم browser repl هذا الخيار" } }, - "pt-BR": { + "de": { "stringUnit": { "state": "translated", - "value": "Ouvindo…" + "value": "browser repl unterstützt diese Option nicht" } }, - "ru": { + "es": { "stringUnit": { "state": "translated", - "value": "Ожидание нажатия…" + "value": "browser repl no admite esta opción" } }, - "th": { + "fr": { "stringUnit": { "state": "translated", - "value": "กำลังรอการกด…" + "value": "browser repl ne prend pas en charge cette option" } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "Dinleniyor…" + "value": "browser repl はこのオプションに対応していません" } }, - "uk": { + "ko": { "stringUnit": { "state": "translated", - "value": "Очікування натискання…" + "value": "browser repl은 이 옵션을 지원하지 않습니다" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "正在监听…" + "value": "browser repl 不支持此选项" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "正在聆聽…" + "value": "browser repl 不支援此選項" } } } }, - "settings.shortcuts.search.clear": { + "cli.browser.repl.error.workspaceNotFound": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Clear search" + "value": "Workspace not found" } }, "ar": { "stringUnit": { "state": "translated", - "value": "مسح البحث" + "value": "لم يتم العثور على مساحة العمل" } }, - "bs": { + "de": { "stringUnit": { "state": "translated", - "value": "Obriši pretragu" + "value": "Arbeitsbereich nicht gefunden" } }, - "da": { + "es": { "stringUnit": { "state": "translated", - "value": "Ryd søgning" + "value": "No se encontró el espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail introuvable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースが見つかりません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "워크스페이스를 찾을 수 없습니다" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找不到工作區" + } + } + } + }, + "browser.repl.sessionTabTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · %2$@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · %2$@" } }, "de": { "stringUnit": { "state": "translated", - "value": "Suche löschen" + "value": "%1$@ · %2$@" } }, "es": { "stringUnit": { "state": "translated", - "value": "Limpiar búsqueda" + "value": "%1$@ · %2$@" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Effacer la recherche" + "value": "%1$@ · %2$@" } }, - "it": { + "ja": { "stringUnit": { "state": "translated", - "value": "Cancella ricerca" + "value": "%1$@ · %2$@" } }, - "ja": { + "ko": { "stringUnit": { "state": "translated", - "value": "検索をクリア" + "value": "%1$@ · %2$@" } }, - "km": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "សម្អាតការស្វែងរក" + "value": "%1$@ · %2$@" } }, - "ko": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "검색 초기화" + "value": "%1$@ · %2$@" } - }, - "nb": { + } + } + }, + "cli.browser.repl.error.maxOutput": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Tøm søk" + "value": "--max-output must be a number of characters, or 0 for no limit" } }, - "pl": { + "ar": { "stringUnit": { "state": "translated", - "value": "Wyczyść wyszukiwanie" + "value": "يجب أن تكون قيمة --max-output عددًا من الأحرف، أو 0 لعدم وجود حد" } }, - "pt-BR": { + "de": { "stringUnit": { "state": "translated", - "value": "Limpar pesquisa" + "value": "--max-output muss eine Anzahl von Zeichen sein, oder 0 für keine Begrenzung" } }, - "ru": { + "es": { "stringUnit": { "state": "translated", - "value": "Очистить поиск" + "value": "--max-output debe ser un número de caracteres, o 0 para no tener límite" } }, - "th": { + "fr": { "stringUnit": { "state": "translated", - "value": "ล้างการค้นหา" + "value": "--max-output doit être un nombre de caractères, ou 0 pour aucune limite" } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "Aramayı temizle" + "value": "--max-output には文字数を指定してください(0 で無制限)" } }, - "uk": { + "ko": { "stringUnit": { "state": "translated", - "value": "Очистити пошук" + "value": "--max-output은 문자 수이거나, 제한이 없으면 0이어야 합니다" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "清除搜索" + "value": "--max-output 必须是字符数,或 0 表示不设限制" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "清除搜尋" + "value": "--max-output 必須是字元數,或 0 表示不設限制" } } } }, - "settings.shortcuts.search.noResults": { + "browser.repl.auth.cancel": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No shortcuts match" + "value": "Cancel" } }, "ar": { "stringUnit": { "state": "translated", - "value": "لا توجد اختصارات مطابقة" + "value": "إلغاء" } }, - "bs": { + "de": { "stringUnit": { "state": "translated", - "value": "Nema odgovarajućih prečica" + "value": "Abbrechen" } }, - "da": { + "es": { "stringUnit": { "state": "translated", - "value": "Ingen matchende genveje" + "value": "Cancelar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Annuler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "취소" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + } + } + }, + "browser.repl.auth.fill": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fill" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعبئة" } }, "de": { "stringUnit": { "state": "translated", - "value": "Keine passenden Tastenkürzel" + "value": "Ausfüllen" } }, "es": { "stringUnit": { "state": "translated", - "value": "No hay atajos coincidentes" + "value": "Rellenar" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Aucun raccourci correspondant" + "value": "Remplir" } }, - "it": { + "ja": { "stringUnit": { "state": "translated", - "value": "Nessuna scorciatoia corrispondente" + "value": "入力" } }, - "ja": { + "ko": { "stringUnit": { "state": "translated", - "value": "一致するショートカットはありません" + "value": "채우기" } }, - "km": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "មិនមានផ្លូវកាត់ដែលត្រូវគ្នាទេ" + "value": "填入" } }, - "ko": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "일치하는 단축키가 없습니다" + "value": "填入" } - }, - "nb": { + } + } + }, + "browser.repl.auth.embedded": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Ingen samsvarende snarveier" + "value": "This form is in a frame from %1$@, inside a page from %2$@." } }, - "pl": { + "ar": { "stringUnit": { "state": "translated", - "value": "Brak pasujących skrótów" + "value": "هذا النموذج داخل إطار من %1$@، ضمن صفحة من %2$@." } }, - "pt-BR": { + "de": { "stringUnit": { "state": "translated", - "value": "Nenhum atalho correspondente" + "value": "Dieses Formular ist in einem Frame von %1$@, innerhalb einer Seite von %2$@." } }, - "ru": { + "es": { "stringUnit": { "state": "translated", - "value": "Нет подходящих сочетаний клавиш" + "value": "Este formulario está en un marco de %1$@, dentro de una página de %2$@." } }, - "th": { + "fr": { "stringUnit": { "state": "translated", - "value": "ไม่มีคีย์ลัดที่ตรงกัน" + "value": "Ce formulaire se trouve dans un cadre de %1$@, à l’intérieur d’une page de %2$@." } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "Eşleşen kısayol yok" + "value": "このフォームは %2$@ のページ内にある %1$@ のフレームにあります。" } }, - "uk": { + "ko": { "stringUnit": { "state": "translated", - "value": "Немає відповідних сполучень клавіш" + "value": "이 양식은 %2$@ 페이지 안에 있는 %1$@ 프레임에 있습니다." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "没有匹配的快捷键" + "value": "此表单位于来自 %2$@ 的网页中、来自 %1$@ 的框架内。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "沒有符合的快速鍵" + "value": "此表單位於來自 %2$@ 的網頁中、來自 %1$@ 的框架內。" } } } }, - "settings.shortcuts.search.placeholder": { + "browser.repl.auth.notice": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Search shortcuts" + "value": "An agent asked cmux to fill this sign-in form. The agent does not receive what you type, but scripts on the page, and code the agent runs in the page, can read the filled fields." } }, "ar": { "stringUnit": { "state": "translated", - "value": "البحث في الاختصارات" + "value": "طلب وكيل من cmux تعبئة نموذج تسجيل الدخول هذا. لا يتلقى الوكيل ما تكتبه، لكن البرامج النصية في الصفحة، والتعليمات البرمجية التي يشغلها الوكيل في الصفحة، يمكنها قراءة الحقول المعبأة." } }, - "bs": { + "de": { "stringUnit": { "state": "translated", - "value": "Pretraži prečice" + "value": "Ein Agent hat cmux gebeten, dieses Anmeldeformular auszufüllen. Der Agent erhält nicht, was du eingibst, aber Skripte der Seite und Code, den der Agent in der Seite ausführt, können die ausgefüllten Felder lesen." } }, - "da": { + "es": { "stringUnit": { "state": "translated", - "value": "Søg i genveje" + "value": "Un agente pidió a cmux que rellene este formulario de inicio de sesión. El agente no recibe lo que escribes, pero los scripts de la página y el código que el agente ejecuta en la página pueden leer los campos rellenados." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Un agent a demandé à cmux de remplir ce formulaire de connexion. L’agent ne reçoit pas ce que vous saisissez, mais les scripts de la page, et le code que l’agent exécute dans la page, peuvent lire les champs remplis." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントが cmux にこのサインインフォームへの入力を依頼しました。入力した内容はエージェントには送られませんが、ページのスクリプトや、エージェントがページ内で実行するコードは入力済みのフィールドを読み取れます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에이전트가 cmux에 이 로그인 양식을 채우도록 요청했습니다. 에이전트는 입력한 내용을 받지 않지만, 페이지의 스크립트와 에이전트가 페이지에서 실행하는 코드는 채워진 필드를 읽을 수 있습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "有智能体请求 cmux 填写此登录表单。智能体不会收到你输入的内容,但网页上的脚本以及智能体在网页中运行的代码可以读取已填写的字段。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "有代理程式要求 cmux 填寫此登入表單。代理程式不會收到你輸入的內容,但網頁上的指令碼,以及代理程式在網頁中執行的程式碼,可以讀取已填寫的欄位。" + } + } + } + }, + "browser.repl.auth.requester": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Asked by an agent working in the tab “%1$@” of the workspace “%2$@”." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "طلبه وكيل يعمل في علامة التبويب ”%1$@“ في مساحة العمل ”%2$@“." } }, "de": { "stringUnit": { "state": "translated", - "value": "Tastenkürzel suchen" + "value": "Angefragt von einem Agenten im Tab „%1$@“ des Arbeitsbereichs „%2$@“." } }, "es": { "stringUnit": { "state": "translated", - "value": "Buscar atajos" + "value": "Lo pide un agente que trabaja en la pestaña «%1$@» del espacio de trabajo «%2$@»." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Rechercher des raccourcis" + "value": "Demandé par un agent qui travaille dans l’onglet « %1$@ » de l’espace de travail « %2$@ »." } }, - "it": { + "ja": { "stringUnit": { "state": "translated", - "value": "Cerca scorciatoie" + "value": "ワークスペース「%2$@」のタブ「%1$@」で作業中のエージェントからの要求です。" } }, - "ja": { + "ko": { "stringUnit": { "state": "translated", - "value": "ショートカットを検索" + "value": "작업 공간 “%2$@”의 탭 “%1$@”에서 작업 중인 에이전트가 요청했습니다." } }, - "km": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "ស្វែងរកផ្លូវកាត់" + "value": "由在工作区“%2$@”的标签页“%1$@”中工作的代理请求。" } }, - "ko": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "단축키 검색" + "value": "由在工作區「%2$@」的分頁「%1$@」中工作的代理請求。" } - }, - "nb": { + } + } + }, + "browser.repl.auth.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Søk i snarveier" + "value": "Sign in to %@" } }, - "pl": { + "ar": { "stringUnit": { "state": "translated", - "value": "Szukaj skrótów" + "value": "تسجيل الدخول إلى %@" } }, - "pt-BR": { + "de": { "stringUnit": { "state": "translated", - "value": "Pesquisar atalhos" + "value": "Bei %@ anmelden" } }, - "ru": { + "es": { "stringUnit": { "state": "translated", - "value": "Поиск сочетаний клавиш" + "value": "Iniciar sesión en %@" } }, - "th": { + "fr": { "stringUnit": { "state": "translated", - "value": "ค้นหาคีย์ลัด" + "value": "Se connecter à %@" } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "Kısayollarda ara" + "value": "%@ にサインイン" } }, - "uk": { + "ko": { "stringUnit": { "state": "translated", - "value": "Пошук сполучень клавіш" + "value": "%@에 로그인" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "搜索快捷键" + "value": "登录 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "搜尋快速鍵" + "value": "登入 %@" } } } diff --git a/Resources/bin/cmux-codex-wrapper b/Resources/bin/cmux-codex-wrapper index 7ec49ac2db32..71c1645430d4 100755 --- a/Resources/bin/cmux-codex-wrapper +++ b/Resources/bin/cmux-codex-wrapper @@ -419,12 +419,13 @@ cmux_codex_emit_computer_use_args() { # reconciled above. The MCP attachment remains fail-closed on cmux # ownership, daemon credentials, and the installed approval broker. cmux_computer_use_surface_socket_available || return 0 - local client default_session runtime_scope runtime_uid cua_socket state_dir auth_token + local client default_session runtime_scope runtime_uid cua_socket state_dir # Read the uid in this shell first: the helpers below run in command # substitutions, so a uid they cache would not survive back here. cmux_codex_wrapper_effective_uid || return 1 client="$(cmux_computer_use_resolve_client)" || return 1 - auth_token="$(cmux_computer_use_auth_token)" || return 1 + # Gate on the credential here; the parent shell exports its value. + cmux_computer_use_auth_token >/dev/null || return 1 # Stable per-terminal session id so the branded cursor survives helper restarts. default_session="cmux-${CMUX_SURFACE_ID:-$$}" runtime_scope="${CMUX_CUA_RUNTIME_SCOPE:-${CMUX_TAG:-default}}" @@ -449,7 +450,7 @@ cmux_codex_emit_computer_use_args() { "-c" \ 'mcp_servers.cmux-cua.env.CMUX_CUA_EXTERNAL_PERMISSION_FLOW="1"' \ "-c" \ - "mcp_servers.cmux-cua.env.CMUX_CUA_SOCKET_AUTH_TOKEN=\"$(cmux_computer_use_json_escape "$auth_token")\"" \ + 'mcp_servers.cmux-cua.env_vars=["CMUX_CUA_SOCKET_AUTH_TOKEN"]' \ "-c" \ "mcp_servers.cmux-cua.env.CMUX_CUA_DEFAULT_SESSION=\"$(cmux_computer_use_json_escape "$default_session")\"" \ "-c" \ @@ -521,7 +522,7 @@ codex_option_consumes_value() { case "$1" in -c|--config|-m|--model|-p|--profile|-C|--cd|--remote|-a|--ask-for-approval|\ -s|--sandbox|--output-last-message|--enable|--disable|--add-dir|-i|--image|\ - --local-provider|--remote-auth-token-env) + --local-provider|--remote-auth-token-env|--output-schema|--color) return 0 ;; esac @@ -580,6 +581,89 @@ should_inject_codex_hooks() { return 0 } +# Codex declares -c/--config, --enable, and --disable as clap `global` +# arguments. When a subcommand also receives one of them, Codex keeps only the +# subcommand-level values: on codex-cli 0.159.3, `codex -c a=1 mcp -c b=2 list` +# sees only b. The wrapper places its own overrides before the user's argv, so +# `codex exec -c model=x` or `codex resume --last -c ...` silently dropped the +# cmux-cua MCP server, the hook config, and `--disable computer_use`. A global +# argument means the same thing at either position, so move the user's +# subcommand-level occurrences, in order, in front of the subcommand. Codex then +# sees one root-level list: wrapper values, user root values, user subcommand +# values, with later values winning as before. Tokens after `--` are prompt +# text and stay in place. Without a subcommand nothing moves. +cmux_codex_session_args=() +cmux_codex_hoist_session_global_args() { + local -a head=() hoisted=() tail=() + local arg + cmux_codex_session_args=() + while (( $# > 0 )); do + arg="$1" + case "$arg" in + --) + head+=("$@") + break + ;; + -*) + head+=("$arg") + shift + if [[ "$arg" != *=* ]] && codex_option_consumes_value "$arg" && (( $# > 0 )); then + head+=("$1") + shift + fi + continue + ;; + esac + if ! codex_known_subcommand "$arg"; then + # First bare token is a prompt: an interactive session has no + # subcommand scope, so every option is already root-level. + head+=("$@") + break + fi + tail+=("$arg") + shift + while (( $# > 0 )); do + arg="$1" + case "$arg" in + --) + tail+=("$@") + break + ;; + -c|--config|--enable|--disable) + if (( $# >= 2 )); then + hoisted+=("$arg" "$2") + shift 2 + else + tail+=("$arg") + shift + fi + ;; + -c=*|--config=*|--enable=*|--disable=*|-c?*) + hoisted+=("$arg") + shift + ;; + -*) + tail+=("$arg") + shift + if [[ "$arg" != *=* ]] && codex_option_consumes_value "$arg" && (( $# > 0 )); then + tail+=("$1") + shift + fi + ;; + *) + tail+=("$arg") + shift + ;; + esac + done + break + done + (( ${#head[@]} > 0 )) && cmux_codex_session_args+=("${head[@]}") + (( ${#hoisted[@]} > 0 )) && cmux_codex_session_args+=("${hoisted[@]}") + (( ${#tail[@]} > 0 )) && cmux_codex_session_args+=("${tail[@]}") + return 0 +} + # `codex exec` runs headless. It shares the pane's surface with any # interactive Codex there, so the agent message hooks skip it. codex_launch_is_headless() { @@ -676,11 +760,24 @@ exec_real_codex_passthrough() { cmux_codex_computer_use_args=() cmux_codex_read_computer_use_args() { - local arg + local arg auth_token while IFS= read -r -d '' arg; do cmux_codex_computer_use_args+=("$arg") done < <(cmux_codex_emit_computer_use_args "$@" 2>/dev/null) (( ${#cmux_codex_computer_use_args[@]} > 0 )) || return 1 + # Codex's -c overrides are argv, and any local user can read argv with ps. + # Keep the socket credential in the Codex process environment instead; the + # emitted `env_vars` entry tells Codex to forward it by name to the MCP + # server. Codex's default shell_environment_policy excludes *TOKEN* names + # from agent shell commands. The emitter runs in a process substitution, so + # the export must happen here in the parent shell. Fail closed: without a + # credential, attach nothing. + if ! cmux_codex_wrapper_effective_uid || + ! auth_token="$(cmux_computer_use_auth_token)"; then + cmux_codex_computer_use_args=() + return 1 + fi + export CMUX_CUA_SOCKET_AUTH_TOKEN="$auth_token" return 0 } @@ -854,11 +951,13 @@ if [[ "${CMUX_CUA_DIAGNOSTICS:-0}" == "1" ]]; then fi fi +cmux_codex_hoist_session_global_args "$@" + if cmux_codex_read_inject_args; then exec "$REAL_CODEX" \ "${cmux_codex_native_computer_use_args[@]}" \ "${cmux_codex_computer_use_args[@]}" \ - "${cmux_codex_injected_args[@]}" "$@" + "${cmux_codex_injected_args[@]}" "${cmux_codex_session_args[@]}" fi # A reconciled persistent channel intentionally emits no overrides; an emitter @@ -867,4 +966,4 @@ fi # bind this session. exec "$REAL_CODEX" \ "${cmux_codex_native_computer_use_args[@]}" \ - "${cmux_codex_computer_use_args[@]}" "$@" + "${cmux_codex_computer_use_args[@]}" "${cmux_codex_session_args[@]}" diff --git a/Resources/browser-repl/agent-tools.js b/Resources/browser-repl/agent-tools.js new file mode 100644 index 000000000000..e2b59d94f587 --- /dev/null +++ b/Resources/browser-repl/agent-tools.js @@ -0,0 +1,1548 @@ +// Reference C parity for the cmux browser REPL +// (docs/browser-repl/reference-c-parity.md): what reference C's tools, +// browser profile and agent offer that a REPL-driving agent can use, in this +// REPL's Playwright-shaped API and without a model inside cmux. +// +// Globals: secret(name), secrets, search(query, options), tools. +// session: allowedDomains, prohibitedDomains, blockIPAddresses, +// blockedNavigations, storageState, setStorageState, downloads, record. +// Page: markdown, extract, searchText, scrollToText, scroll, scrollInfo, +// dropdownOptions, highlight, hideHighlight; locator.highlight. +// +// The runtime calls the hooks below through session.agentTools +// (runtime-core.js Session.call, its event router, Page._afterAction and +// Page._inputText; repl-host.js output and errors; api.js fetch). +(function (root) { + "use strict"; + const ns = (root.CmuxBrowserRepl = root.CmuxBrowserRepl || {}); + const core = ns.core; + const { Buffer } = core; + const AGENT = 'globalThis[Symbol.for("cmux.browserRepl.agent")]'; + + // --------------------------------------------------------------------------- + // Domain patterns, reference C's syntax (utils.match_url_with_domain_pattern + // and the security watchdog): "example.com" (and www.example.com), + // "*.example.com" (subdomains and the bare domain), "http*://example.com", + // "https://example.com", "*". A port in the pattern must match (reference C + // drops it). Multiple wildcards, wildcard TLDs and embedded wildcards are + // refused when set, where reference C logs and ignores them. + + // Host names as the policy compares them: lower case, no trailing dot, + // internationalized labels in Punycode, as the native session does + // (BrowserReplHostName). + function punycode(input) { + const cps = [...input].map((c) => c.codePointAt(0)); + let out = cps.filter((c) => c < 0x80).map((c) => String.fromCharCode(c)).join(""); + const basic = out.length; + let handled = basic; + if (basic) out += "-"; + let n = 128; + let delta = 0; + let bias = 72; + const digit = (d) => String.fromCharCode(d < 26 ? d + 97 : d + 22); + const adapt = (d, count, first) => { + d = first ? Math.floor(d / 700) : d >> 1; + d += Math.floor(d / count); + let k = 0; + while (d > 455) { + d = Math.floor(d / 35); + k += 36; + } + return k + Math.floor((36 * d) / (d + 38)); + }; + while (handled < cps.length) { + const m = Math.min(...cps.filter((c) => c >= n)); + delta += (m - n) * (handled + 1); + n = m; + for (const c of cps) { + if (c < n) delta++; + if (c === n) { + let q = delta; + for (let k = 36; ; k += 36) { + const t = k <= bias ? 1 : k >= bias + 26 ? 26 : k - bias; + if (q < t) break; + out += digit(t + ((q - t) % (36 - t))); + q = Math.floor((q - t) / (36 - t)); + } + out += digit(q); + bias = adapt(delta, handled + 1, handled === basic); + delta = 0; + handled++; + } + } + delta++; + n++; + } + return out; + } + function normalizeHost(raw) { + let host = String(raw || "").trim(); + if (host.includes(":") && !host.startsWith("[")) host = `[${host}]`; + if (host.startsWith("[")) return host.toLowerCase(); + host = host.replace(/\.+$/, ""); + return host + .split(".") + .map((label) => { + const l = label.normalize("NFC").toLowerCase(); + return /[^\x00-\x7f]/.test(l) ? "xn--" + punycode(l) : l; + }) + .join("."); + } + + function parsePattern(raw, title) { + if (typeof raw !== "string" || !raw.trim()) throw new Error(`${title}: expected domain patterns as non-empty strings, got ${JSON.stringify(raw)}`); + let p = raw.trim().toLowerCase(); + let scheme = null; + const m = /^([a-z*][a-z0-9+.*-]*):\/\/(.*)$/.exec(p); + if (m) { + scheme = m[1]; + p = m[2]; + } + p = p.replace(/\/.*$/, ""); + let port = null; + const pm = /^(.*):(\d+|\*)$/.exec(p); + if (pm && !p.startsWith("[")) { + p = pm[1]; + port = pm[2] === "*" ? null : pm[2]; + } + const host = p; + if (host !== "*") { + if ((host.match(/\*/g) || []).length > 1) throw new Error(`${title}: ${JSON.stringify(raw)}: only one wildcard is allowed`); + if (host.endsWith(".*")) throw new Error(`${title}: ${JSON.stringify(raw)}: wildcard top-level domains are not allowed`); + if (host.includes("*") && !host.startsWith("*.")) throw new Error(`${title}: ${JSON.stringify(raw)}: use *.example.com; other wildcards are not allowed`); + if (!host || /[\s/]/.test(host)) throw new Error(`${title}: ${JSON.stringify(raw)}: expected a domain`); + } + const normalized = host === "*" ? host : host.startsWith("*.") ? "*." + normalizeHost(host.slice(2)) : normalizeHost(host); + if (!normalized || normalized === "*.") throw new Error(`${title}: ${JSON.stringify(raw)}: expected a domain`); + return { raw, scheme, host: normalized, port }; + } + + // WebKit content-blocker rules for a domain policy. Content-blocker regular + // expressions have no alternation, so each pattern becomes its own rule. + // Documents in the main frame are left to the navigation checks, which + // report the block; iframes and every subresource are blocked here. + const SUBRESOURCES = ["image", "style-sheet", "script", "font", "raw", "svg-document", "media", "ping", "fetch", "websocket", "other"]; + const cbEscape = (s) => s.replace(/[.+?^${}()|[\]\\*]/g, "\\$&"); + function patternFilters(p) { + // Without a scheme a pattern covers http(s) and its WebSockets. + const schemes = p.scheme ? [p.scheme.split("").map((c) => (c === "*" ? "[a-z0-9+.-]*" : cbEscape(c))).join("")] : ["https?", "wss?"]; + return schemes.flatMap((scheme) => schemeFilters(p, scheme)); + } + function schemeFilters(p, scheme) { + let host; + if (p.host === "*") host = "[^/@:]+"; + else if (p.host.startsWith("*.")) host = "([^/@:]*\\.)?" + cbEscape(p.host.slice(2)) + "\\.?"; + else host = cbEscape(p.host) + "\\.?"; + const head = "^" + scheme + "://([^/@]*@)?" + host; + if (p.port === null) return [head + "(:[0-9]+)?/"]; + const out = [head + ":" + p.port + "/"]; + // A default port is not written in the URL. + if ((p.port === "443" && (!p.scheme || /^https/.test(p.scheme) || p.scheme === "*")) || (p.port === "80" && (!p.scheme || /^http/.test(p.scheme) || p.scheme === "*"))) out.push(head + "/"); + return out; + } + function policyContentRules(policy) { + const rules = []; + const triggers = (filter) => [ + { "url-filter": filter, "resource-type": SUBRESOURCES }, + { "url-filter": filter, "resource-type": ["document"], "load-context": ["child-frame"] }, + ]; + const add = (filter, type) => { + for (const trigger of triggers(filter)) rules.push({ trigger, action: { type } }); + }; + if (policy.allowed) { + add(".*", "block"); + for (const p of policy.allowed) for (const f of patternFilters(p)) add(f, "ignore-previous-rules"); + for (const scheme of ["data", "blob", "about"]) add("^" + scheme + ":", "ignore-previous-rules"); + } + for (const p of policy.prohibited) for (const f of patternFilters(p)) add(f, "block"); + if (policy.blockIPs) { + add("^[a-z][a-z0-9+.-]*://([^/@]*@)?[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+\\.?[:/]", "block"); + add("^[a-z][a-z0-9+.-]*://([^/@]*@)?\\[", "block"); + } + return rules; + } + + const globRe = (glob) => new RegExp("^" + glob.replace(/[.+^${}()|[\]\\?]/g, "\\$&").replace(/\*/g, ".*") + "$"); + const LOOPBACK = /^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/; + + // `secure`: a pattern without a scheme matches https only (and http on a + // loopback host), as reference C's secret matching does; otherwise it + // matches http and https, as its allowed_domains check does. + function urlMatches(url, pattern, secure) { + let u; + try { + u = new core.URL(url); + } catch { + return false; + } + const scheme = String(u.protocol || "").replace(/:$/, "").toLowerCase(); + const host = normalizeHost(u.hostname || ""); + if (!host) return false; + if (pattern.scheme) { + if (!globRe(pattern.scheme).test(scheme)) return false; + } else if (secure) { + if (!(scheme === "https" || (scheme === "http" && LOOPBACK.test(host)))) return false; + } else if (scheme !== "http" && scheme !== "https") return false; + if (pattern.port !== null) { + const port = u.port || (scheme === "https" ? "443" : scheme === "http" ? "80" : ""); + if (port !== pattern.port) return false; + } + const h = pattern.host; + if (h === "*") return true; + if (h.startsWith("*.")) { + const base = h.slice(2); + return host === base || host.endsWith("." + base); + } + if (host === h) return true; + // A root domain also covers www (the watchdog's www variant). + return h.split(".").length === 2 && host === "www." + h; + } + + // IPv4 in any form the URL parser accepts (it normalizes decimal, hex and + // octal to dotted form) and bracketed IPv6. + // An IP host: bracketed IPv6, or a name whose last label is a number, + // which URL parsers read as IPv4 (127.1, 0x7f.0.0.1). + const isIPHost = (host) => /^\[[0-9a-f:.]+\]$/i.test(host) || /(^|\.)(\d+|0x[0-9a-f]*)$/i.test(normalizeHost(host)); + + // --------------------------------------------------------------------------- + // TOTP (RFC 6238) for secrets registered with { totp: true }: reference C's + // `bu_2fa_code` secrets. + + function base32Decode(s) { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + const clean = String(s).toUpperCase().replace(/[\s=-]/g, ""); + const out = []; + let bits = 0; + let value = 0; + for (const c of clean) { + const i = alphabet.indexOf(c); + if (i < 0) throw new Error("secrets: a TOTP secret must be base32"); + value = (value << 5) | i; + bits += 5; + if (bits >= 8) { + out.push((value >>> (bits - 8)) & 255); + bits -= 8; + } + } + return new Uint8Array(out); + } + + function sha1(bytes) { + const ml = bytes.length; + const withPad = new Uint8Array((((ml + 9 + 63) >> 6) << 6)); + withPad.set(bytes); + withPad[ml] = 0x80; + const dv = new DataView(withPad.buffer); + dv.setUint32(withPad.length - 4, (ml * 8) >>> 0); + dv.setUint32(withPad.length - 8, Math.floor((ml * 8) / 2 ** 32)); + let [a0, b0, c0, d0, e0] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0]; + const w = new Uint32Array(80); + const rotl = (x, n) => (x << n) | (x >>> (32 - n)); + for (let off = 0; off < withPad.length; off += 64) { + for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4); + for (let i = 16; i < 80; i++) w[i] = rotl(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1); + let [a, b, c, d, e] = [a0, b0, c0, d0, e0]; + for (let i = 0; i < 80; i++) { + const f = i < 20 ? (b & c) | (~b & d) : i < 40 ? b ^ c ^ d : i < 60 ? (b & c) | (b & d) | (c & d) : b ^ c ^ d; + const k = i < 20 ? 0x5a827999 : i < 40 ? 0x6ed9eba1 : i < 60 ? 0x8f1bbcdc : 0xca62c1d6; + const t = (rotl(a, 5) + f + e + k + w[i]) >>> 0; + e = d; + d = c; + c = rotl(b, 30) >>> 0; + b = a; + a = t; + } + a0 = (a0 + a) >>> 0; + b0 = (b0 + b) >>> 0; + c0 = (c0 + c) >>> 0; + d0 = (d0 + d) >>> 0; + e0 = (e0 + e) >>> 0; + } + const out = new Uint8Array(20); + const odv = new DataView(out.buffer); + [a0, b0, c0, d0, e0].forEach((v, i) => odv.setUint32(i * 4, v)); + return out; + } + + function hmacSha1(key, msg) { + if (key.length > 64) key = sha1(key); + const k = new Uint8Array(64); + k.set(key); + const inner = new Uint8Array(64 + msg.length); + const outer = new Uint8Array(64 + 20); + for (let i = 0; i < 64; i++) { + inner[i] = k[i] ^ 0x36; + outer[i] = k[i] ^ 0x5c; + } + inner.set(msg, 64); + outer.set(sha1(inner), 64); + return sha1(outer); + } + + function totp(secretBase32, timeMs, { digits = 6, period = 30 } = {}) { + const counter = Math.floor(timeMs / 1000 / period); + const msg = new Uint8Array(8); + const dv = new DataView(msg.buffer); + dv.setUint32(0, Math.floor(counter / 2 ** 32)); + dv.setUint32(4, counter >>> 0); + const h = hmacSha1(base32Decode(secretBase32), msg); + const o = h[19] & 15; + const code = (((h[o] & 127) << 24) | (h[o + 1] << 16) | (h[o + 2] << 8) | h[o + 3]) % 10 ** digits; + return String(code).padStart(digits, "0"); + } + + // --------------------------------------------------------------------------- + // Animated PNG from same-size PNG frames, by moving their IDAT data into + // APNG frame chunks (no decoding). + + const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; + })(); + function crc32(bytes) { + let c = 0xffffffff; + for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 255] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; + } + function pngChunks(png) { + const dv = new DataView(png.buffer, png.byteOffset, png.byteLength); + const out = []; + for (let off = 8; off + 8 <= png.length;) { + const len = dv.getUint32(off); + const type = String.fromCharCode(png[off + 4], png[off + 5], png[off + 6], png[off + 7]); + out.push({ type, data: png.subarray(off + 8, off + 8 + len) }); + off += 12 + len; + } + return out; + } + function chunk(type, data) { + const out = new Uint8Array(12 + data.length); + const dv = new DataView(out.buffer); + dv.setUint32(0, data.length); + for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i); + out.set(data, 8); + dv.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length))); + return out; + } + function u32s(...values) { + const b = new Uint8Array(values.length * 4); + const dv = new DataView(b.buffer); + values.forEach((v, i) => dv.setUint32(i * 4, v)); + return b; + } + // Frames whose header differs from the first (another size) are skipped. + function buildApng(frames, delayMs = 800) { + const parsed = frames.map((f) => pngChunks(f instanceof Uint8Array ? f : new Uint8Array(f))); + const ihdr = parsed[0].find((c) => c.type === "IHDR").data; + const same = parsed.filter((cs) => { + const h = cs.find((c) => c.type === "IHDR"); + return h && h.data.length === ihdr.length && h.data.every((b, i) => b === ihdr[i]); + }); + const width = new DataView(ihdr.buffer, ihdr.byteOffset).getUint32(0); + const height = new DataView(ihdr.buffer, ihdr.byteOffset).getUint32(4); + const parts = [new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), chunk("IHDR", ihdr), chunk("acTL", u32s(same.length, 0))]; + let seq = 0; + same.forEach((cs, i) => { + const fctl = new Uint8Array(26); + fctl.set(u32s(seq++, width, height, 0, 0)); + new DataView(fctl.buffer).setUint16(20, delayMs); + new DataView(fctl.buffer).setUint16(22, 1000); + parts.push(chunk("fcTL", fctl)); + for (const c of cs) { + if (c.type !== "IDAT") continue; + if (i === 0) parts.push(chunk("IDAT", c.data)); + else { + const d = new Uint8Array(4 + c.data.length); + d.set(u32s(seq++)); + d.set(c.data, 4); + parts.push(chunk("fdAT", d)); + } + } + }); + parts.push(chunk("IEND", new Uint8Array(0))); + return { bytes: Buffer.concat(parts.map((p) => Buffer.from(p))), frames: same.length, skipped: frames.length - same.length }; + } + + // --------------------------------------------------------------------------- + // Page functions (agent world: closed shadow roots are open to it). + + // The frame as Markdown blocks. Iframes become placeholders "\u0000F\u0000" + // with their handles, so the host stitches each frame's Markdown in place. + function markdownOfFrame(opts) { + const A = globalThis[Symbol.for("cmux.browserRepl.agent")]; + const frames = []; + const SKIP = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "HEAD", "META", "LINK", "TITLE", "SVG", "CANVAS", "VIDEO", "AUDIO", "OBJECT", "EMBED", "MAP", "DIALOG"]); + const OUTSIDE_MAIN = new Set(["navigation", "banner", "contentinfo", "complementary", "search"]); + const styles = new Map(); + const style = (el) => { + let cs = styles.get(el); + if (!cs) styles.set(el, (cs = getComputedStyle(el))); + return cs; + }; + const tag = (el) => (el.tagName || "").toUpperCase(); + const landmark = (el) => { + const role = el.getAttribute("role"); + if (role) return role; + const t = tag(el); + if (t === "NAV") return "navigation"; + if (t === "ASIDE") return "complementary"; + if ((t === "HEADER" || t === "FOOTER") && !el.closest("article, main, section, [role=main], [role=article]")) return t === "HEADER" ? "banner" : "contentinfo"; + return null; + }; + const skipped = (el, ctx) => { + if (SKIP.has(tag(el))) return true; + if (tag(el) === "DIALOG" && el.open) return false; + const cs = style(el); + if (cs.display === "none" || cs.contentVisibility === "hidden") return true; + if (ctx.main && OUTSIDE_MAIN.has(landmark(el))) return true; + return false; + }; + // Children in the flat tree: a shadow root's, a slot's assigned nodes, + // and the children of display:contents boxes in their place. + const kids = (el) => { + const out = []; + for (const c of ownKids(el)) { + if (c.nodeType === 1 && !SKIP.has(tag(c)) && style(c).display === "contents") out.push(...kids(c)); + else out.push(c); + } + return out; + }; + const ownKids = (el) => { + if (el.shadowRoot) return [...el.shadowRoot.childNodes]; + if (tag(el) === "SLOT") { + const assigned = el.assignedNodes({ flatten: true }); + return assigned.length ? assigned : [...el.childNodes]; + } + if (tag(el) === "DETAILS" && !el.open) return [...el.children].filter((c) => tag(c) === "SUMMARY"); + return [...el.childNodes]; + }; + const sub = (el, ctx) => { + const cs = style(el); + return cs.visibility === "hidden" || cs.visibility === "collapse" ? { ...ctx, hidden: true } : cs.visibility === "visible" ? { ...ctx, hidden: false } : ctx; + }; + const isInline = (el) => { + const t = tag(el); + if (/^(H[1-6]|P|PRE|UL|OL|TABLE|BLOCKQUOTE|HR|IFRAME|FRAME|LI|DL|DT|DD|FIGURE|SECTION|ARTICLE|DETAILS|SUMMARY)$/.test(t)) return false; + return style(el).display.startsWith("inline"); + }; + const collapse = (s) => s.replace(/[\s ]+/g, " "); + const esc = (s) => s.replace(/([\\`*_[\]])/g, "\\$1"); + const absURL = (v) => { + try { + return new URL(v, document.baseURI).href; + } catch (e) { + return v; + } + }; + const fieldValue = (el) => { + const t = tag(el); + if (t === "SELECT") return [...el.selectedOptions].map((o) => o.label || o.text).join(", "); + if (t === "TEXTAREA") return el.value; + if (t === "INPUT") { + const type = (el.type || "text").toLowerCase(); + if (type === "password" || type === "hidden" || type === "file") return ""; + if (type === "checkbox" || type === "radio") return el.checked ? "[x]" : "[ ]"; + if (type === "submit" || type === "button" || type === "reset") return el.value; + return el.value; + } + return ""; + }; + + function inline(node, ctx) { + if (node.nodeType === 3) return ctx.hidden ? "" : collapse(node.data); + if (node.nodeType !== 1 || skipped(node, ctx)) return ""; + ctx = sub(node, ctx); + const t = tag(node); + if (t === "BR") return "\n"; + if (t === "IFRAME" || t === "FRAME") return ""; + if (t === "IMG") { + if (ctx.hidden) return ""; + const alt = collapse(node.getAttribute("alt") || "").trim(); + if (opts.images && node.getAttribute("src")) return `![${esc(alt)}](${absURL(node.getAttribute("src"))})`; + return ""; + } + if (t === "INPUT" || t === "TEXTAREA" || t === "SELECT") { + if (ctx.hidden) return ""; + const v = fieldValue(node); + if (!v) return ""; + return v === "[x]" || v === "[ ]" ? v + " " : t === "INPUT" && /^(submit|button|reset)$/i.test(node.type) ? v : "`" + collapse(v) + "`"; + } + const inner = kids(node).map((c) => inline(c, ctx)).join(""); + const trimmed = inner.trim(); + if (!trimmed && t === "A" && opts.links && node.getAttribute("href") && !ctx.hidden) { + // An icon link is named by its label, its SVG title or its image's alt. + const svgTitle = node.querySelector("svg title"); + const img = node.querySelector("img[alt]"); + const label = collapse(node.getAttribute("aria-label") || (svgTitle && svgTitle.textContent) || (img && img.getAttribute("alt")) || node.getAttribute("title") || "").trim(); + if (label && !/^(javascript:|#$)/i.test(node.getAttribute("href").trim())) return `[${esc(label)}](${absURL(node.getAttribute("href"))})`; + } + if (!trimmed) return inner && /\s/.test(inner) ? " " : ""; + const pad = (s) => (/^\s/.test(inner) ? " " : "") + s + (/\s$/.test(inner) ? " " : ""); + if (t === "A") { + const href = node.getAttribute("href"); + if (opts.links && href && !/^(javascript:|#$)/i.test(href.trim())) return pad(`[${trimmed.replace(/\n+/g, " ")}](${absURL(href)})`); + return inner; + } + if (t === "STRONG" || t === "B") return pad(`**${trimmed}**`); + if (t === "EM" || t === "I") return pad(`*${trimmed}*`); + if (t === "CODE" || t === "KBD" || t === "SAMP") return pad("`" + trimmed + "`"); + if (t === "S" || t === "DEL") return pad(`~~${trimmed}~~`); + return inner; + } + const tidy = (s) => s.split("\n").map((l) => l.replace(/ +/g, " ").trim()).filter(Boolean).join("\n"); + + // Blocks of a container: inline runs between block children become paragraphs. + function blocks(node, ctx) { + const out = []; + let run = ""; + const end = () => { + const t = tidy(run); + if (t) out.push(t); + run = ""; + }; + for (const c of kids(node)) { + if (c.nodeType === 3) { + run += ctx.hidden ? "" : collapse(c.data); + continue; + } + if (c.nodeType !== 1 || skipped(c, ctx)) continue; + if (isInline(c) && !(tag(c) === "IFRAME" || tag(c) === "FRAME")) { + run += inline(c, ctx); + continue; + } + end(); + out.push(...block(c, sub(c, ctx))); + } + end(); + return out; + } + function list(el, ctx) { + const ordered = tag(el) === "OL"; + let n = Number(el.getAttribute("start")) || 1; + const lines = []; + for (const li of kids(el)) { + if (li.nodeType !== 1 || skipped(li, ctx)) continue; + const parts = tag(li) === "LI" ? blocks(li, sub(li, ctx)) : block(li, sub(li, ctx)); + if (!parts.length) continue; + const marker = ordered ? `${n++}. ` : "- "; + const text = parts.join("\n").split("\n"); + lines.push(marker + text[0], ...text.slice(1).map((l) => " ".repeat(marker.length) + l)); + } + return lines.length ? [lines.join("\n")] : []; + } + function table(el, ctx) { + const rows = [...el.rows].filter((r) => !skipped(r, ctx)); + const layout = !rows.length || el.querySelector("table") || el.getAttribute("role") === "presentation" || rows.every((r) => r.cells.length <= 1); + if (layout) return blocks(el, ctx); + const width = Math.max(...rows.map((r) => r.cells.length)); + const cell = (c) => tidy(inline(c, sub(c, ctx))).replace(/\n/g, " ").replace(/\|/g, "\\|"); + const line = (r) => "| " + [...r.cells].map(cell).concat(Array(width - r.cells.length).fill("")).join(" | ") + " |"; + const caption = el.caption ? tidy(inline(el.caption, ctx)) : ""; + const lines = [line(rows[0]), "|" + " --- |".repeat(width), ...rows.slice(1).map(line)]; + return caption ? [caption, lines.join("\n")] : [lines.join("\n")]; + } + function block(el, ctx) { + const t = tag(el); + const h = /^H([1-6])$/.exec(t); + if (h) { + const text = tidy(inline(el, ctx)).replace(/\n/g, " "); + return text ? ["#".repeat(Number(h[1])) + " " + text] : []; + } + if (t === "PRE") return ctx.hidden ? [] : ["```\n" + el.textContent.replace(/\n$/, "") + "\n```"]; + if (t === "UL" || t === "OL") return list(el, ctx); + if (t === "TABLE") return table(el, ctx); + if (t === "HR") return ["---"]; + if (t === "BLOCKQUOTE") { + const inner = blocks(el, ctx); + return inner.length ? [inner.join("\n\n").split("\n").map((l) => "> " + l).join("\n")] : []; + } + if (t === "IFRAME" || t === "FRAME") { + if (ctx.hidden) return []; + frames.push(A.handleFor(el)); + return [`\u0000F${frames.length - 1}\u0000`]; + } + if (t === "INPUT" || t === "TEXTAREA" || t === "SELECT" || t === "IMG") { + const s = tidy(inline(el, ctx)); + return s ? [s] : []; + } + return blocks(el, ctx); + } + + let rootEl = document.body || document.documentElement; + let main = false; + if (opts.main) { + const visible = (e) => e && style(e).display !== "none" && (e.innerText || "").trim().length > 0; + const mainEl = [...document.querySelectorAll("main, [role=main]")].find(visible); + const articles = [...document.querySelectorAll("article, [role=article]")].filter(visible); + if (mainEl) rootEl = mainEl; + else if (articles.length === 1) rootEl = articles[0]; + else main = true; + } + const out = rootEl ? blocks(rootEl, { hidden: false, main }) : []; + return { blocks: out, frames }; + } + + // Structured data by selectors (Playwright syntax: CSS, text=, role=, ...; + // they reach into shadow roots). A spec is "selector" (text), "selector@attr" + // (attribute; href and src resolve to absolute URLs), "@attr" or "." (the + // scope itself), ["spec"] (every match), or an object: { $: "selector", ...fields } + // is one object per match, an object without $ is one nested object. + function extractInFrame(schema, opts) { + const A = globalThis[Symbol.for("cmux.browserRepl.agent")]; + const limit = opts.limit || 1000; + const all = (sel, scope) => A.queryAll(sel, scope && scope !== document ? A.handleFor(scope) : undefined).map((h) => A.element(h)); + const text = (el) => { + const t = (el.tagName || "").toUpperCase(); + if (t === "INPUT" || t === "TEXTAREA") return el.value; + if (t === "SELECT") return [...el.selectedOptions].map((o) => o.label || o.text).join(", "); + return (el.innerText !== undefined ? el.innerText : el.textContent || "").replace(/[\s ]+/g, " ").trim(); + }; + const split = (spec) => { + const m = /^(.*?)@([A-Za-z_][\w:.-]*)$/.exec(spec); + if (m && !/["'\]]/.test(spec.slice(m[1].length))) return [m[1].trim(), m[2]]; + return [spec.trim(), null]; + }; + const read = (el, attr) => { + if (!el) return null; + if (!attr) return text(el); + if ((attr === "href" || attr === "src") && typeof el[attr] === "string" && el[attr]) return el[attr]; + return el.getAttribute(attr); + }; + const leaf = (spec, scope, many) => { + const [sel, attr] = split(spec); + if (!sel || sel === ".") return many ? [read(scope, attr)] : read(scope, attr); + const els = all(sel, scope); + return many ? els.slice(0, limit).map((e) => read(e, attr)) : read(els[0], attr); + }; + const run = (spec, scope) => { + if (typeof spec === "string") return leaf(spec, scope, false); + if (Array.isArray(spec)) { + if (spec.length !== 1) throw new Error("page.extract: a list spec has one item: [\"selector\"] or [{ $: \"selector\", ... }]"); + const item = spec[0]; + if (typeof item === "string") return leaf(item, scope, true); + if (item && typeof item === "object" && Array.isArray(item.__cmuxPairs) && item.__cmuxPairs.some(([k, v]) => k === "$" && typeof v === "string")) return run(item, scope); + throw new Error("page.extract: a list spec has one item: [\"selector\"] or [{ $: \"selector\", ... }]"); + } + // Objects travel as key/value pairs both ways, so their key order + // survives drivers whose JSON does not keep it (the app's). + if (spec && typeof spec === "object" && Array.isArray(spec.__cmuxPairs)) { + const entries = spec.__cmuxPairs; + const each = entries.find(([k]) => k === "$"); + const fields = (s) => ({ __cmuxPairs: entries.filter(([k]) => k !== "$").map(([k, v]) => [k, run(v, s)]) }); + if (each && typeof each[1] === "string") return all(each[1], scope).slice(0, limit).map(fields); + return fields(scope); + } + throw new Error(`page.extract: expected a selector string, [spec] or an object, got ${JSON.stringify(spec)}`); + }; + const scope = opts.scope ? A.element(opts.scope) : document; + return run(schema, scope); + } + + // Text matches with context, like grep over what the page renders. + function searchTextInFrame(opts) { + const A = globalThis[Symbol.for("cmux.browserRepl.agent")]; + const scope = opts.scope ? A.element(opts.scope) : document.body || document.documentElement; + const visible = (el) => { + if (!el) return false; + if (typeof el.checkVisibility === "function") return el.checkVisibility({ visibilityProperty: true, contentVisibilityAuto: true }); + const cs = getComputedStyle(el); + return cs.display !== "none" && cs.visibility !== "hidden"; + }; + const blockOf = (el) => { + for (let e = el; e; e = e.parentElement || (e.getRootNode && e.getRootNode().host)) { + if (!getComputedStyle(e).display.startsWith("inline")) return e; + } + return null; + }; + const nodes = []; + const walk = (root) => { + for (const n of root.childNodes) { + if (n.nodeType === 3) { + if (n.data.trim() && visible(n.parentElement)) nodes.push(n); + } else if (n.nodeType === 1) { + const t = n.tagName.toUpperCase(); + if (t === "SCRIPT" || t === "STYLE" || t === "NOSCRIPT" || t === "TEMPLATE") continue; + if (n.shadowRoot) walk(n.shadowRoot); + else walk(n); + } + } + }; + walk(scope); + let text = ""; + const starts = []; + let lastBlock = null; + for (const n of nodes) { + const b = blockOf(n.parentElement); + if (text && b !== lastBlock) text += "\n"; + lastBlock = b; + starts.push(text.length); + text += n.data.replace(/[\s ]+/g, " "); + } + let re; + try { + re = opts.regex ? new RegExp(opts.pattern, opts.caseSensitive ? "g" : "gi") : new RegExp(opts.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), opts.caseSensitive ? "g" : "gi"); + } catch (e) { + throw new Error(`page.searchText: invalid pattern: ${e.message}`); + } + const matches = []; + let total = 0; + let m; + while ((m = re.exec(text))) { + if (!m[0].length) { + re.lastIndex++; + continue; + } + total++; + if (matches.length >= opts.limit) continue; + let lo = 0; + let hi = starts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (starts[mid] <= m.index) lo = mid; + else hi = mid - 1; + } + const a = Math.max(0, m.index - opts.context); + const b = Math.min(text.length, m.index + m[0].length + opts.context); + const el = nodes[lo] && nodes[lo].parentElement; + matches.push({ match: m[0], context: (a > 0 ? "…" : "") + text.slice(a, b).replace(/\n/g, " ").trim() + (b < text.length ? "…" : ""), handle: el ? A.handleFor(el) : null }); + } + return { total, matches }; + } + + function dropdownInFrame(handle) { + const A = globalThis[Symbol.for("cmux.browserRepl.agent")]; + const el = A.element(handle); + const clean = (s) => (s || "").replace(/[\s ]+/g, " ").trim(); + if (el.tagName && el.tagName.toUpperCase() === "SELECT") { + return { kind: "select", multiple: el.multiple, options: [...el.options].map((o, index) => ({ index, label: o.label || clean(o.text), value: o.value, selected: o.selected, disabled: o.disabled })) }; + } + const rootNode = el.getRootNode(); + const byId = (id) => (rootNode.getElementById ? rootNode.getElementById(id) : document.getElementById(id)); + const role = el.getAttribute("role"); + let popup = null; + if (role === "listbox" || role === "menu" || role === "tree" || role === "radiogroup") popup = el; + else { + for (const attr of ["aria-controls", "aria-owns"]) { + for (const id of (el.getAttribute(attr) || "").split(/\s+/).filter(Boolean)) popup = popup || byId(id); + } + if (!popup) popup = el.querySelector("[role=listbox], [role=menu], [role=tree]"); + } + if (!popup) return { kind: "none", options: [] }; + const items = [...popup.querySelectorAll("[role=option], [role=menuitem], [role=menuitemradio], [role=menuitemcheckbox], [role=treeitem], [role=radio]")] + .filter((o) => !(typeof o.checkVisibility === "function") || o.checkVisibility({ visibilityProperty: true })); + return { + kind: "aria", + multiple: popup.getAttribute("aria-multiselectable") === "true", + options: items.map((o, index) => ({ + index, + label: clean(o.getAttribute("aria-label") || o.innerText || o.textContent), + value: o.getAttribute("data-value") || o.getAttribute("value") || null, + selected: o.getAttribute("aria-selected") === "true" || o.getAttribute("aria-checked") === "true", + disabled: o.getAttribute("aria-disabled") === "true", + handle: A.handleFor(o), + })), + }; + } + + function scrollInfoInFrame(handle) { + const A = globalThis[Symbol.for("cmux.browserRepl.agent")]; + const el = handle ? A.element(handle) : null; + const doc = document.scrollingElement || document.documentElement; + const target = el || doc; + const vh = el ? el.clientHeight : innerHeight; + const y = el ? el.scrollTop : scrollY; + const r1 = (n) => Math.round(n * 10) / 10; + return { + x: Math.round(el ? el.scrollLeft : scrollX), + y: Math.round(y), + viewportHeight: vh, + scrollHeight: target.scrollHeight, + pagesAbove: vh ? r1(y / vh) : 0, + pagesBelow: vh ? r1(Math.max(0, target.scrollHeight - y - vh) / vh) : 0, + }; + } + + // Search engine results pages, parsed in a blank tab's DOM parser. + function parseResults(arg) { + const doc = new DOMParser().parseFromString(arg.html, "text/html"); + const clean = (s) => (s || "").replace(/[\s ]+/g, " ").trim(); + const abs = (href) => { + try { + return new URL(href, arg.base).href; + } catch (e) { + return null; + } + }; + const out = []; + const seen = new Set(); + const add = (title, url, snippet) => { + if (!title || !url || !/^https?:/.test(url) || seen.has(url) || out.length >= arg.limit) return; + seen.add(url); + out.push({ title, url, snippet: snippet || "" }); + }; + if (arg.engine === "duckduckgo") { + for (const r of doc.querySelectorAll(".result, .web-result")) { + if (r.classList.contains("result--ad")) continue; + const a = r.querySelector("a.result__a"); + if (!a) continue; + let url = abs(a.getAttribute("href")); + try { + const u = new URL(url); + if (/duckduckgo\.com$/.test(u.hostname) && u.pathname === "/l/" && u.searchParams.get("uddg")) url = u.searchParams.get("uddg"); + } catch (e) {} + add(clean(a.textContent), url, clean((r.querySelector(".result__snippet") || {}).textContent)); + } + return { results: out, blocked: !out.length && !!doc.querySelector(".anomaly-modal, #challenge-form, .challenge-form") }; + } + for (const r of doc.querySelectorAll("#b_results > li.b_algo, li.b_algo")) { + const a = r.querySelector("h2 a"); + if (!a) continue; + let url = abs(a.getAttribute("href")); + try { + const u = new URL(url); + const enc = u.pathname === "/ck/a" && u.searchParams.get("u"); + if (enc && enc.startsWith("a1")) url = atob(enc.slice(2).replace(/-/g, "+").replace(/_/g, "/")); + } catch (e) {} + add(clean(a.textContent), url, clean((r.querySelector(".b_caption p, .b_lineclamp2, .b_lineclamp3, .b_algoSlug") || {}).textContent)); + } + return { results: out, blocked: !out.length && !!doc.querySelector("#b_captcha, .captcha") }; + } + + const ENGINES = { + duckduckgo: "https://html.duckduckgo.com/html/?q=%s", + bing: "https://www.bing.com/search?q=%s", + }; + + // --------------------------------------------------------------------------- + + const fnSource = (fn) => fn.toString(); + const agentCall = (frame, fn, ...args) => frame._call("agent", fnSource(fn), args); + const errCode = (e) => e && (e.code || (e.cause && e.cause.code)); + + function install(ctx) { + const { session, host, globals, sessionApi, fetch, fs, path, currentPage, show } = ctx; + const print = (level, text) => { + try { + host.print(level, text); + } catch {} + }; + + // ---- secrets ------------------------------------------------------------- + // Values live in the native session (BrowserReplSecretStore), never in + // this context: secret(name) is a handle, the session substitutes the + // value into input.insertText for the driver, which types it only into a + // frame whose own origin matches the secret's domains, and the session + // masks every value in output, errors, driver results, events, fetch + // bodies and written files. Agent code runs in this context, so nothing + // here is the guard; these functions only shape the API. + const SecretBrand = new WeakSet(); + class Secret { + constructor(name) { + this.name = name; + SecretBrand.add(this); + Object.freeze(this); + } + toString() { + return ``; + } + toJSON() { + return this.toString(); + } + } + Object.freeze(Secret.prototype); + const isSecret = (v) => v !== null && typeof v === "object" && SecretBrand.has(v); + const secretsHost = (op, args) => host.secrets(op, args || {}); + // { name, domains, totp } in that key order, whatever order the host used. + const described = (d) => ({ name: d.name, domains: d.domains, totp: d.totp }); + const secrets = Object.freeze({ + // set(name, value, { domains, totp }): the value is typed only into + // frames on those domains and is masked as everywhere. + set(name, value, options) { + return described(secretsHost("set", { name, value, domains: options && options.domains, totp: !!(options && options.totp) })); + }, + // Reference C's sensitive_data shape: { "": { name: value } }, + // as an object or a JSON file path (read by the native session, so the + // values never enter this context). A value { value, totp } is accepted. + load(source) { + if (typeof source === "string") return secretsHost("load", { path: source }).map(described); + if (!source || typeof source !== "object" || Array.isArray(source)) throw new Error("secrets.load: expected { \"\": { name: value } }"); + return secretsHost("load", { object: source }).map(described); + }, + list: () => secretsHost("list").map(described), + has: (name) => secretsHost("has", { name }), + delete: (name) => secretsHost("delete", { name }), + clear: () => { + secretsHost("clear"); + }, + }); + function secret(name) { + if (!secretsHost("has", { name })) throw new Error(`secret(${JSON.stringify(name)}): no such secret; register it with secrets.set(name, value, { domains }) or secrets.load(file)`); + return new Secret(name); + } + const anySecrets = () => secretsHost("list").length > 0; + + // ---- domain policy ------------------------------------------------------- + // The policy lives in the native session, which checks navigations, new + // tabs, every fetch hop and subresources (content rules) itself, and + // refuses reads and input on a tab whose page it blocks. These wrappers + // give early errors and keep the log of blocked navigations. + const policyHost = (op, args) => host.policy(op, args || {}); + const policyLog = []; + const blockedTabs = new Map(); // targetId -> { url, reason } reported by the driver + const policyActive = () => { + const p = policyHost("get"); + return !!(p.allowed || p.prohibited.length || p.blockIPs); + }; + const urlReason = (url) => policyHost("check", { url: String(url) }); + function checkURL(title, url) { + const reason = urlReason(url); + if (reason) { + policyLog.push({ url: String(url), reason, at: new Date(session.now()).toISOString(), blocked: "before" }); + throw new Error(`${title}: ${url} is blocked: ${reason}`); + } + } + function setPatterns(kind, title, list, options) { + if (list === undefined) { + const p = policyHost("get"); + return kind === "allowed" ? p.allowed : p.prohibited; + } + if (list !== null && !Array.isArray(list)) throw new Error(`${title}: expected an array of domain patterns or null, got ${JSON.stringify(list)}`); + const p = policyHost("set", { [kind]: list, lock: !!(options && options.lock), title }); + return kind === "allowed" ? p.allowed : p.prohibited; + } + + // ---- browser-context options (session.configure) ------------------------------- + const contextConfig = {}; + async function configure(options) { + if (options === null || typeof options !== "object" || Array.isArray(options)) throw new Error(`session.configure: options: expected an object, got ${JSON.stringify(options)}`); + const known = ["userAgent", "extraHTTPHeaders", "permissions", "proxy"]; + const unknown = Object.keys(options).filter((k) => !known.includes(k)); + if (unknown.length) throw new Error(`session.configure: unknown option ${unknown.map((k) => JSON.stringify(k)).join(", ")}; expected ${known.join(", ")}`); + const params = {}; + if ("userAgent" in options) { + if (options.userAgent !== null && typeof options.userAgent !== "string") throw new Error("session.configure: userAgent: expected a string or null"); + params.userAgent = options.userAgent; + } + if ("extraHTTPHeaders" in options) { + const h = options.extraHTTPHeaders; + if (h !== null && (typeof h !== "object" || Array.isArray(h) || Object.values(h).some((v) => typeof v !== "string"))) throw new Error("session.configure: extraHTTPHeaders: expected { name: string value } or null"); + params.extraHTTPHeaders = h || {}; + } + if ("permissions" in options) { + const list = options.permissions; + if (list !== null && (!Array.isArray(list) || list.some((v) => typeof v !== "string"))) throw new Error("session.configure: permissions: expected an array of permission names or null"); + params.permissions = list || []; + } + if ("proxy" in options) { + const px = options.proxy; + if (px !== null && (typeof px !== "object" || typeof px.server !== "string" || !px.server)) throw new Error("session.configure: proxy: expected { server, username?, password?, bypass? } or null"); + params.proxy = px; + } + const result = await session.driver.call("session.configure", params); + for (const [k, v] of Object.entries(params)) { + if (v === null || (Array.isArray(v) && !v.length) || (k === "extraHTTPHeaders" && !Object.keys(v).length)) delete contextConfig[k]; + else contextConfig[k] = k === "proxy" ? Object.fromEntries(["server", "username", "bypass"].filter((f) => v[f] !== undefined).map((f) => [f, v[f]])) : v; + } + const out = { ...contextConfig }; + if (result && result.proxy) out.proxyAppliesTo = "tabs opened from now on"; + return out; + } + + // ---- downloads ------------------------------------------------------------- + const downloads = []; + const downloadsById = new Map(); + + // ---- recording ------------------------------------------------------------- + let recorder = null; + let recordCount = 0; + const RECORDED = new Set(["tab.navigate", "tab.history", "tab.reload", "tabs.open", "tabs.close", "input.mouse", "input.key", "input.insertText", "input.drag", "input.setFiles", "dialog.respond", "filechooser.respond"]); + const NAVIGATIONS = new Set(["tab.navigate", "tab.history", "tab.reload"]); + function traceParams(method, p) { + const o = {}; + if (p.url !== undefined) o.url = p.url; + if (method === "input.mouse") Object.assign(o, { type: p.type, x: p.x, y: p.y, button: p.button, deltaX: p.deltaX, deltaY: p.deltaY }); + // With secrets registered, keys and inserted text stay out of the trace + // (a secret typed key by key would otherwise be spelled out there). + const quiet = anySecrets(); + if (method === "input.key") Object.assign(o, { type: p.type, modifiers: p.modifiers }, quiet ? {} : { key: p.key }); + if (method === "input.insertText") o.text = p.secret ? `` : quiet ? `<${String(p.text).length} characters>` : p.text; + if (method === "input.setFiles") o.files = (p.files || []).map((f) => f.name); + if (method === "tab.history") o.delta = p.delta; + if (method === "dialog.respond") o.accept = p.accept; + return o; + } + async function recordFrame(page) { + if (!recorder || !recorder.screenshots || page._closed || recorder.busy) return; + recorder.busy = true; + try { + const r = await session.call("tab.screenshot", { targetId: page._targetId, format: "png" }); + const file = path.join(recorder.dir, `frame-${String(++recorder.frames).padStart(4, "0")}.png`); + fs.writeFileSync(file, Buffer.from(r.base64, "base64")); + recorder.frameFiles.push(file); + return file; + } catch { + return null; + } finally { + recorder.busy = false; + } + } + function trace(entry) { + if (!recorder) return; + recorder.actions++; + fs.appendFileSync(recorder.trace, JSON.stringify(entry) + "\n"); + } + + // ---- hooks ------------------------------------------------------------------- + // Secrets in captures are masked by the driver: it hides the fields it + // typed a secret into, and text holding a value in frames on that + // secret's domains, for the length of the capture. + const TITLES = { "tab.navigate": "page.goto", "tabs.open": "tabs.open" }; + const blockedMessage = (b) => `navigation to ${b.url} was blocked: ${b.reason}`; + const hooks = { + isSecret, + checkURL, + async beforeCall(method, params) { + // A cancelled navigation is reported to the action that caused it; a + // report that came in after that action ended is dropped when the + // tab's next action starts. + if (params && params.targetId && /^(tab\.(navigate|history|reload)|input\.)/.test(method)) blockedTabs.delete(params.targetId); + if ((method === "tab.navigate" || method === "tabs.open") && params && params.url) checkURL(TITLES[method], params.url); + }, + afterCall(method, params, promise) { + if (!recorder && !(NAVIGATIONS.has(method) || method === "tabs.open")) return promise; + return promise.then(async (r) => { + if (recorder && RECORDED.has(method) && !(method === "input.mouse" && params.type === "move") && !(method === "input.key" && params.type === "up")) { + const entry = { t: new Date(session.now()).toISOString(), tab: params.targetId, method, ...traceParams(method, params) }; + if (NAVIGATIONS.has(method) || method === "tabs.open") { + const page = session.pages.get(params.targetId || (r && r.targetId)); + if (page) entry.frame = await recordFrame(page); + } + trace(entry); + } + return r; + }, (e) => { + // The driver cancelled this navigation (a redirect to a blocked + // page) and reported it while the call was running. + const reported = NAVIGATIONS.has(method) && params && params.targetId && blockedTabs.get(params.targetId); + if (reported) { + blockedTabs.delete(params.targetId); + throw new Error(`${TITLES[method] || method}: ${blockedMessage(reported)}; the tab stayed on its page`); + } + // The driver refused a navigation the policy blocks. Other + // refusals (a frame or input the policy blocks) pass unchanged. + if (e && e.code === "blocked" && (NAVIGATIONS.has(method) || method === "tabs.open")) { + policyLog.push({ url: String(params.url || ""), reason: String(e.message).replace(/^.* is blocked: /, ""), at: new Date(session.now()).toISOString(), blocked: "before" }); + throw new Error(`${TITLES[method] || method}: ${e.message}`); + } + throw e; + }); + }, + onEvent(event, payload) { + return payload; + }, + afterEvent(event, p) { + if (event === "download.started") { + const d = { id: p.downloadId, url: p.url, suggestedFilename: p.suggestedFilename, tab: p.targetId, state: "started", path: null, error: null, startedAt: new Date(session.now()).toISOString() }; + downloads.push(d); + downloadsById.set(p.downloadId, d); + trace({ t: d.startedAt, tab: p.targetId, event: "download", url: p.url, suggestedFilename: p.suggestedFilename }); + } else if (event === "download.finished") { + const d = downloadsById.get(p.downloadId); + if (d) Object.assign(d, { state: p.error ? "failed" : "finished", path: p.path || null, error: p.error || null }); + } else if (event === "navigation.blocked") { + // The driver cancelled a navigation of a tab the session opened (a + // link, redirect or script): the tab stays where it was. + policyLog.push({ url: p.url, reason: p.reason, at: new Date(session.now()).toISOString(), blocked: "cancelled" }); + blockedTabs.set(p.targetId, { url: p.url, reason: p.reason }); + print("warn", `# ${blockedMessage(p)}; the tab stayed on its page`); + } else if (event === "tab.created" && p.url && p.openerTargetId) { + const reason = urlReason(p.url); + if (reason) { + policyLog.push({ url: p.url, reason, at: new Date(session.now()).toISOString(), blocked: "popup" }); + print("warn", `# a new tab for ${p.url} was closed: ${reason}`); + session.call("tabs.close", { targetId: p.targetId }).catch(() => {}); + } + } + }, + async afterAction(page) { + const blocked = blockedTabs.get(page._targetId); + if (blocked) { + blockedTabs.delete(page._targetId); + throw new Error(`${blockedMessage(blocked)}; the tab stayed on ${page.url()}`); + } + if (recorder) { + const file = await recordFrame(page); + if (file) trace({ t: new Date(session.now()).toISOString(), tab: page._targetId, event: "after-action", url: page.url(), frame: file }); + } + }, + }; + // Agent code can reach the session object; the hooks stay fixed. They + // are conveniences: the guards are native and do not depend on them. + Object.freeze(hooks); + Object.defineProperty(session, "agentTools", { value: hooks, writable: false, configurable: false, enumerable: false }); + + // ---- storage state ------------------------------------------------------------- + // Default scope: the sites (registrable domains) of one tab, so a saved + // state never carries the rest of the user's profile by accident. + // { all: true } saves everything; { urls } saves what those URLs see. + // The native session answers a host's site from the system's Public + // Suffix List, the one the driver scopes cookies.clear with. + const siteOf = (hostname) => policyHost("site", { host: String(hostname || "") }); + async function storageState(options = {}, fromPage) { + if (options === null || typeof options !== "object") throw new Error(`session.storageState: options: expected an object, got ${JSON.stringify(options)}`); + const urls = options.urls ? [].concat(options.urls) : null; + const page = fromPage || currentPage(); + let site = null; + if (!options.all && !urls) { + const url = page && !page._closed ? String(page.url()) : ""; + const hostname = /^https?:/i.test(url) ? new core.URL(url).hostname : ""; + if (!hostname) throw new Error(`session.storageState: the current tab (${url || "none"}) has no site to scope to; open the site first, or pass { all: true } for the whole profile or { urls: [...] }`); + site = siteOf(hostname); + } + const inScope = (hostname) => site === null || siteOf(hostname) === site; + // The cookies of the page's own data store (cookieScope). + const cookies = (await session.call("cookies.get", { ...cookieScope(page), ...(urls ? { urls } : {}) })).filter((c) => inScope(String(c.domain || ""))); + // localStorage only from the open tabs in that store (storeTabs). + const { targetIds } = await storeTabs(page); + const origins = new Map(); + for (const page of [...session.pages.values()]) { + if (page._closed || !targetIds.has(page._targetId)) continue; + for (const frame of [page._mainFrame, ...page._frames.values()]) { + if (frame._detached) continue; + const r = await frame._call("agent", "() => { try { return { origin: location.origin, items: Object.entries(localStorage) }; } catch (e) { return null; } }", []).catch(() => null); + if (!r || !r.origin || r.origin === "null") continue; + if (urls && !urls.some((u) => new core.URL(u).origin === r.origin)) continue; + if (!inScope(new core.URL(r.origin).hostname)) continue; + origins.set(r.origin, r.items.map(([name, value]) => ({ name, value }))); + } + } + const state = { cookies, origins: [...origins].map(([origin, localStorage]) => ({ origin, localStorage })) }; + if (options.path) fs.writeFileSync(options.path, JSON.stringify(state, null, 2)); + return state; + } + // A page's cookie calls name its tab, so the driver uses that tab's data + // store (a private tab's, or the session's proxy store), not another's. + function cookieScope(page) { + return page && !page._closed && typeof page._cookieScope === "function" ? page._cookieScope() : {}; + } + // The data store the page's cookie calls use (`tabs.dataStore`), and the + // open tabs in it. localStorage belongs to a store too, so storage state + // reads and writes it only through those tabs, never through a tab on + // the same origin in another store. A driver without `tabs.dataStore` + // gets the page's own tab only. + async function storeTabs(page) { + const scope = cookieScope(page); + let dataStore; + try { + ({ dataStore } = await session.call("tabs.dataStore", scope)); + } catch (e) { + if (errCode(e) !== "unsupported") throw e; + return { dataStore: undefined, targetIds: new Set(scope.targetId ? [scope.targetId] : []) }; + } + const list = await session.call("tabs.list", { all: true }); + return { dataStore, targetIds: new Set(list.filter((t) => t.dataStore === dataStore).map((t) => t.targetId)) }; + } + async function setStorageState(source, fromPage) { + const state = typeof source === "string" ? JSON.parse(fs.readFileSync(source, "utf8")) : source; + if (!state || typeof state !== "object" || (!Array.isArray(state.cookies) && !Array.isArray(state.origins))) { + throw new Error("session.setStorageState: expected { cookies, origins } (Playwright's storage state) or a path to one"); + } + const target = fromPage || currentPage(); + if (state.cookies && state.cookies.length) await session.call("cookies.set", { ...cookieScope(target), cookies: state.cookies }); + let restored = 0; + let store = null; + for (const { origin, localStorage } of state.origins || []) { + if (!localStorage || !localStorage.length) continue; + checkURL("session.setStorageState", origin); + // An open tab on the origin in the page's data store takes the + // items; otherwise a background tab of that store loads the origin, + // takes them and closes. + if (!store) store = await storeTabs(target); + let page = [...session.pages.values()].find((p) => !p._closed && store.targetIds.has(p._targetId) && /^https?:/.test(p.url()) && new core.URL(p.url()).origin === origin); + const temp = !page; + if (temp) { + page = await session.newPage(undefined, { background: true, dataStore: store.dataStore }); + await page.goto(origin + "/", { waitUntil: "domcontentloaded" }); + } + try { + await page._mainFrame._call("agent", "(items) => { for (const { name, value } of items) localStorage.setItem(name, value); }", [localStorage]); + restored++; + } finally { + if (temp) await page.close().catch(() => {}); + } + } + return { cookies: (state.cookies || []).length, origins: restored }; + } + + session.agentStorage = { storageState, setStorageState }; + + // ---- session members ------------------------------------------------------------- + Object.assign(sessionApi, { + // Navigations, new tabs, fetch and sites tools may reach only these + // domains; null clears. { lock: true } fixes the policy for the session. + allowedDomains: (list, options) => setPatterns("allowed", "session.allowedDomains", list, options), + prohibitedDomains: (list, options) => setPatterns("prohibited", "session.prohibitedDomains", list, options), + blockIPAddresses(on) { + if (on === undefined) return policyHost("get").blockIPs; + return policyHost("set", { blockIPs: !!on, title: "session.blockIPAddresses" }).blockIPs; + }, + blockedNavigations: () => policyLog.map((e) => ({ ...e })), + // Playwright browser-context options for the tabs this session created: + // { userAgent, extraHTTPHeaders, permissions, proxy }. null clears one. + configure, + configuration: () => JSON.parse(JSON.stringify(contextConfig)), + storageState, + setStorageState, + downloads: () => downloads.map((d) => ({ ...d })), + // Records each action of this session: trace.jsonl (method, URL, time), + // a PNG after each action and navigation, and on stop() run.png, an + // animated PNG of the run. + record(options = {}) { + if (recorder) throw new Error(`session.record: already recording to ${recorder.dir}; call stop() on it first`); + const dir = options.dir ? path.resolve(String(options.dir)) : path.join(host.tmpdir, `record-${++recordCount}`); + fs.mkdirSync(dir, { recursive: true }); + const r = { dir, trace: path.join(dir, "trace.jsonl"), screenshots: options.screenshots !== false, frames: 0, frameFiles: [], actions: 0, busy: false }; + fs.writeFileSync(r.trace, ""); + recorder = r; + return { + dir, + async stop() { + if (recorder === r) recorder = null; + const result = { dir, trace: r.trace, actions: r.actions, frames: r.frameFiles.length, animation: null }; + if (r.frameFiles.length) { + const apng = buildApng(r.frameFiles.map((f) => fs.readFileSync(f)), options.frameMs || 800); + result.animation = path.join(dir, "run.png"); + fs.writeFileSync(result.animation, apng.bytes); + } + return result; + }, + }; + }, + }); + + // ---- search ------------------------------------------------------------------------ + async function search(query, options = {}) { + if (typeof query !== "string" || !query.trim()) throw new Error(`search: query: expected a non-empty string, got ${JSON.stringify(query)}`); + const engine = options.engine || "duckduckgo"; + const limit = options.limit === undefined ? 10 : options.limit; + if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new Error(`search: limit: expected 1 to 50, got ${JSON.stringify(limit)}`); + if (engine === "google" && !options.endpoint) { + if (!globals.sites || !globals.sites.googleSearch) throw new Error("search: Google search needs sites.googleSearch"); + return (await globals.sites.googleSearch.search(query, { limit })).map((r) => ({ title: r.title, url: r.url, snippet: r.snippet || "" })); + } + if (!ENGINES[engine]) throw new Error(`search: engine: expected one of duckduckgo, bing, google, got ${JSON.stringify(engine)}`); + const template = options.endpoint || ENGINES[engine]; + const url = template.replace(/%s|\{query\}/, encodeURIComponent(query).replace(/%20/g, "+")); + const res = await fetch(url, { headers: { "accept-language": "en-US,en;q=0.9" } }); + if (!res.ok && res.status !== 202) throw new Error(`search: ${engine} returned HTTP ${res.status}`); + const html = await res.text(); + const page = await session.newPage(undefined, { background: true }); + try { + const r = await page.evaluate(parseResults, { html, engine, base: url, limit }); + if (r.blocked) throw new Error(`search: ${engine} showed a CAPTCHA; cmux does not solve CAPTCHAs. Try another engine, or open ${url} with tabs.open() and let the user answer it`); + return r.results; + } finally { + await page.close().catch(() => {}); + } + } + + // ---- custom tools ------------------------------------------------------------------ + const registry = new Map(); + const TYPES = { string: "string", number: "number", boolean: "boolean", object: "object", array: "array", any: "any" }; + function checkArgs(name, params, args) { + if (!params) return; + if (args === null || typeof args !== "object" || Array.isArray(args)) throw new Error(`tools.${name}: expected an object of arguments, got ${JSON.stringify(args)}`); + for (const [key, spec] of Object.entries(params)) { + const optional = spec.endsWith("?"); + const type = spec.replace(/\?$/, ""); + const v = args[key]; + if (v === undefined) { + if (!optional) throw new Error(`tools.${name}: ${key}: required (${type})`); + continue; + } + const actual = Array.isArray(v) ? "array" : v === null ? "null" : typeof v; + if (type !== "any" && actual !== type) throw new Error(`tools.${name}: ${key}: expected ${type}, got ${actual}`); + } + for (const key of Object.keys(args)) if (!(key in params)) throw new Error(`tools.${name}: unknown argument ${key}; expected ${Object.keys(params).join(", ") || "none"}`); + } + const tools = { + // register(name, fn, { description, params: { key: "string" | "number?" ... }, domains }) + register(name, fn, options = {}) { + if (typeof name !== "string" || !/^[A-Za-z_$][\w$]*$/.test(name) || name in tools) throw new Error(`tools.register: name: expected an identifier that is not ${Object.keys(tools).join(", ")}, got ${JSON.stringify(name)}`); + if (typeof fn !== "function") throw new Error(`tools.register: ${name}: expected a function`); + const params = options.params || null; + if (params) for (const [k, t] of Object.entries(params)) if (typeof t !== "string" || !TYPES[t.replace(/\?$/, "")]) throw new Error(`tools.register: ${name}: params.${k}: expected one of ${Object.keys(TYPES).join(", ")} (add ? when optional), got ${JSON.stringify(t)}`); + const domains = options.domains ? options.domains.map((d) => parsePattern(d, "tools.register")) : null; + const entry = { name, fn, description: String(options.description || ""), params, domains }; + if (registry.has(name)) delete tools[registry.get(name).name]; + registry.set(name, entry); + Object.defineProperty(tools, name, { value: (args) => tools.call(name, args), enumerable: false, configurable: true }); + return tools.describe(name); + }, + describe(name) { + const e = registry.get(name); + if (!e) throw new Error(`tools: no tool ${JSON.stringify(name)}; see tools.list()`); + return { name: e.name, description: e.description, params: e.params, domains: e.domains && e.domains.map((d) => d.raw) }; + }, + list: () => [...registry.keys()].map((n) => tools.describe(n)), + async call(name, args = {}) { + const e = registry.get(name); + if (!e) throw new Error(`tools: no tool ${JSON.stringify(name)}; see tools.list()`); + checkArgs(name, e.params, args); + const page = currentPage(); + if (e.domains && !e.domains.some((d) => urlMatches(page.url(), d, false))) throw new Error(`tools.${name}: available only on ${e.domains.map((d) => d.raw).join(", ")}; the current tab is ${page.url()}`); + return e.fn(args, { page, session: sessionApi, tabs: ctx.tabs }); + }, + unregister(name) { + if (!registry.has(name)) return false; + registry.delete(name); + delete tools[name]; + return true; + }, + }; + + Object.assign(globals, { secret, secrets, search, tools }); + } + + // --------------------------------------------------------------------------- + // Page and Locator additions. + + const P = core.Page.prototype; + + async function frameMarkdown(page, frame, opts, depth, budget) { + const r = await agentCall(frame, markdownOfFrame, opts); + let text = r.blocks.join("\n\n"); + if (!r.frames.length) return text; + let children = null; + try { + const found = await page._session.call("frame.contentFrames", { targetId: page._targetId, frameId: frame._id || undefined, elements: r.frames }); + children = found.map((f) => (f ? page._frameFor(f.frameId, frame) : null)); + } catch (e) { + if (errCode(e) !== "unsupported") throw e; + children = []; + for (const h of r.frames) children.push(await frame._contentFrame(h).catch(() => null)); + } + const parts = await Promise.all(children.map((child) => (child && depth < 6 && budget.frames-- > 0 ? frameMarkdown(page, child, { ...opts, main: false }, depth + 1, budget).catch(() => "") : ""))); + return text.replace(/\u0000F(\d+)\u0000/g, (_, i) => parts[Number(i)] || "").replace(/\n{3,}/g, "\n\n").trim(); + } + + // The page as Markdown: headings, paragraphs, lists, tables, code, links, + // form values (never passwords), iframes and shadow roots in place. + // { main: true } keeps the main content (
, the one
, else + // the page without navigation, banner, footer and sidebars). { links: false } + // drops link URLs, { images: true } keeps images. { start, maxChars } cut at + // block boundaries; a cut says where to continue. + P.markdown = async function (options = {}) { + if (options === null || typeof options !== "object") throw new Error(`page.markdown: options: expected an object, got ${JSON.stringify(options)}`); + const opts = { main: !!options.main, links: options.links !== false, images: !!options.images }; + await this._syncInfo().catch(() => {}); + const full = (await frameMarkdown(this, this._mainFrame, opts, 0, { frames: 100 })) + "\n"; + const start = options.start || 0; + const max = options.maxChars === undefined ? Infinity : options.maxChars; + if (!Number.isInteger(start) || start < 0) throw new Error(`page.markdown: start: expected a non-negative integer, got ${JSON.stringify(options.start)}`); + if (!(max > 0)) throw new Error(`page.markdown: maxChars: expected a positive number, got ${JSON.stringify(options.maxChars)}`); + if (start === 0 && full.length <= max) return full; + if (start >= full.length) throw new Error(`page.markdown: start ${start} is past the end (${full.length} characters)`); + let end = Math.min(full.length, start + max); + if (end < full.length) { + const cut = full.lastIndexOf("\n\n", end); + const line = full.lastIndexOf("\n", end); + end = cut > start ? cut + 2 : line > start ? line + 1 : end; + } + let text = full.slice(start, end); + // A cut inside a table repeats its header row. + const before = full.slice(0, start); + const tableStart = before.lastIndexOf("\n\n") + 2; + if (start > 0 && /^\|/.test(text) && /^\|/.test(full.slice(tableStart))) { + const header = full.slice(tableStart).split("\n").slice(0, 2); + if (header.length === 2 && /^\|( --- \|)+$/.test(header[1]) && full.slice(tableStart, start).includes("\n")) text = header.join("\n") + "\n" + text; + } + const commas = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ","); + const tail = end < full.length ? `; continue with page.markdown({ start: ${end}${options.maxChars !== undefined ? `, maxChars: ${options.maxChars}` : ""}${options.main ? ", main: true" : ""} })` : ""; + return text.replace(/\n*$/, "\n") + `\n\n`; + }; + + // Structured data by selectors, no model: page.extract({ $: ".product", + // name: "h3", price: ".price", url: "a@href" }). { scope: ref | locator } + // limits it to one element; { limit } caps each list (1000). + P.extract = async function (schema, options = {}) { + let frame = this._mainFrame; + let scope = null; + if (options.scope) { + const loc = typeof options.scope === "string" ? this.locator(options.scope) : options.scope; + if (!(loc instanceof core.Locator)) throw new Error("page.extract: scope: expected a ref, selector or locator"); + const r = await loc._resolveOne(true); + if (!r) throw new Error(`page.extract: scope ${loc} matched nothing`); + frame = r.frame; + scope = r.handle; + } + const unpair = (v) => { + if (Array.isArray(v)) return v.map(unpair); + if (v && typeof v === "object" && Array.isArray(v.__cmuxPairs)) { + const o = {}; + for (const [k, x] of v.__cmuxPairs) o[k] = unpair(x); + return o; + } + return v; + }; + const pair = (v) => { + if (Array.isArray(v)) return v.map(pair); + if (v && typeof v === "object") return { __cmuxPairs: Object.entries(v).map(([k, x]) => [k, pair(x)]) }; + return v; + }; + return unpair(await agentCall(frame, extractInFrame, pair(schema), { scope, limit: options.limit })); + }; + + // Text matches with surrounding context and the ref of the element each is + // in: { total, matches: [{ match, context, ref }] }. + P.searchText = async function (pattern, options = {}) { + if (typeof pattern !== "string" || !pattern) throw new Error(`page.searchText: pattern: expected a non-empty string, got ${JSON.stringify(pattern)}`); + let frame = this._mainFrame; + let scope = null; + if (options.scope) { + const loc = typeof options.scope === "string" ? this.locator(options.scope) : options.scope; + const r = await loc._resolveOne(true); + if (!r) throw new Error(`page.searchText: scope ${loc} matched nothing`); + frame = r.frame; + scope = r.handle; + } + const r = await agentCall(frame, searchTextInFrame, { pattern, regex: !!options.regex, caseSensitive: !!options.caseSensitive, context: options.context === undefined ? 60 : options.context, limit: options.limit === undefined ? 25 : options.limit, scope }); + const matches = []; + for (const m of r.matches) { + let ref = null; + if (m.handle) ref = await this._refForHandle(frame, m.handle).catch(() => null); + matches.push({ match: m.match, context: m.context, ref }); + } + return { total: r.total, matches }; + }; + + const targetLocator = (page, target, title) => { + if (typeof target === "string") return page.locator(target); + if (target instanceof core.Locator) return target; + throw new Error(`${title}: expected a ref, selector or locator, got ${JSON.stringify(target)}`); + }; + + // Scrolls the first element with this text into view; returns its ref. + P.scrollToText = async function (text, options = {}) { + if (typeof text !== "string" || !text) throw new Error(`page.scrollToText: text: expected a non-empty string, got ${JSON.stringify(text)}`); + const loc = this.getByText(text, { exact: !!options.exact }).first(); + await loc.scrollIntoViewIfNeeded({ timeout: options.timeout === undefined ? 5000 : options.timeout }); + const r = await loc._resolveOne(false); + return r ? this._refForHandle(r.frame, r.handle) : null; + }; + + // Where the page, or a scrollable element, is scrolled: + // { x, y, viewportHeight, scrollHeight, pagesAbove, pagesBelow }. + P.scrollInfo = async function (target) { + if (target === undefined) return agentCall(this._mainFrame, scrollInfoInFrame, null); + const r = await targetLocator(this, target, "page.scrollInfo")._resolveOne(true); + if (!r) throw new Error(`page.scrollInfo: ${target} matched nothing`); + return agentCall(r.frame, scrollInfoInFrame, r.handle); + }; + + // Scrolls by pages with real wheel events, over the page or an element + // (negative pages scroll up); returns scrollInfo afterwards. + P.scroll = async function (options = {}) { + const pages = options.pages === undefined ? 1 : options.pages; + if (typeof pages !== "number" || !Number.isFinite(pages) || pages === 0) throw new Error(`page.scroll: pages: expected a non-zero number, got ${JSON.stringify(options.pages)}`); + let point; + let info; + if (options.target !== undefined) { + const loc = targetLocator(this, options.target, "page.scroll"); + await loc.scrollIntoViewIfNeeded(); + const box = await loc.boundingBox(); + if (!box) throw new Error(`page.scroll: ${options.target} is not visible`); + point = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + info = await this.scrollInfo(options.target); + } else { + info = await this.scrollInfo(); + const vp = await this._mainFrame._call("agent", "() => ({ w: innerWidth, h: innerHeight })", []); + point = { x: vp.w / 2, y: vp.h / 2 }; + } + // A scroll position set by script (scrollIntoView, scrollToText) reaches + // WebKit's scrolling tree at the next rendering update; a wheel sent + // before it starts from the old position and is dropped. + await this._mainFrame._call("agent", "() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))", []); + await this.mouse.move(point.x, point.y); + await this.mouse.wheel(0, Math.round(pages * info.viewportHeight)); + let last = null; + for (let i = 0; i < 20; i++) { + const now = options.target !== undefined ? await this.scrollInfo(options.target) : await this.scrollInfo(); + if (last && now.y === last.y && now.y !== info.y) return now; + last = now; + await this._session.sleep(50); + } + return last; + }; + + // The options of a native and controls no listbox or menu; open the drop-down first, then pass it or its listbox`); + if (d.kind === "aria" && !d.options.length) throw new Error(`page.dropdownOptions: the listbox of ${target} shows no options; click it to open it, then call again`); + const out = []; + for (const o of d.options) { + const row = { index: o.index, label: o.label, value: o.value, selected: o.selected, disabled: o.disabled }; + if (o.handle) row.ref = await this._refForHandle(r.frame, o.handle).catch(() => null); + out.push(row); + } + return out; + }; + + // Boxes and ref labels over elements, until hideHighlight() or the next + // highlight: every interactive element without targets (reference C's + // highlight_elements), else the given refs or locators. Returns the count. + P.highlight = async function (targets) { + await this.hideHighlight(); + if (targets === undefined) { + this._highlightClear = await ns.snapshot.annotate(this); + return null; + } + const byFrame = new Map(); + for (const t of [].concat(targets)) { + const r = await targetLocator(this, t, "page.highlight")._resolveAll(); + if (!r) continue; + for (const h of r.handles) { + const ref = await this._refForHandle(r.frame, h); + if (!byFrame.has(r.frame)) byFrame.set(r.frame, []); + byFrame.get(r.frame).push([ref.replace(/^f\d+/, ""), ref]); + } + } + let n = 0; + for (const [frame, pairs] of byFrame) n += await frame._agent("annotate", pairs); + const frames = [...byFrame.keys()]; + this._highlightClear = async () => { + for (const f of frames) await f._agent("clearAnnotations").catch(() => {}); + }; + return n; + }; + P.hideHighlight = async function () { + const clear = this._highlightClear; + this._highlightClear = null; + if (clear) await clear(); + }; + core.Locator.prototype.highlight = async function () { + await this._page.highlight(this); + }; + + // Playwright's context.storageState / setStorageState names. + const context = P.context; + P.context = function () { + const c = context.call(this); + const storage = this._session.agentStorage; + // Scoped like session.storageState, to this page's site. + if (storage) Object.assign(c, { storageState: (options = {}) => storage.storageState(options, this), setStorageState: (source) => storage.setStorageState(source, this) }); + return c; + }; + + ns.agentTools = { install, urlMatches, parsePattern, normalizeHost, policyContentRules, isIPHost, totp, base32Decode, sha1, crc32, buildApng, pngChunks, parseResults, markdownOfFrame }; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/Resources/browser-repl/api.js b/Resources/browser-repl/api.js new file mode 100644 index 000000000000..0eb6b04e4d27 --- /dev/null +++ b/Resources/browser-repl/api.js @@ -0,0 +1,776 @@ +// cmux browser REPL globals (docs/browser-repl/README.md, Globals): page, +// tabs, snapshot, screenshot, fetch, fs/path/os/Buffer, sleep, display, +// session, console. Built on runtime-core (Playwright model) and snapshot.js. +(function (root) { + "use strict"; + const ns = (root.CmuxBrowserRepl = root.CmuxBrowserRepl || {}); + const core = ns.core; + const { Buffer } = core; + + // --------------------------------------------------------------------------- + // node:path (POSIX) + + function normalizeParts(parts, allowAboveRoot) { + const out = []; + for (const p of parts) { + if (!p || p === ".") continue; + if (p === "..") { + if (out.length && out[out.length - 1] !== "..") out.pop(); + else if (allowAboveRoot) out.push(".."); + } else out.push(p); + } + return out; + } + function createPath(cwd) { + const path = { + sep: "/", + delimiter: ":", + isAbsolute: (p) => String(p).startsWith("/"), + normalize(p) { + p = String(p); + const abs = path.isAbsolute(p); + const trailing = /\/$/.test(p); + let s = normalizeParts(p.split("/"), !abs).join("/"); + if (!s && !abs) s = "."; + if (s && trailing) s += "/"; + return (abs ? "/" : "") + s; + }, + join(...parts) { + const joined = parts.map(String).filter((p) => p !== "").join("/"); + return joined ? path.normalize(joined) : "."; + }, + resolve(...parts) { + let resolved = ""; + for (let i = parts.length - 1; i >= 0 && !path.isAbsolute(resolved); i--) { + const part = String(parts[i]); + if (part) resolved = part + (resolved ? "/" + resolved : ""); + } + if (!path.isAbsolute(resolved)) resolved = cwd() + "/" + resolved; + return "/" + normalizeParts(resolved.split("/"), false).join("/"); + }, + dirname(p) { + const s = String(p).replace(/\/+$/, ""); + const i = s.lastIndexOf("/"); + if (i < 0) return "."; + return i === 0 ? "/" : s.slice(0, i); + }, + basename(p, ext) { + let b = String(p).replace(/\/+$/, "").split("/").pop(); + if (ext && b.endsWith(ext) && b !== ext) b = b.slice(0, -ext.length); + return b; + }, + extname(p) { + const b = path.basename(p); + const i = b.lastIndexOf("."); + return i <= 0 ? "" : b.slice(i); + }, + relative(from, to) { + const a = path.resolve(from).split("/").filter(Boolean); + const b = path.resolve(to).split("/").filter(Boolean); + let i = 0; + while (i < a.length && i < b.length && a[i] === b[i]) i++; + return [...a.slice(i).map(() => ".."), ...b.slice(i)].join("/"); + }, + parse(p) { + const base = path.basename(p); + const ext = path.extname(p); + return { root: path.isAbsolute(p) ? "/" : "", dir: path.dirname(p), base, ext, name: ext ? base.slice(0, -ext.length) : base }; + }, + format(o) { + return (o.dir ? o.dir + "/" : o.root || "") + (o.base || (o.name || "") + (o.ext || "")); + }, + }; + path.posix = path; + return path; + } + + // --------------------------------------------------------------------------- + // node:fs on the host's synchronous fs operations. The host confines paths + // to the session directory and the temporary directory. + + function createFs(host, path) { + const op = (name, args) => host.fsOp(name, args); + const toPath = (p) => (p && typeof p === "object" && p.href ? decodeURIComponent(String(p.pathname)) : String(p)); + const abs = (p) => path.resolve(toPath(p)); + const encodingOf = (o) => (typeof o === "string" ? o : o && o.encoding) || null; + const bytesOf = (data, o) => { + if (typeof data === "string") return Buffer.from(data, encodingOf(o) || "utf8"); + if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) return Buffer.from(data); + return Buffer.from(String(data)); + }; + const statOf = (s) => ({ + size: s.size, + mtimeMs: s.mtimeMs, + birthtimeMs: s.birthtimeMs, + mtime: new Date(s.mtimeMs), + birthtime: new Date(s.birthtimeMs || s.mtimeMs), + isFile: () => s.type === "file", + isDirectory: () => s.type === "directory", + isSymbolicLink: () => s.type === "symlink", + }); + const sync = { + readFileSync(p, o) { + const bytes = Buffer.from(op("readFile", { path: abs(p) }), "base64"); + const enc = encodingOf(o); + return enc ? bytes.toString(enc) : bytes; + }, + writeFileSync: (p, data, o) => void op("writeFile", { path: abs(p), base64: bytesOf(data, o).toString("base64") }), + appendFileSync: (p, data, o) => void op("writeFile", { path: abs(p), base64: bytesOf(data, o).toString("base64"), append: true }), + mkdirSync(p, o) { + const target = abs(p); + const recursive = !!(o && o.recursive); + const existed = recursive && op("exists", { path: target }); + op("mkdir", { path: target, recursive }); + return recursive && !existed ? target : undefined; + }, + readdirSync(p, o) { + const entries = op("readdir", { path: abs(p) }); + if (o && o.withFileTypes) { + return entries.map((e) => ({ + name: e.name, + isFile: () => e.type === "file", + isDirectory: () => e.type === "directory", + isSymbolicLink: () => e.type === "symlink", + })); + } + return entries.map((e) => e.name); + }, + statSync: (p) => statOf(op("stat", { path: abs(p) })), + lstatSync: (p) => statOf(op("lstat", { path: abs(p) })), + existsSync(p) { + try { + return !!op("exists", { path: abs(p) }); + } catch { + return false; + } + }, + accessSync(p) { + if (!sync.existsSync(p)) { + const e = new Error(`ENOENT: no such file or directory, access '${toPath(p)}'`); + e.code = "ENOENT"; + throw e; + } + }, + rmSync: (p, o) => void op("rm", { path: abs(p), recursive: !!(o && o.recursive), force: !!(o && o.force) }), + rmdirSync: (p, o) => void op("rm", { path: abs(p), recursive: !!(o && o.recursive) }), + unlinkSync: (p) => void op("rm", { path: abs(p) }), + renameSync: (from, to) => void op("rename", { from: abs(from), to: abs(to) }), + copyFileSync: (from, to) => void op("copyFile", { from: abs(from), to: abs(to) }), + realpathSync: (p) => op("resolve", { path: abs(p) }), + mkdtempSync(prefix) { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + for (let attempt = 0; attempt < 16; attempt++) { + let suffix = ""; + for (let i = 0; i < 6; i++) suffix += chars[Math.floor(Math.random() * chars.length)]; + const dir = abs(String(prefix) + suffix); + if (op("exists", { path: dir })) continue; + op("mkdir", { path: dir }); + return dir; + } + const e = new Error(`EEXIST: file already exists, mkdtemp '${prefix}XXXXXX'`); + e.code = "EEXIST"; + throw e; + }, + }; + const promises = {}; + const fs = { promises }; + for (const [key, fn] of Object.entries(sync)) { + fs[key] = fn; + if (key === "existsSync") continue; + const name = key.replace(/Sync$/, ""); + promises[name] = async (...args) => fn(...args); + // Node's callback form; without a callback it returns a promise. + fs[name] = (...args) => { + const cb = typeof args[args.length - 1] === "function" ? args.pop() : null; + const p = promises[name](...args); + if (!cb) return p; + p.then((v) => cb(null, v), (e) => cb(e)); + return undefined; + }; + } + fs.exists = (p, cb) => { + const v = sync.existsSync(p); + if (typeof cb === "function") cb(v); + return Promise.resolve(v); + }; + fs.constants = { F_OK: 0, R_OK: 4, W_OK: 2, X_OK: 1 }; + return fs; + } + + function createOs(host) { + return { + tmpdir: () => host.tmpdir, + homedir: () => host.homedir, + platform: () => "darwin", + type: () => "Darwin", + arch: () => "arm64", + EOL: "\n", + }; + } + + // --------------------------------------------------------------------------- + // Printing (close to Node's util.inspect for common values) + + function imageSize(bytes) { + if (bytes.length > 24 && bytes[0] === 0x89 && bytes[1] === 0x50) { + const u32 = (i) => ((bytes[i] << 24) | (bytes[i + 1] << 16) | (bytes[i + 2] << 8) | bytes[i + 3]) >>> 0; + return { type: "png", width: u32(16), height: u32(20) }; + } + if (bytes[0] === 0xff && bytes[1] === 0xd8) { + for (let i = 2; i + 9 < bytes.length;) { + if (bytes[i] !== 0xff) break; + const marker = bytes[i + 1]; + const len = (bytes[i + 2] << 8) | bytes[i + 3]; + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + return { type: "jpeg", height: (bytes[i + 5] << 8) | bytes[i + 6], width: (bytes[i + 7] << 8) | bytes[i + 8] }; + } + i += 2 + len; + } + return { type: "jpeg", width: 0, height: 0 }; + } + return { type: "png", width: 0, height: 0 }; + } + + // An image that shows itself when printed: the bytes go to a file in the + // temporary directory and the path is printed, so an agent can open it. + class Image { + constructor(bytes) { + this.buffer = Buffer.from(bytes); + const size = imageSize(this.buffer); + this.type = size.type; + this.width = size.width; + this.height = size.height; + } + get base64() { + return this.buffer.toString("base64"); + } + toString() { + return `Image ${this.width}x${this.height} ${this.type}`; + } + } + + function inspect(value, depth = 0, seen = new Set()) { + const nested = depth > 0; + if (typeof value === "string") return nested ? `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'` : value; + if (value === null || value === undefined || typeof value === "number" || typeof value === "boolean") return String(value); + if (typeof value === "bigint") return value + "n"; + if (typeof value === "symbol") return value.toString(); + if (typeof value === "function") return value.prototype && /^class\b/.test(Function.prototype.toString.call(value)) ? `[class ${value.name}]` : `[Function: ${value.name || "(anonymous)"}]`; + if (value instanceof Error) return nested ? `[${value.name}: ${value.message}]` : value.stack && value.stack.includes(value.message) ? value.stack : `${value.name}: ${value.message}`; + if (value instanceof ns.snapshot.Snapshot) return nested ? "[Snapshot]" : String(value); + if (value instanceof Image) return `[${value}]`; + if (value instanceof core.Page) return `Page { id: '${value.id}', url: '${value.url()}' }`; + if (value instanceof core.Locator) return value.toString(); + if (value instanceof core.ConsoleMessage) return `ConsoleMessage { type: '${value.type()}', text: ${inspect(value.text(), 1)} }`; + if (value instanceof Date) return isNaN(value) ? "Invalid Date" : value.toISOString(); + if (Object.prototype.toString.call(value) === "[object RegExp]") return String(value); + if (typeof value.then === "function") return "Promise { }"; + if (seen.has(value)) return "[Circular]"; + if (depth > 3) return Array.isArray(value) ? "[Array]" : "[Object]"; + seen.add(value); + try { + const wrap = (open, items, close) => { + if (!items.length) return open + close; + const single = `${open} ${items.join(", ")} ${close}`; + if (single.length <= 72 && !single.includes("\n")) return single; + const pad = " ".repeat(depth + 1); + return `${open}\n${items.map((i) => pad + i).join(",\n")}\n${" ".repeat(depth)}${close}`; + }; + if (Buffer.isBuffer(value)) { + const hex = Array.from(value.subarray(0, 50), (b) => b.toString(16).padStart(2, "0")).join(" "); + return ` 50 ? ` ... ${value.length - 50} more bytes` : ""}>`; + } + if (ArrayBuffer.isView(value)) { + const items = Array.from(value.subarray(0, 100), String); + if (value.length > 100) items.push(`... ${value.length - 100} more items`); + return `${value.constructor.name}(${value.length}) ${wrap("[", items, "]")}`; + } + if (Array.isArray(value)) { + const items = value.slice(0, 100).map((v) => inspect(v, depth + 1, seen)); + if (value.length > 100) items.push(`... ${value.length - 100} more items`); + return wrap("[", items, "]"); + } + if (value instanceof Map) { + return `Map(${value.size}) ${wrap("{", [...value].map(([k, v]) => `${inspect(k, depth + 1, seen)} => ${inspect(v, depth + 1, seen)}`), "}")}`; + } + if (value instanceof Set) return `Set(${value.size}) ${wrap("{", [...value].map((v) => inspect(v, depth + 1, seen)), "}")}`; + const keys = Object.keys(value); + const items = keys.map((k) => `${/^[A-Za-z_$][\w$]*$/.test(k) ? k : `'${k}'`}: ${inspect(value[k], depth + 1, seen)}`); + const ctor = value.constructor && value.constructor !== Object && value.constructor.name ? value.constructor.name + " " : ""; + return ctor + wrap("{", items, "}"); + } finally { + seen.delete(value); + } + } + + // --------------------------------------------------------------------------- + // Content export (page.exportContent, tabs.content) + + // Runs in the page: its visible content as Markdown. + function pageMarkdown() { + const skip = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "SVG", "CANVAS", "IFRAME"]); + const clean = (t) => t.replace(/\s+/g, " "); + const hidden = (el) => { const cs = getComputedStyle(el); return cs.display === "none" || cs.visibility === "hidden"; }; + const inline = (node) => { + if (node.nodeType === 3) return clean(node.textContent); + if (node.nodeType !== 1 || skip.has(node.tagName) || hidden(node)) return ""; + const inner = [...node.childNodes].map(inline).join(""); + if (node.tagName === "A" && node.getAttribute("href")) return inner.trim() ? `[${inner.trim()}](${node.href})` : ""; + if (node.tagName === "B" || node.tagName === "STRONG") return inner.trim() ? `**${inner.trim()}**` : ""; + if (node.tagName === "EM" || node.tagName === "I") return inner.trim() ? `*${inner.trim()}*` : ""; + if (node.tagName === "CODE") return "`" + inner + "`"; + if (node.tagName === "IMG") return node.alt ? `![${node.alt}](${node.src})` : ""; + if (node.tagName === "BR") return "\n"; + if (node.tagName === "INPUT" || node.tagName === "TEXTAREA") return node.type === "password" ? "" : node.value ? `\`${node.value}\`` : ""; + return inner; + }; + const out = []; + const block = (node, depth) => { + if (node.nodeType === 3) { + const t = clean(node.textContent).trim(); + if (t) out.push(t); + return; + } + if (node.nodeType !== 1 || skip.has(node.tagName) || hidden(node)) return; + const tag = node.tagName; + const m = /^H([1-6])$/.exec(tag); + if (m) return void out.push("#".repeat(Number(m[1])) + " " + inline(node).trim()); + if (tag === "P" || tag === "SUMMARY" || tag === "LABEL" || tag === "BUTTON") return void (inline(node).trim() && out.push(inline(node).trim())); + if (tag === "PRE") return void out.push("```\n" + node.innerText + "\n```"); + if (tag === "UL" || tag === "OL") { + let n = 0; + for (const li of node.children) if (li.tagName === "LI") out.push(`${" ".repeat(depth)}${tag === "OL" ? `${++n}.` : "-"} ${inline(li).trim()}`); + return; + } + if (tag === "TABLE") { + const rows = [...node.rows].map((r) => "| " + [...r.cells].map((c) => inline(c).trim().replace(/\|/g, "\\|")).join(" | ") + " |"); + if (rows.length) out.push([rows[0], "| " + [...node.rows[0].cells].map(() => "---").join(" | ") + " |", ...rows.slice(1)].join("\n")); + return; + } + if (tag === "BLOCKQUOTE") return void out.push("> " + inline(node).trim()); + const hasBlock = [...node.children].some((c) => /^(DIV|P|H[1-6]|UL|OL|TABLE|SECTION|ARTICLE|MAIN|NAV|HEADER|FOOTER|ASIDE|FORM|PRE|BLOCKQUOTE|DETAILS|FIELDSET|FIGURE|LI)$/.test(c.tagName)); + if (!hasBlock) { + const t = inline(node).trim(); + if (t) out.push(t); + return; + } + for (const c of node.childNodes) block(c, depth); + }; + block(document.body || document.documentElement, 0); + return `# ${document.title}\n\n<${location.href}>\n\n` + out.filter(Boolean).join("\n\n") + "\n"; + } + + // Google Workspace export endpoints for a Docs, Sheets or Slides URL. + const GOOGLE_FORMATS = { + document: ["pdf", "md", "docx", "txt", "odt", "rtf", "html", "epub"], + spreadsheets: ["pdf", "xlsx", "csv", "tsv", "ods"], + presentation: ["pdf", "pptx", "odp", "txt"], + }; + function googleExportURL(pageURL, format) { + let u; + try { + u = new core.URL(pageURL); + } catch { + u = null; + } + const m = u && u.protocol === "https:" && u.hostname === "docs.google.com" ? /^\/(document|spreadsheets|presentation)\/d\/([\w-]+)/.exec(u.pathname) : null; + if (!m) throw new Error(`page.exportContent({ format }): expected a Google Docs, Sheets or Slides tab (https://docs.google.com/...), got ${pageURL}`); + const [, kind, id] = m; + const allowed = GOOGLE_FORMATS[kind]; + if (!allowed.includes(format)) throw new Error(`page.exportContent: format: expected one of ${allowed.join(", ")} for a Google ${kind === "document" ? "Docs document" : kind === "spreadsheets" ? "Sheets spreadsheet" : "Slides presentation"}, got ${JSON.stringify(format)}`); + if (kind === "presentation") return { url: `https://docs.google.com/presentation/d/${id}/export/${format}`, kind, id }; + const gid = kind === "spreadsheets" ? /[#&?]gid=(\d+)/.exec(pageURL) : null; + return { url: `https://docs.google.com/${kind}/d/${id}/export?format=${format}${gid ? `&gid=${gid[1]}` : ""}`, kind, id }; + } + + function youtubeVideoId(pageURL) { + let u; + try { + u = new core.URL(pageURL); + } catch { + return null; + } + if (u.protocol !== "https:" || !/^(www\.|m\.)?youtube\.com$/.test(u.hostname) || u.pathname !== "/watch") return null; + return u.searchParams.get("v"); + } + + // The hosts a YouTube caption track URL may name. Track URLs come from page + // data and caption fetches send the session's cookies, so every caption + // fetch (page.exportContent, sites.youtube) goes through youtubeCaptionURL. + const YOUTUBE_CAPTION_HOSTS = Object.freeze(["www.youtube.com", "m.youtube.com", "youtube.com"]); + // A caption track URL resolved against `base`, or null when it is not https + // on one of YOUTUBE_CAPTION_HOSTS. + function youtubeCaptionURL(raw, base = "https://www.youtube.com") { + try { + const u = new core.URL(String(raw), base); + return u.protocol === "https:" && YOUTUBE_CAPTION_HOSTS.includes(u.hostname) ? u : null; + } catch { + return null; + } + } + + // YouTube's json3 caption format to plain text, one caption per line. + function transcriptText(json3) { + const lines = []; + for (const ev of (json3 && json3.events) || []) { + const text = (ev.segs || []).map((s) => s.utf8 || "").join("").replace(/\s+/g, " ").trim(); + if (text) lines.push(text); + } + return lines.join("\n") + (lines.length ? "\n" : ""); + } + + function createExporter({ fetch, fs, path, host, Buffer }) { + let n = 0; + const target = (options, ext) => { + if (options.path) return path.resolve(String(options.path)); + // The session's own temporary directory (private, mode 0700). + const dir = host.tmpdir; + fs.mkdirSync(dir, { recursive: true }); + return path.join(dir, `export-${++n}${ext}`); + }; + return { + async markdown(page, options) { + const text = await page.evaluate(pageMarkdown); + const file = target(options, ".md"); + fs.writeFileSync(file, text); + return file; + }, + async google(page, pageURL, options) { + const { url } = googleExportURL(pageURL, options.format); + const r = await fetch(url); + if (!r.ok) throw new Error(`page.exportContent: Google returned HTTP ${r.status} for ${url}`); + const file = target(options, "." + options.format); + fs.writeFileSync(file, Buffer.from(await r.arrayBuffer())); + return file; + }, + async youtubeTranscript(page, pageURL, options) { + const id = youtubeVideoId(pageURL); + if (!id) throw new Error(`page.exportContent({ transcript: true }): expected a YouTube watch page (https://www.youtube.com/watch?v=...), got ${pageURL}`); + const tracks = await page.evaluate(() => { + const r = window.ytInitialPlayerResponse; + const list = r && r.captions && r.captions.playerCaptionsTracklistRenderer && r.captions.playerCaptionsTracklistRenderer.captionTracks; + return (list || []).map((t) => ({ baseUrl: t.baseUrl, lang: t.languageCode, kind: t.kind || null })); + }); + if (!tracks.length) throw new Error(`page.exportContent: video ${id} has no captions`); + const usable = tracks.map((t) => ({ ...t, url: youtubeCaptionURL(t.baseUrl, pageURL) })).filter((t) => t.url); + if (!usable.length) throw new Error(`page.exportContent: video ${id} has no captions on YouTube's caption hosts (${YOUTUBE_CAPTION_HOSTS.join(", ")})`); + const want = options.lang ? usable.find((t) => t.lang === options.lang) : usable.find((t) => t.kind !== "asr") || usable[0]; + if (!want) throw new Error(`page.exportContent: video ${id} has no ${options.lang} captions; available: ${usable.map((t) => t.lang).join(", ")}`); + const r = await fetch(want.url.href + "&fmt=json3"); + if (!r.ok) throw new Error(`page.exportContent: captions request returned HTTP ${r.status}`); + const file = target(options, ".txt"); + fs.writeFileSync(file, transcriptText(await r.json())); + return file; + }, + }; + } + + function createGlobals(session, host) { + const workDir = () => host.workDir || "/"; + const path = createPath(workDir); + const fs = createFs(host, path); + const os = createOs(host); + const out = (level, text) => host.print(level, text); + const state = { current: null, name: null, images: 0 }; + + session.files = { + read: async (p) => fs.readFileSync(p), + readAbsolute: async (p) => fs.readFileSync(p), + write: async (p, bytes) => fs.writeFileSync(p, bytes), + }; + + const modules = { + fs, + "fs/promises": fs.promises, + path, + os, + buffer: { Buffer }, + }; + function importModule(specifier) { + const name = String(specifier).replace(/^node:/, ""); + const mod = modules[name]; + if (!mod) throw new Error(`Cannot import ${specifier}: the cmux browser REPL provides node:fs, node:fs/promises, node:path, node:os and node:buffer`); + return Object.assign({ default: mod }, mod); + } + + function currentPage() { + if (!state.current || state.current.isClosed()) state.current = session.lazyPage(); + return state.current; + } + + function imageLine(image) { + // The session's own temporary directory (private, mode 0700). + const dir = host.tmpdir; + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, `image-${++state.images}.${image.type === "jpeg" ? "jpg" : "png"}`); + fs.writeFileSync(file, image.buffer); + return `[${image}: ${file}]`; + } + + // Shows a value the way the REPL prints its last expression. + function show(value) { + if (value === undefined) return; + if (value instanceof Image) out("log", imageLine(value)); + else out("log", inspect(value)); + } + + const consoleApi = {}; + for (const [method, level] of [["log", "log"], ["info", "info"], ["debug", "debug"], ["warn", "warn"], ["error", "error"], ["trace", "log"]]) { + consoleApi[method] = (...args) => { + out(level, args.map((a) => inspect(a)).join(" ")); + }; + } + consoleApi.dir = (v) => { + out("log", inspect(v)); + }; + consoleApi.table = consoleApi.dir; + + function splitTarget(target, options) { + if (target && typeof target === "object" && !(target instanceof core.Page) && !(target instanceof core.Locator)) return [undefined, target]; + return [target, options || {}]; + } + const pageOf = (target) => (target instanceof core.Page ? target : target instanceof core.Locator ? target._page : currentPage()); + + async function snapshot(target, options) { + [target, options] = splitTarget(target, options); + const page = pageOf(target); + return ns.snapshot.takeSnapshot(page, target instanceof core.Page ? undefined : target, options); + } + + async function screenshot(target, options) { + [target, options] = splitTarget(target, options); + const page = pageOf(target); + const scoped = typeof target === "string" ? page.ref(target) : target instanceof core.Locator ? target : null; + const clear = options.annotate ? await ns.snapshot.annotate(page, scoped || undefined) : null; + let bytes; + try { + const shot = { type: options.type, quality: options.quality, fullPage: options.fullPage, clip: options.clip }; + bytes = scoped ? await scoped.screenshot(shot) : await page.screenshot(shot); + } finally { + if (clear) await clear(); + } + if (options.path) fs.writeFileSync(options.path, bytes); + return new Image(bytes); + } + + class Headers { + constructor(init) { + this._map = new Map(); + for (const [k, v] of Object.entries(init || {})) this._map.set(k.toLowerCase(), String(v)); + } + get(k) { + const v = this._map.get(String(k).toLowerCase()); + return v === undefined ? null : v; + } + has(k) { + return this._map.has(String(k).toLowerCase()); + } + forEach(fn) { + for (const [k, v] of this._map) fn(v, k, this); + } + entries() { + return this._map.entries(); + } + keys() { + return this._map.keys(); + } + [Symbol.iterator]() { + return this._map.entries(); + } + } + + // Standard fetch that sends, and stores, the current tab's cookies. + // `credentials`: "include" (the default here: cookies for every URL), + // "same-origin" (only for the current tab's origin) or "omit" (none sent, + // none stored). The native session checks the domain policy on every + // redirect hop, caps the body at 64 MiB and masks secrets in text bodies. + async function fetchWithCookies(input, init = {}) { + const page = state.current && !state.current.isClosed() ? state.current : null; + const base = page && /^https?:/.test(page.url()) ? page.url() : undefined; + const url = new core.URL(String(input && input.url ? input.url : input), base).href; + const credentials = init.credentials === undefined ? "include" : init.credentials; + if (!["include", "same-origin", "omit"].includes(credentials)) throw new TypeError(`fetch: credentials: expected "include", "same-origin" or "omit", got ${JSON.stringify(credentials)}`); + const origin = base ? new core.URL(base).origin : undefined; + if (session.agentTools) session.agentTools.checkURL("fetch", url); + const headers = {}; + const src = init.headers || {}; + if (typeof src.forEach === "function" && !Array.isArray(src)) src.forEach((v, k) => (headers[k] = v)); + else if (Array.isArray(src)) for (const [k, v] of src) headers[k] = v; + else Object.assign(headers, src); + const sendsCookies = credentials === "include" || (credentials === "same-origin" && origin === new core.URL(url).origin); + if (!host.fetchHandlesCookies && sendsCookies && !Object.keys(headers).some((k) => k.toLowerCase() === "cookie")) { + const scope = page && !String(page._targetId).startsWith("lazy:") ? { targetId: page._targetId } : {}; + const cookies = await session.call("cookies.get", { ...scope, urls: [url] }).catch(() => []); + if (cookies.length) headers.cookie = cookies.map((c) => `${c.name}=${c.value}`).join("; "); + } + const body = init.body === undefined || init.body === null ? undefined : Buffer.from(init.body).toString("base64"); + const targetId = page && !String(page._targetId).startsWith("lazy:") ? page._targetId : undefined; + const r = await host.fetch(url, { method: (init.method || "GET").toUpperCase(), headers, body, targetId, credentials, origin }); + const bytes = Buffer.from(r.base64 || "", "base64"); + return { + ok: r.status >= 200 && r.status < 300, + status: r.status, + statusText: r.statusText || "", + url: r.url || url, + redirected: !!r.redirected, + headers: new Headers(r.headers), + text: async () => bytes.toString("utf8"), + json: async () => JSON.parse(bytes.toString("utf8")), + arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + bytes: async () => new Uint8Array(bytes), + }; + } + + async function pageById(id) { + if (id instanceof core.Page) return id; + // Any tab tabs.list({ all: true }) lists can be attached (a claim). + const list = await session.call("tabs.list", { all: true }); + if (!list.some((t) => t.targetId === String(id))) throw new Error(`No open tab with id ${JSON.stringify(String(id))}; see tabs.list()`); + const page = session.pageFor(String(id)); + await page._syncInfo().catch(() => {}); + return page; + } + + session.exporter = createExporter({ fetch: fetchWithCookies, fs, path, host, Buffer }); + + const tabs = { + // `{ all: true }` also lists browser tabs in the user's other + // workspaces and windows; tabs.use(id) attaches any of them. + async list(options = {}) { + const list = await session.call("tabs.list", options && options.all ? { all: true } : {}); + const current = state.current && state.current._targetId; + return list.map((t) => { + // state: "live", "hibernated" (cmux unloaded the hidden page to + // save memory; the next call on it loads it again), "waking" or + // "crashed" (page.reload() loads it again). + const row = { id: t.targetId, title: t.title, url: t.url, active: !!t.active, current: t.targetId === current, state: t.state || "live" }; + if (options && options.all) row.workspace = t.windowId === undefined ? null : t.windowId; + return row; + }); + }, + // Loads each URL in a background tab, extracts it and closes the tab; + // the current tab does not change. format: "text" (default), + // "markdown", "html" or "snapshot". + async content(input, options = {}) { + const opts = Array.isArray(input) || typeof input === "string" ? { ...options, urls: [].concat(input) } : { ...(input || {}) }; + const urls = opts.urls; + if (!Array.isArray(urls) || !urls.length || urls.some((u) => typeof u !== "string" || !u)) throw new Error(`tabs.content: urls: expected a non-empty array of URLs, got ${JSON.stringify(urls)}`); + const format = opts.format || "text"; + if (!["text", "markdown", "html", "snapshot"].includes(format)) throw new Error(`tabs.content: format: expected one of text, markdown, html, snapshot, got ${JSON.stringify(format)}`); + const timeout = opts.timeout !== undefined ? opts.timeout : 30000; + const one = async (url) => { + const page = await session.newPage(undefined, { background: true }); + try { + const response = await page.goto(url, { timeout, waitUntil: opts.waitUntil || "load" }); + let content; + if (format === "html") content = await page.content(); + else if (format === "snapshot") content = String((await ns.snapshot.takeSnapshot(page, undefined, { maxChars: Infinity })).tree); + else if (format === "markdown") content = await page.evaluate(ns.api.pageMarkdown); + else content = await page.evaluate(() => (document.body ? document.body.innerText : "")); + return { url: page.url(), title: await page.title(), status: response ? response.status() : null, content }; + } catch (e) { + return { url, title: null, status: null, content: null, error: String((e && e.message) || e) }; + } finally { + await page.close().catch(() => {}); + } + }; + const out = []; + // A few at a time, in the order given. + for (let i = 0; i < urls.length; i += 4) out.push(...(await Promise.all(urls.slice(i, i + 4).map(one)))); + return out; + }, + // cmux's browser history, most recent first: [{ url, title, dateVisited }]. + async history(options = {}) { + if (options === null || typeof options !== "object") throw new Error(`tabs.history: options: expected an object, got ${JSON.stringify(options)}`); + const limit = options.limit === undefined ? 100 : options.limit; + if (!Number.isInteger(limit) || limit < 1) throw new Error(`tabs.history: limit: expected a positive integer, got ${JSON.stringify(limit)}`); + const date = (v, name) => { + if (v === undefined) return undefined; + const d = v instanceof Date ? v : new Date(v); + if (isNaN(d.getTime())) throw new Error(`tabs.history: ${name}: expected a date, got ${JSON.stringify(v)}`); + return d.getTime(); + }; + const from = date(options.from, "from"); + const to = date(options.to, "to"); + const queries = [].concat(options.query === undefined ? [] : options.query, options.queries || []).map(String).filter(Boolean); + const rows = await session.call("history.search", { queries, from, to, limit }); + return rows.map((r) => ({ url: r.url, title: r.title || "", dateVisited: new Date(r.dateVisited).toISOString() })); + }, + async open(url, options = {}) { + if (url !== undefined && url !== null && url !== "") ns.checkNavigableURL("tabs.open", url); + const page = await session.newPage(url, { background: !!options.background }); + if (url) await page.waitForLoadState("load").catch(() => {}); + await page._syncInfo().catch(() => {}); + if (!options.background) state.current = page; + return page; + }, + current: () => currentPage(), + async use(tabOrId) { + state.current = await pageById(tabOrId); + return state.current; + }, + get: (id) => pageById(id), + }; + + const sessionApi = { + get id() { + return host.sessionId || null; + }, + async name(label) { + state.name = String(label); + try { + await session.call("session.name", { name: state.name }); + } catch (e) { + if (e && e.code !== "unsupported") throw e; + } + return state.name; + }, + keep: (page) => (page || currentPage()).keep(), + guide: () => (host.readResource ? host.readResource("guide.md") : null), + }; + + const globals = { + tabs, + snapshot, + screenshot, + fetch: fetchWithCookies, + fs, + path, + os, + Buffer, + require: (name) => importModule(name).default, + sleep: (ms) => session.sleep(ms), + display: (value) => { + show(value); + }, + session: sessionApi, + console: consoleApi, + URL: core.URL, + URLSearchParams: core.URLSearchParams, + }; + Object.defineProperty(globals, "page", { + get: () => currentPage(), + set: (v) => { + state.current = v; + }, + enumerable: true, + configurable: true, + }); + // Site tools (docs/browser-repl/site-tools.md), built on first use. + if (ns.sites) { + let sites = null; + Object.defineProperty(globals, "sites", { + get: () => sites || (sites = ns.sites.createSites({ session, host, fetch: fetchWithCookies, fs, path, Buffer, URL: core.URL, currentPage, snapshot })), + enumerable: true, + configurable: true, + }); + } + // Reference C parity tools (agent-tools.js): secrets, domain policy, + // storage state, downloads, recording, search, custom tools, Markdown and + // structured extraction. + if (ns.agentTools) ns.agentTools.install({ session, host, globals, sessionApi, fetch: fetchWithCookies, fs, path, currentPage, tabs, show }); + return { globals, show, importModule, state }; + } + + ns.api = { createGlobals, createPath, createFs, inspect, Image, imageSize, pageMarkdown, googleExportURL, youtubeVideoId, youtubeCaptionURL, YOUTUBE_CAPTION_HOSTS, transcriptText }; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/Resources/browser-repl/guide.md b/Resources/browser-repl/guide.md new file mode 100644 index 000000000000..c0701b8769db --- /dev/null +++ b/Resources/browser-repl/guide.md @@ -0,0 +1,219 @@ +# `cmux browser repl` + +Run JavaScript in a persistent JavaScriptCore session that drives the browser +panes of your cmux workspace. The API is Playwright: `page`, locators, +`keyboard`, `mouse`, events and waits behave as in Playwright. Input is native: +pages see trusted events. + +## Usage + + cmux browser repl 'await page.goto("https://example.com"); snapshot()' + cmux browser repl --eval - < script.js + cmux browser repl --session work 'const s1 = await snapshot()' + cmux browser repl list | reset | guide + +Without `--session` each call is one-shot: its tabs close at the end unless +`page.keep()` was called. With `--session NAME`, top-level `const`/`let` +bindings and tabs persist until `reset NAME` or 30 minutes idle. A session +binds to your cmux workspace, or to the focused workspace outside cmux. + +- The last expression's value prints (a promise is awaited first); + `undefined` prints nothing. `console.log()` prints too. +- 120 second timeout per call (`--timeout `). +- A call prints at most 25,000 characters (`--max-output `, `0` for + no limit). Past that, its whole output goes to a file: the start, the + last lines and the file's path print. Read the file with `fs` or your + own tools. +- `page` is ready at once: the first use opens a tab. Nothing you do takes + the user's focus: `tabs.open()`, input, dialogs and waking a tab never + change the user's window, workspace, pane, tab or keyboard focus, also when + the user works in the same workspace. Only `page.bringToFront()` shows a + tab, and a `sites.browserAuth` sign-in sheet asks the user to type. + +## Globals + +- `page`: the current tab, a Playwright `Page` with a stable `page.id`. +- `tabs`: `list()`, `open(url, { background })`, `current()`, `use(tabOrId)`, + `get(id)`. `list()` returns `{ id, title, url, active, current, state }` + for every tab in the workspace without attaching or waking it; `list({ all: true })` adds the + user's tabs in other workspaces and windows (with `workspace`), and + `use(id)` takes any of them; `use(id)` and `get(id)` return a `Page`. + `content({ urls, format })` loads URLs in background tabs and returns + `[{ url, title, status, content }]` (`format`: `text`, `markdown`, + `html`, `snapshot`) without changing the current tab. + `history({ query, from, to, limit })` returns cmux browser history, + newest first, as `{ url, title, dateVisited }`. +- `snapshot(target?, options?)`: accessibility snapshot of `page`, a locator or + a ref. Options: `interactive` (controls plus headings and landmarks), + `viewport` (only what is on screen), `showHidden`, `maxChars` (print + budget, below), `options` (one line per option), and `urls` (every + link's `[url=…]`; links with no name show it anyway). +- `screenshot(target?, options?)`: an image of the viewport, `{ fullPage }`, a + locator or a ref. `{ annotate: true }` draws each ref's box and label. + Printing an image saves it to a file and prints the path. +- `fetch(url, init)`: standard fetch with the current tab's cookies + (`credentials: "same-origin"` or `"omit"` to send fewer); bodies over + 64 MiB fail, download those in a tab. +- `fs`, `path`, `os`, `Buffer`: Node APIs. Files are limited to the directory + you ran the command in and the system temp directory. `import("node:fs")` + and `require("fs")` return the same modules. +- `sleep(ms)`, `display(value)`, `console`. +- `session`: `name(label)` labels this session's tabs; `keep(page)` keeps a + tab after a one-shot run; `id`; `guide()` returns this text. + `allowedDomains(["example.com", "*.example.org"], { lock })`, + `prohibitedDomains([...])` and `blockIPAddresses(true)` limit navigations, + new tabs, `fetch` (every redirect), site tools and the subresources of + tabs this session opened. A tab this session opened never loads a blocked page (the + navigation is cancelled and the action fails); a tab you claimed stays + where it is, but reads and input on it fail while it shows a blocked + page. `blockedNavigations()` lists the blocks. The policy is enforced + outside this JavaScript context, so `{ lock: true }` cannot be undone. `configure({ userAgent, extraHTTPHeaders, permissions, proxy })` + sets browser-context options for the tabs this session opened (a tab you + claimed keeps its own; `null` clears one; a proxy applies to tabs opened afterwards, in a private + profile without your cookies). `storageState({ path })` + and `setStorageState(stateOrPath)` save and restore cookies and + localStorage (Playwright's format); a save covers the current tab's site + only, `{ all: true }` the whole profile, `{ urls }` those URLs. `downloads()` lists downloads. + `record()` returns a recorder; `stop()` writes `trace.jsonl`, a PNG per + action and `run.png`, an animated PNG of the run. +- `secrets.set(name, value, { domains, totp })` or `secrets.load(file)` + (`{ "": { name: value } }`) registers a secret; type it with + `locator.fill(secret("name"))` or `locator.type(secret("name"))`. It is + typed only into frames on its domains, and its value prints, reads and + saves as `` everywhere. `{ totp: true }` types the current + one-time code of a base32 seed. +- `search(query, { engine, limit })`: `[{ title, url, snippet }]` from + DuckDuckGo (default), Bing or Google. +- `tools.register(name, fn, { description, params, domains })` adds a + callable `tools.name(args)` to the session; `fn(args, { page, session, + tabs })`; `params` like `{ q: "string", n: "number?" }` are checked. +- `page.exportContent()` writes the page as Markdown and returns the file's + path; `{ format: "pdf" }` (or `md`, `docx`, `xlsx`, `csv`, `pptx`, ...) + exports a Google Docs, Sheets or Slides tab; `{ transcript: true }` writes a + YouTube watch page's captions as text. +- To drop files on a drop zone: + `locator.dispatchEvent("drop", { dataTransfer: { files: [path or { name, mimeType, buffer }] } })`. + +## Snapshot + + title: Sign up + url: http://localhost:8765/ + - navigation "Main" [ref=e1]: + - link "Home" [ref=e2] + - main: + - heading "Sign up" [level=1] + - textbox "Email" [ref=e3] [placeholder="you@x.com"]: "me@x.com" + - checkbox "Accept terms" [ref=e4] [checked] + - combobox "Plan" [ref=e5] [options: Free, Pro, Team]: "Pro" + - table "Scores": + - row [header]: "Name | Score" + - row: + - cell: "Ada" + - link "Profile" [ref=e6] + - iframe "Payment" [ref=e7]: + - textbox "Card" [ref=f1e1] + +- Refs (`e5`, `f1e2` inside a frame) work anywhere a selector works: + `page.locator("e5").click()`, `page.ref("e5")`, `snapshot("e5")`, + `screenshot("e5")`. A ref names one element for its life and is never + reused. A removed element's ref fails at once with `ref e5 is stale`; take + a new snapshot then. +- Refs mark controls, iframes, scrollable regions and named landmarks, + dialogs and lists. States: `[checked]`, `[checked=mixed]`, `[disabled]`, + `[expanded]`, `[expanded=false]`, `[pressed]`, `[selected]`, `[focused]`, + `[required]`, `[invalid]`, `[readonly]`, `[level=N]`, `[scrollable]`. +- Only what a user can see prints: collapsed, `display:none`, + `content-visibility:hidden`, inert and `aria-hidden` content does not + (`showHidden` adds it). Names come from content only for buttons, links, + headings and similar leaves, so nothing prints twice. Tables print + `row: "a | b"` when every cell is text; layout tables flatten. +- Frames (also cross-origin and nested) and shadow roots, closed ones too, + are inlined; refs and locators work inside them. +- An open dialog or file chooser prints first, under the header. +- Printing a snapshot shows its diff against the previous snapshot of the + same tab when that is shorter (for a large page, 30% shorter), else the + full tree. `.tree` and `.diff` are always there. Diff lines start with `+` + (added), `-` (removed) or `~` (changed, new version); unchanged ancestors + are shown for context. +- A printed snapshot is at most 20,000 characters (`maxChars`). A larger + page prints condensed: on-screen controls, the focused element and the + headings and landmarks first, then the page from the top; long runs of + similar items (list items, rows, cards) keep their first few. Each cut + is one line, `- … 480 more listitem (480 refs): snapshot("e12")`, and a + last `# condensed …` line says what is left out. Refs in the cut part + work. For more: `snapshot("e12")` for that region, + `snapshot({ viewport: true })` after scrolling, or + `snapshot({ maxChars: Infinity })` and `.tree` for everything (search + `.tree` in code rather than printing it). +- Refs stay valid until their element is removed, also when its name or + state changes, so there is no need to take a new snapshot after every + action; a stale ref fails at once and says so. + +## Dialogs and file choosers + +With a `page.on("dialog")` or `page.on("filechooser")` listener (including +`waitForEvent`), Playwright rules apply. Without one, in a tab you opened, the +dialog or chooser stays open and shows in the snapshot. While a JavaScript +dialog is open the page cannot run script, so page calls fail with a message +that says so. A tab you did not open (`tabs.use()` of the user's tab) is the +user's: its dialogs, file choosers, downloads and permission prompts go to +the user unless you have a listener for that event on the page, or your own +click, key, drag, navigation or `page.evaluate()` (within its first +second) opened the dialog or chooser, which then comes to you as in a tab you opened; a window your +action opens there comes to you as a `popup` and stays the user's tab. A dialog +that opens during Meta+C, Meta+X or Meta+V is dismissed at once, so it +cannot hold the clipboard command; listeners still get it, and the next +snapshot prints `dialog dismissed: ...` once. + + page.dialog() // { type, message, defaultValue, accept(text?), dismiss() } or null + page.fileChooser() // { multiple, setFiles(paths), cancel() } or null + +## Hibernated and crashed tabs + +`state` in `tabs.list()` is `live`, `hibernated` (cmux unloaded the hidden +page to save memory), `waking` (it is loading again) or `crashed`. Any call +on a hibernated tab loads it again first, in the background, and waits up to +30 s; it fails with `hibernated` when the user stopped that load or it ended +without a page, and with a timeout when it is still loading (retry). A +crashed tab fails every call but navigation with `crashed`; call +`page.reload()` or `page.goto(url)`. Errors name the tab as +`tab ("title", url)`. + +## Page additions + +- `page.consoleMessages({ level, filter, limit })`, `page.errors()`: console + and uncaught-error history of the tab. +- `page.clipboard`: `readText()`, `writeText(text)`, `read()`, `write(items)` + on the tab's own clipboard, which Meta+C, Meta+X and Meta+V use. Those + shortcuts work only in tabs you opened; a page that keeps one running + past 5 s crashes its tab (`page.reload()` brings it back). In tabs you + opened, what the page's own scripts copy (a Copy button's + `navigator.clipboard.writeText` or `execCommand("copy")` after your + click) also lands here, never on the system clipboard, so + `page.clipboard.readText()` returns it. +- `page.elementAt(x, y)`: `{ ref, role, name, box }` at a viewport point. +- `page.keep()`: keep this tab after a one-shot run. +- `page.markdown({ main, links, images, start, maxChars })`: the page as + Markdown with iframes and shadow roots in place; `main: true` keeps the + main content; a cut ends with where to continue. +- `page.extract({ $: ".item", name: "h3", url: "a@href", tags: ["li"] })`: + structured data by selectors; `"sel@attr"` reads an attribute, `["sel"]` + every match, `{ $: sel, ...fields }` one object per match. +- `page.searchText(pattern, { regex, caseSensitive, context, scope, limit })`: + `{ total, matches: [{ match, context, ref }] }`. +- `page.scrollToText(text)` (returns the ref), `page.scroll({ pages, target })` + (native wheel, negative pages scroll up), `page.scrollInfo(target?)` + (`{ y, pagesAbove, pagesBelow, ... }`). +- `page.dropdownOptions(ref)`: options of a `,
diff --git a/tests/browser-parity/fixtures/input.html b/tests/browser-parity/fixtures/input.html new file mode 100644 index 000000000000..ee226c6961e6 --- /dev/null +++ b/tests/browser-parity/fixtures/input.html @@ -0,0 +1,23 @@ +Input Fixture + +

Input

+ + + +
Drag
Drop zone
+
+

none

+
start
+ + diff --git a/tests/browser-parity/fixtures/log.js b/tests/browser-parity/fixtures/log.js new file mode 100644 index 000000000000..a440f8e3e02e --- /dev/null +++ b/tests/browser-parity/fixtures/log.js @@ -0,0 +1,12 @@ +// Shared event log. Every page records trusted-ness so scenarios can prove +// input arrived as real user input, not synthetic DOM events. +window.__log = []; +function __record(e) { + const t = e.target; + const id = t && t.id ? "#" + t.id : t && t.tagName ? t.tagName.toLowerCase() : String(t); + window.__log.push({ type: e.type, id, trusted: e.isTrusted, key: e.key, code: e.code, button: e.button, detail: e.detail }); +} +for (const type of ["click", "dblclick", "contextmenu", "mousedown", "mouseup", "keydown", "keyup", "input", "change", "focus", "blur", "dragstart", "drop", "wheel", "pointerdown"]) { + addEventListener(type, __record, true); +} +window.__summary = () => window.__log.map((e) => `${e.type}${e.id ? " " + e.id : ""}${e.key ? " key=" + e.key : ""}${e.trusted ? "" : " UNTRUSTED"}`); diff --git a/tests/browser-parity/fixtures/nest-l1.html b/tests/browser-parity/fixtures/nest-l1.html new file mode 100644 index 000000000000..804f5c23daa4 --- /dev/null +++ b/tests/browser-parity/fixtures/nest-l1.html @@ -0,0 +1,6 @@ +Level one +

Level one (peer origin)

+ + diff --git a/tests/browser-parity/fixtures/nest-l2.html b/tests/browser-parity/fixtures/nest-l2.html new file mode 100644 index 000000000000..267e346c8ee1 --- /dev/null +++ b/tests/browser-parity/fixtures/nest-l2.html @@ -0,0 +1,6 @@ +Level two +

Level two (primary origin)

+ + diff --git a/tests/browser-parity/fixtures/nest-l3.html b/tests/browser-parity/fixtures/nest-l3.html new file mode 100644 index 000000000000..f70a399bea5c --- /dev/null +++ b/tests/browser-parity/fixtures/nest-l3.html @@ -0,0 +1,7 @@ +Level three + +
+ diff --git a/tests/browser-parity/fixtures/nest-top.html b/tests/browser-parity/fixtures/nest-top.html new file mode 100644 index 000000000000..2765d952ff93 --- /dev/null +++ b/tests/browser-parity/fixtures/nest-top.html @@ -0,0 +1,8 @@ +Nested Frames +

Top

+ + + + diff --git a/tests/browser-parity/fixtures/search-bing.html b/tests/browser-parity/fixtures/search-bing.html new file mode 100644 index 000000000000..5d16f2c6f864 --- /dev/null +++ b/tests/browser-parity/fixtures/search-bing.html @@ -0,0 +1,6 @@ +Bing fixture +
    +
  1. cmux on Bing

    Terminal with notifications for agents.

  2. +
  3. Ad

  4. +
  5. Second Bing result

    Second snippet.

  6. +
diff --git a/tests/browser-parity/fixtures/search-duckduckgo.html b/tests/browser-parity/fixtures/search-duckduckgo.html new file mode 100644 index 000000000000..91cdb86b7910 --- /dev/null +++ b/tests/browser-parity/fixtures/search-duckduckgo.html @@ -0,0 +1,15 @@ +DuckDuckGo fixture + diff --git a/tests/browser-parity/fixtures/shadow.html b/tests/browser-parity/fixtures/shadow.html new file mode 100644 index 000000000000..59deefdb6406 --- /dev/null +++ b/tests/browser-parity/fixtures/shadow.html @@ -0,0 +1,9 @@ +Shadow Fixture +

Shadow DOM

none

+ diff --git a/tests/browser-parity/fixtures/states.html b/tests/browser-parity/fixtures/states.html new file mode 100644 index 000000000000..e3ac152a95bc --- /dev/null +++ b/tests/browser-parity/fixtures/states.html @@ -0,0 +1,29 @@ +States Fixture +
+

States

+
+ + + + + + + +
+ +
Paris
+
  • Paris
  • Rome
+
UserAction
Ada
Gracenone
+

Links: relative, peer, script.

+
Term
Definition
+ +

Spaces around + inline elements stay single.

+

line one

line two

line three

line four

+
+ + diff --git a/tests/browser-parity/fixtures/stress/frame.html b/tests/browser-parity/fixtures/stress/frame.html new file mode 100644 index 000000000000..5a7e026fa01a --- /dev/null +++ b/tests/browser-parity/fixtures/stress/frame.html @@ -0,0 +1,16 @@ + + +Stress frame + + + diff --git a/tests/browser-parity/fixtures/stress/stress.html b/tests/browser-parity/fixtures/stress/stress.html new file mode 100644 index 000000000000..f9af284cb53a --- /dev/null +++ b/tests/browser-parity/fixtures/stress/stress.html @@ -0,0 +1,128 @@ + + +Stress + + +

Stress page

+
+ + diff --git a/tests/browser-parity/fixtures/surface.html b/tests/browser-parity/fixtures/surface.html new file mode 100644 index 000000000000..9c1a2eef351f --- /dev/null +++ b/tests/browser-parity/fixtures/surface.html @@ -0,0 +1,24 @@ +Surface Fixture +

Surface

+ + + + +

+ + +
More

Details body

+
+
Drag
Drop zone
+ +
Far text
+ diff --git a/tests/browser-parity/fixtures/targets.html b/tests/browser-parity/fixtures/targets.html new file mode 100644 index 000000000000..896487d8e0e3 --- /dev/null +++ b/tests/browser-parity/fixtures/targets.html @@ -0,0 +1,22 @@ +Targets Fixture + +
+

Targets

+

Cited claimJump up and more text.

+★ +Main content +

Docs elsewhere Example net Local page

+
GuidesGuide one
+
A commit message long enough to run past the edge of its cell (#9)
+
+ + +
+

Waiting

+
+ diff --git a/tests/browser-parity/fixtures/visibility.html b/tests/browser-parity/fixtures/visibility.html new file mode 100644 index 000000000000..bd361974bc5f --- /dev/null +++ b/tests/browser-parity/fixtures/visibility.html @@ -0,0 +1,50 @@ +Visibility Fixture + +

Visibility

+
Closed details

Details body text

+ + + + +

Content-visibility text

Content-visibility link
+ +
Inert text
+ +
+
Hidden parent text Visible child text
+Screen reader text +

Names

+ + + + + + + +
Scores
NameScore
Ada9
LinusProfile and notes
+ +
    +

    A link whose text runs long: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor.

    +

    Layout table

    + + + +
    Site | new
    + + + + +
    1.upvoteStory one (example.com)
    10 points by ada | 3 comments
    2.upvoteStory two
    +
    + diff --git a/tests/browser-parity/gate.sh b/tests/browser-parity/gate.sh new file mode 100755 index 000000000000..92b2a14b4d6d --- /dev/null +++ b/tests/browser-parity/gate.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Browser REPL regression gate: every suite that proves behavior, in one +# command. Run it before each push; a round that changes behavior must keep it +# green or change goldens with a reviewed reason. +# +# tests/browser-parity/gate.sh # unit, sites, cmux-dev, oracle +# PARITY_CMUX_CLI= CMUX_SOCKET_PATH=/tmp/cmux-debug-.sock \ +# tests/browser-parity/gate.sh --app [--app-runs N] # plus the real app +set -uo pipefail +cd "$(dirname "$0")/../.." + +app=0 +app_runs=1 +while [ $# -gt 0 ]; do + case "$1" in + --app) app=1 ;; + --app-runs) app_runs="$2"; shift ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac + shift +done + +failed=() +step() { + local name="$1"; shift + echo "== $name" + if "$@"; then echo " ok"; else echo " FAILED"; failed+=("$name"); fi +} + +step unit node --test tests/browser-parity/unit/*.test.mjs +step sites node --test tests/browser-parity/sites/*.test.mjs +step cmux-dev node tests/browser-parity/run.mjs check --backend cmux-dev +step oracle node tests/browser-parity/run.mjs check --backend oracle +if [ "$app" = 1 ]; then + : "${PARITY_CMUX_CLI:?set PARITY_CMUX_CLI to the tagged app cmux CLI}" + for i in $(seq 1 "$app_runs"); do + step "app run $i/$app_runs" node tests/browser-parity/run.mjs check --backend cmux + done +fi + +if [ ${#failed[@]} -gt 0 ]; then + echo "gate FAILED: ${failed[*]}" + exit 1 +fi +echo "gate passed" diff --git a/tests/browser-parity/goldens/01-snapshot.json b/tests/browser-parity/goldens/01-snapshot.json new file mode 100644 index 000000000000..6653c1d472d7 --- /dev/null +++ b/tests/browser-parity/goldens/01-snapshot.json @@ -0,0 +1,19 @@ +{ + "oracle": {}, + "cmux": { + "full": "title: Parity Fixture\nurl: PRIMARY/\n- navigation \"Main\" [ref=e1]:\n - link \"Home\" [ref=e2]\n - link \"Docs\" [ref=e3]\n- main:\n - heading \"Sign up\" [level=1]\n - textbox \"Email\" [ref=e4] [placeholder=\"you@x.com\"]\n - checkbox \"Accept terms\" [ref=e5]\n - combobox \"Plan\" [ref=e6] [options: Free, Pro, Team]: \"Pro\"\n - textbox \"Bio\" [ref=e7]: \"hi\"\n - button \"Create account\" [ref=e8]\n - button \"Disabled\" [ref=e9] [disabled]\n - list:\n - listitem: \"One\"\n - listitem: \"Two\"\n - text: \"Plain bold text.\"\n - img \"Logo\"\n - button \"Far away\" [ref=e10]", + "prints-tree-first": true, + "interactive": "title: Parity Fixture\nurl: PRIMARY/\n- navigation \"Main\" [ref=e1]:\n - link \"Home\" [ref=e2]\n - link \"Docs\" [ref=e3]\n- main:\n - heading \"Sign up\" [level=1]\n - textbox \"Email\" [ref=e4] [placeholder=\"you@x.com\"]\n - checkbox \"Accept terms\" [ref=e5]\n - combobox \"Plan\" [ref=e6] [options: Free, Pro, Team]: \"Pro\"\n - textbox \"Bio\" [ref=e7]: \"hi\"\n - button \"Create account\" [ref=e8]\n - button \"Disabled\" [ref=e9] [disabled]\n - button \"Far away\" [ref=e10]", + "scoped-ref": "title: Parity Fixture\nurl: PRIMARY/\n- navigation \"Main\" [ref=e1]:\n - link \"Home\" [ref=e2]\n - link \"Docs\" [ref=e3]", + "scoped-locator": "title: Parity Fixture\nurl: PRIMARY/\n- textbox \"Email\" [ref=e4] [placeholder=\"you@x.com\"]\n- checkbox \"Accept terms\" [ref=e5]\n- combobox \"Plan\" [ref=e6] [options: Free, Pro, Team]: \"Pro\"\n- textbox \"Bio\" [ref=e7]: \"hi\"\n- button \"Create account\" [ref=e8]\n- button \"Disabled\" [ref=e9] [disabled]", + "options": [ + " - combobox \"Plan\" [ref=e6]: \"Pro\"", + " - option \"Free\"", + " - option \"Pro\" [selected]", + " - option \"Team\"" + ], + "max-chars": "title: Parity Fixture\nurl: PRIMARY/\n- navigation \"Main\" [ref=e1]:\n - link \"Home\" [ref=e2]\n - link \"Docs\" [ref=e3]\n- main:\n - … 1 more line\n - textbox \"Email\" [ref=e4] [placeholder=\"you@x.com\"]\n - checkbox \"Accept terms\" [ref=e5]\n - … 10 more lines (5 refs)\n# condensed to 226 of 516 characters (5 of 10 refs not shown): snapshot(ref) prints a region, snapshot({ viewport: true }) what is on screen, snapshot(locator, { maxChars: Infinity }) or .tree everything", + "max-chars-tree-complete": true, + "page-arg": true + } +} diff --git a/tests/browser-parity/goldens/02-aria.json b/tests/browser-parity/goldens/02-aria.json new file mode 100644 index 000000000000..fc0305485d49 --- /dev/null +++ b/tests/browser-parity/goldens/02-aria.json @@ -0,0 +1,8 @@ +{ + "oracle": {}, + "cmux": { + "full": "title: ARIA Fixture\nurl: PRIMARY/aria.html\n- banner:\n - heading \"ARIA roles\" [level=1]\n- navigation \"Breadcrumb\" [ref=e1]:\n - list:\n - link \"Root\" [ref=e2]\n - link \"Here\" [ref=e3]\n- main:\n - heading \"Names\" [level=2]\n - text: \"Labelled by span\"\n - textbox \"Labelled by span\" [ref=e4]\n - textbox \"Label for\" [ref=e5]: \"prefilled\"\n - textbox \"Title only\" [ref=e6]\n - button \"Icon button\" [ref=e7]\n - button \"Described\" [ref=e8]\n - text: \"extra description\"\n - heading \"Level three\" [level=3]\n - heading \"Level four\" [level=4]\n - heading \"Widgets\" [level=2]\n - tablist \"Tabs\" [ref=e9]:\n - tab \"First\" [ref=e10] [selected]\n - tab \"Second\" [ref=e11]\n - tabpanel: \"Panel one\"\n - button \"Menu\" [ref=e12] [expanded=false]\n - button \"Bold\" [ref=e13] [pressed]\n - switch \"Wifi\" [ref=e14] [checked]\n - slider \"Volume\" [ref=e15]: \"3\"\n - group \"Size\":\n - radio \"Small\" [ref=e16] [checked]\n - radio \"Large\" [ref=e17]\n - progressbar \"Upload\": \"40\"\n - alert: \"Heads up\"\n - table \"Scores\":\n - row [header]: \"Name | Score\"\n - row: \"Ada | 9\"\n - tree \"Files\" [ref=e18]:\n - treeitem \"src\" [ref=e19] [expanded]:\n - treeitem \"main.swift\" [ref=e20]\n - button \"More\" [ref=e21] [expanded=false]\n - heading \"Visibility\" [level=2]\n - text: \"Offscreen text\"\n - dialog \"Inline dialog\" [ref=e22]:\n - text: \"Dialog body\"\n - button \"OK\" [ref=e23]\n - link \"Anchor link\" [ref=e24]\n - generic [ref=e25]: \"Clickable div\"\n - textbox \"Editor\" [ref=e26]: \"Editable text\"\n- contentinfo: \"Footer text\"", + "interactive": "title: ARIA Fixture\nurl: PRIMARY/aria.html\n- banner:\n - heading \"ARIA roles\" [level=1]\n- navigation \"Breadcrumb\" [ref=e1]:\n - link \"Root\" [ref=e2]\n - link \"Here\" [ref=e3]\n- main:\n - heading \"Names\" [level=2]\n - textbox \"Labelled by span\" [ref=e4]\n - textbox \"Label for\" [ref=e5]: \"prefilled\"\n - textbox \"Title only\" [ref=e6]\n - button \"Icon button\" [ref=e7]\n - button \"Described\" [ref=e8]\n - heading \"Level three\" [level=3]\n - heading \"Level four\" [level=4]\n - heading \"Widgets\" [level=2]\n - tablist \"Tabs\" [ref=e9]:\n - tab \"First\" [ref=e10] [selected]\n - tab \"Second\" [ref=e11]\n - button \"Menu\" [ref=e12] [expanded=false]\n - button \"Bold\" [ref=e13] [pressed]\n - switch \"Wifi\" [ref=e14] [checked]\n - slider \"Volume\" [ref=e15]: \"3\"\n - group \"Size\":\n - radio \"Small\" [ref=e16] [checked]\n - radio \"Large\" [ref=e17]\n - tree \"Files\" [ref=e18]:\n - treeitem \"src\" [ref=e19] [expanded]:\n - treeitem \"main.swift\" [ref=e20]\n - button \"More\" [ref=e21] [expanded=false]\n - heading \"Visibility\" [level=2]\n - dialog \"Inline dialog\" [ref=e22]:\n - button \"OK\" [ref=e23]\n - link \"Anchor link\" [ref=e24]\n - generic [ref=e25]: \"Clickable div\"\n - textbox \"Editor\" [ref=e26]: \"Editable text\"", + "show-hidden": "title: ARIA Fixture\nurl: PRIMARY/aria.html\n- banner:\n - heading \"ARIA roles\" [level=1]\n- navigation \"Breadcrumb\" [ref=e1]:\n - list:\n - link \"Root\" [ref=e2]\n - link \"Here\" [ref=e3]\n- main:\n - heading \"Names\" [level=2]\n - text: \"Labelled by span\"\n - textbox \"Labelled by span\" [ref=e4]\n - textbox \"Label for\" [ref=e5]: \"prefilled\"\n - textbox \"Title only\" [ref=e6]\n - button \"Icon button\" [ref=e7]\n - button \"Described\" [ref=e8]\n - text: \"extra description\"\n - heading \"Level three\" [level=3]\n - heading \"Level four\" [level=4]\n - heading \"Widgets\" [level=2]\n - tablist \"Tabs\" [ref=e9]:\n - tab \"First\" [ref=e10] [selected]\n - tab \"Second\" [ref=e11]\n - tabpanel: \"Panel one\"\n - button \"Menu\" [ref=e12] [expanded=false]\n - button \"Bold\" [ref=e13] [pressed]\n - switch \"Wifi\" [ref=e14] [checked]\n - slider \"Volume\" [ref=e15]: \"3\"\n - group \"Size\":\n - radio \"Small\" [ref=e16] [checked]\n - radio \"Large\" [ref=e17]\n - progressbar \"Upload\": \"40\"\n - alert: \"Heads up\"\n - table \"Scores\":\n - row [header]: \"Name | Score\"\n - row: \"Ada | 9\"\n - tree \"Files\" [ref=e18]:\n - treeitem \"src\" [ref=e19] [expanded]:\n - treeitem \"main.swift\" [ref=e20]\n - button \"More\" [ref=e21] [expanded=false]\n - paragraph [hidden]: \"Hidden details text\"\n - heading \"Visibility\" [level=2]\n - paragraph [hidden]: \"Hidden attribute\"\n - paragraph [hidden]: \"Display none\"\n - paragraph [hidden]: \"Visibility hidden\"\n - paragraph [hidden]: \"Aria hidden\"\n - text: \"Offscreen text\"\n - dialog \"Inline dialog\" [ref=e22]:\n - text: \"Dialog body\"\n - button \"OK\" [ref=e23]\n - link \"Anchor link\" [ref=e24]\n - generic [ref=e25]: \"Clickable div\"\n - textbox \"Editor\" [ref=e26]: \"Editable text\"\n- contentinfo: \"Footer text\"" + } +} diff --git a/tests/browser-parity/goldens/03-states.json b/tests/browser-parity/goldens/03-states.json new file mode 100644 index 000000000000..ce089eac6ee2 --- /dev/null +++ b/tests/browser-parity/goldens/03-states.json @@ -0,0 +1,21 @@ +{ + "oracle": {}, + "cmux": { + "full": "title: States Fixture\nurl: PRIMARY/states.html?peer=PEER\n- main:\n - heading \"States\" [level=1]\n - form \"Profile\" [ref=e1]:\n - textbox \"Name\" [ref=e2] [required]\n - textbox \"Code\" [ref=e3] [invalid]: \"x1\"\n - textbox \"Account\" [ref=e4] [readonly]: \"42\"\n - textbox \"Secret\" [ref=e5]: \"********\"\n - checkbox \"Some selected\" [ref=e6] [checked=mixed]\n - combobox \"Size\" [ref=e7] [options: S, M, L]: \"M\"\n - listbox \"Tags\" [ref=e8]:\n - option \"red\" [selected]\n - option \"blue\"\n - button \"Partial\" [ref=e9] [pressed=mixed]\n - combobox \"City\" [ref=e10] [expanded]: \"Paris\"\n - listbox \"Cities\" [ref=e11]:\n - option \"Paris\" [ref=e12] [selected]\n - option \"Rome\" [ref=e13]\n - table \"Users\":\n - row [header]: \"User | Action\"\n - row:\n - cell: \"Ada\"\n - button \"Edit\" [ref=e14]\n - row: \"Grace | none\"\n - text: \"Links:\"\n - link \"relative\" [ref=e15]\n - link \"peer\" [ref=e16] [url=127.0.0.1/aria.html]\n - link \"script\" [ref=e17]\n - term: \"Term\"\n - definition: \"Definition\"\n - progressbar \"Progress\": \"30\"\n - text: \"Spaces around inline elements stay single.\"\n - generic \"Log\" [ref=e18] [scrollable]:\n - text: \"line one\"\n - text: \"line two\"\n - text: \"line three\"\n - text: \"line four\"", + "options": [ + " - combobox \"Size\" [ref=e7]: \"M\"", + " - option \"S\"", + " - option \"M\" [selected]", + " - option \"L\"", + " - option \"red\" [selected]", + " - option \"blue\"", + " - combobox \"City\" [ref=e10] [expanded]: \"Paris\"", + " - option \"Paris\" [ref=e12] [selected]", + " - option \"Rome\" [ref=e13]" + ], + "user-invalid": [ + " - textbox \"Name\" [ref=e2] [required] [invalid]", + " - textbox \"Code\" [ref=e3] [invalid] [focused]: \"x1\"" + ] + } +} diff --git a/tests/browser-parity/goldens/04-actions-diff.json b/tests/browser-parity/goldens/04-actions-diff.json new file mode 100644 index 000000000000..3d7a6c471bbd --- /dev/null +++ b/tests/browser-parity/goldens/04-actions-diff.json @@ -0,0 +1,24 @@ +{ + "oracle": { + "result": "Submitted me@x.com tos=true plan=Team", + "trusted-log": [ + "input #email", + "change #email", + "click #tos", + "input #tos", + "change #tos", + "input #plan UNTRUSTED", + "change #plan UNTRUSTED", + "click #submit" + ] + }, + "cmux": { + "after-actions": "title: Parity Fixture\nurl: PRIMARY/\n- navigation \"Main\" [ref=e1]:\n - link \"Home\" [ref=e2]\n - link \"Docs\" [ref=e3]\n- main:\n - heading \"Sign up\" [level=1]\n - textbox \"Email\" [ref=e4] [placeholder=\"you@x.com\"]: \"me@x.com\"\n - checkbox \"Accept terms\" [ref=e5] [checked]\n - combobox \"Plan\" [ref=e6] [options: Free, Pro, Team]: \"Team\"\n - textbox \"Bio\" [ref=e7]: \"hi\"\n - button \"Create account\" [ref=e8] [focused]\n - button \"Disabled\" [ref=e9] [disabled]\n - list:\n - listitem: \"One\"\n - listitem: \"Two\"\n - text: \"Plain bold text.\"\n - img \"Logo\"\n - button \"Far away\" [ref=e10]\n- text: \"Submitted me@x.com tos=true plan=Team\"", + "small-change-prints-diff": true, + "small-change": "title: ARIA Fixture\nurl: PRIMARY/aria.html\n# changes since the previous snapshot (+ added, - removed, ~ changed):\n - main:\n~ - textbox \"Label for\" [ref=e15]: \"changed\"\n- - heading \"Level three\" [level=3]\n+ - heading \"Level three renamed\" [level=3]", + "no-change": "title: ARIA Fixture\nurl: PRIMARY/aria.html\n# no changes since the previous snapshot", + "large-change-prints-diff": false, + "large-change": "title: ARIA Fixture\nurl: PRIMARY/aria.html\n- banner:\n - heading \"ARIA roles\" [level=1]\n- navigation \"Breadcrumb\" [ref=e11]:\n - list:\n - link \"Root\" [ref=e12]\n - link \"Here\" [ref=e13]\n- main:\n - heading \"Replaced\" [level=1]\n - text: \"New content\"\n- contentinfo: \"Footer text\"", + "large-change-diff": "title: ARIA Fixture\nurl: PRIMARY/aria.html\n# changes since the previous snapshot (+ added, - removed, ~ changed):\n - main:\n- - heading \"Names\" [level=2]\n- - text: \"Labelled by span\"\n- - textbox \"Labelled by span\" [ref=e14]\n- - textbox \"Label for\" [ref=e15]: \"changed\"\n- - textbox \"Title only\" [ref=e16]\n- - button \"Icon button\" [ref=e17]\n- - button \"Described\" [ref=e18]\n- - text: \"extra description\"\n- - heading \"Level three renamed\" [level=3]\n- - heading \"Level four\" [level=4]\n- - heading \"Widgets\" [level=2]\n- - tablist \"Tabs\" [ref=e19]:\n- - tab \"First\" [ref=e20] [selected]\n- - tab \"Second\" [ref=e21]\n- - tabpanel: \"Panel one\"\n- - button \"Menu\" [ref=e22] [expanded=false]\n- - button \"Bold\" [ref=e23] [pressed]\n- - switch \"Wifi\" [ref=e24] [checked]\n- - slider \"Volume\" [ref=e25]: \"3\"\n- - group \"Size\":\n- - radio \"Small\" [ref=e26] [checked]\n- - radio \"Large\" [ref=e27]\n- - progressbar \"Upload\": \"40\"\n- - alert: \"Heads up\"\n- - table \"Scores\":\n- - row [header]: \"Name | Score\"\n- - row: \"Ada | 9\"\n- - tree \"Files\" [ref=e28]:\n- - treeitem \"src\" [ref=e29] [expanded]:\n- - treeitem \"main.swift\" [ref=e30]\n- - button \"More\" [ref=e31] [expanded=false]\n- - heading \"Visibility\" [level=2]\n- - text: \"Offscreen text\"\n- - dialog \"Inline dialog\" [ref=e32]:\n- - text: \"Dialog body\"\n- - button \"OK\" [ref=e33]\n- - link \"Anchor link\" [ref=e34]\n- - generic [ref=e35]: \"Clickable div\"\n- - textbox \"Editor\" [ref=e36]: \"Editable text\"\n+ - heading \"Replaced\" [level=1]\n+ - text: \"New content\"" + } +} diff --git a/tests/browser-parity/goldens/05-input.json b/tests/browser-parity/goldens/05-input.json new file mode 100644 index 000000000000..c5f17b61daa7 --- /dev/null +++ b/tests/browser-parity/goldens/05-input.json @@ -0,0 +1,43 @@ +{ + "oracle": { + "keys-value": "aZ", + "select-all-delete": "", + "hover-visible": true, + "dbl": "Double clicked", + "ctx": "Context opened", + "drop": "Dropped payload", + "canvas": "hit right trusted=true", + "scrolled": true, + "editor": "start typed", + "trusted-types": [ + "blur", + "click", + "contextmenu", + "dblclick", + "dragstart", + "drop", + "focus", + "input", + "keydown", + "keyup", + "mousedown", + "mouseup", + "pointerdown", + "wheel" + ], + "untrusted-events": [] + }, + "cmux": { + "element-at-canvas": { + "ref": "e1", + "role": "canvas", + "name": "Canvas", + "sameBox": true + }, + "element-at-button": { + "role": "button", + "name": "Double clicked" + }, + "element-at-ref-clicks": 3 + } +} diff --git a/tests/browser-parity/goldens/06-frames.json b/tests/browser-parity/goldens/06-frames.json new file mode 100644 index 000000000000..b652d7eec5e5 --- /dev/null +++ b/tests/browser-parity/goldens/06-frames.json @@ -0,0 +1,15 @@ +{ + "oracle": { + "frame-button-count": 2, + "same-clicked": "Inner clicked ", + "cross-clicked": "Inner clicked PORT", + "cross-typed": "typed across", + "cross-fill": "cross value", + "same-fill": "same value", + "cross-trusted": true + }, + "cmux": { + "full": "title: Frames Fixture\nurl: PRIMARY/frames.html?peer=PEER\n- heading \"Frames\" [level=1]\n- iframe \"Same origin\" [ref=e1]:\n - button \"Inside frame\" [ref=f1e1]\n - textbox \"Frame field\" [ref=f1e2]\n- iframe \"Cross origin\" [ref=e2]:\n - button \"Inside frame\" [ref=f2e1]\n - textbox \"Frame field\" [ref=f2e2]", + "after-clicks": "title: Frames Fixture\nurl: PRIMARY/frames.html?peer=PEER\n- heading \"Frames\" [level=1]\n- iframe \"Same origin\" [ref=e1]:\n - button \"Inner clicked \" [ref=f1e1]\n - textbox \"Frame field\" [ref=f1e2]\n- iframe \"Cross origin\" [ref=e2]:\n - button \"Inner clicked \" [ref=f2e1] [focused]\n - textbox \"Frame field\" [ref=f2e2]" + } +} diff --git a/tests/browser-parity/goldens/07-shadow.json b/tests/browser-parity/goldens/07-shadow.json new file mode 100644 index 000000000000..763e2a4e21c6 --- /dev/null +++ b/tests/browser-parity/goldens/07-shadow.json @@ -0,0 +1,8 @@ +{ + "oracle": { + "out": "shadow clicked trusted=true value=inside" + }, + "cmux": { + "full": "title: Shadow Fixture\nurl: PRIMARY/shadow.html\n- heading \"Shadow DOM\" [level=1]\n- region \"Card\" [ref=e1]:\n - heading \"Card title\" [level=2]\n - textbox \"Shadow input\" [ref=e2]\n - button \"Shadow button\" [ref=e3]\n- text: \"none\"" + } +} diff --git a/tests/browser-parity/goldens/08-dialogs.json b/tests/browser-parity/goldens/08-dialogs.json new file mode 100644 index 000000000000..0217908e9757 --- /dev/null +++ b/tests/browser-parity/goldens/08-dialogs.json @@ -0,0 +1,19 @@ +{ + "oracle": { + "after-alert": "after alert", + "after-confirm": "confirm false", + "after-prompt": "prompt typed answer", + "dialogs": [ + "alert:Alert text:", + "confirm:Confirm text:", + "prompt:Prompt text:default" + ], + "none-open": null, + "wait-for-event": [ + "confirm", + "Confirm text" + ], + "after-wait-accept": "confirm true" + }, + "cmux": {} +} diff --git a/tests/browser-parity/goldens/09-dialogs-held.json b/tests/browser-parity/goldens/09-dialogs-held.json new file mode 100644 index 000000000000..cedf18b3f644 --- /dev/null +++ b/tests/browser-parity/goldens/09-dialogs-held.json @@ -0,0 +1,22 @@ +{ + "oracle": {}, + "cmux": { + "snapshot": "title: Dialogs Fixture\nurl: PRIMARY/dialogs.html\ndialog: confirm \"Confirm text\" (answer with page.dialog().accept() or .dismiss())\n# the page is blocked until the dialog is answered", + "dialog": { + "type": "confirm", + "message": "Confirm text", + "defaultValue": "" + }, + "blocked": "page is blocked by a JavaScript confirm dialog \"Confirm text\"; answer it with page.dialog().accept() or page.dialog().dismiss()", + "blocked-evaluate": "page is blocked by a JavaScript confirm dialog \"Confirm text\"; answer it with page.dialog().accept() or page.dialog().dismiss()", + "after-accept": "confirm true", + "closed": null, + "prompt-line": "dialog: prompt \"Prompt text\" [default=\"default\"] (answer with page.dialog().accept() or .dismiss())", + "after-prompt": "prompt from agent", + "prompt-dismissed": "prompt null", + "keyboard-opened": "alert", + "after-alert": "after alert", + "timer-dialog": "Later", + "after-timer": true + } +} diff --git a/tests/browser-parity/goldens/10-files.json b/tests/browser-parity/goldens/10-files.json new file mode 100644 index 000000000000..aca391bc3b55 --- /dev/null +++ b/tests/browser-parity/goldens/10-files.json @@ -0,0 +1,14 @@ +{ + "oracle": { + "single": "one: a.txt(alpha)", + "multiple-with-temp": "many: a.txt(alpha),t.txt(from temp)", + "buffer": "one: mem.txt(in memory)", + "chooser-multiple": false, + "chooser": "hidden-file: b.txt(beta)", + "download-name": "parity-download.txt", + "download-url": "PRIMARY/download.txt", + "download-body": "parity download body\n", + "download-saved": "parity download body\n" + }, + "cmux": {} +} diff --git a/tests/browser-parity/goldens/11-files-held.json b/tests/browser-parity/goldens/11-files-held.json new file mode 100644 index 000000000000..1a6ced81ca22 --- /dev/null +++ b/tests/browser-parity/goldens/11-files-held.json @@ -0,0 +1,14 @@ +{ + "oracle": {}, + "cmux": { + "snapshot": "title: Files Fixture\nurl: PRIMARY/files.html\nfile chooser: [ref=e1] (answer with page.fileChooser().setFiles(paths) or .cancel())\n- button \"Single\" [ref=e2]\n- button \"Many\" [ref=e3]\n- button \"Choose via button\" [ref=e4] [focused]\n- link \"Download file\" [ref=e5]\n- text: \"none\"", + "multiple": false, + "set": "hidden-file: held.txt(held file)", + "answered": null, + "many-line": "file chooser: [ref=e3] [multiple] (answer with page.fileChooser().setFiles(paths) or .cancel())", + "many-multiple": true, + "cancelled": null, + "unchanged": "hidden-file: held.txt(held file)", + "answer-twice": "File chooser was already answered" + } +} diff --git a/tests/browser-parity/goldens/12-navigation.json b/tests/browser-parity/goldens/12-navigation.json new file mode 100644 index 000000000000..1af302038994 --- /dev/null +++ b/tests/browser-parity/goldens/12-navigation.json @@ -0,0 +1,19 @@ +{ + "oracle": { + "title": "Dynamic Fixture", + "pushed-url": "PRIMARY/dynamic.html?step=2", + "pushed-title": "Dynamic step 2", + "wait-url": "PRIMARY/dynamic.html?step=2", + "late": "Late content", + "slow-heading": "Slow page loaded", + "back-url": "PRIMARY/dynamic.html?step=2", + "forward-url": "PRIMARY/slow?ms=600", + "reload-title": "Slow", + "goto-status": 200, + "goto-title": "ARIA Fixture", + "missing-status": 404 + }, + "cmux": { + "snapshot-url": true + } +} diff --git a/tests/browser-parity/goldens/13-tabs.json b/tests/browser-parity/goldens/13-tabs.json new file mode 100644 index 000000000000..b078ab6cde32 --- /dev/null +++ b/tests/browser-parity/goldens/13-tabs.json @@ -0,0 +1,30 @@ +{ + "oracle": { + "current-is-opened": true, + "list-keys": [ + "active", + "current", + "id", + "state", + "title", + "url" + ], + "list-count": 1, + "popup-title": "ARIA Fixture", + "popup-listener": true, + "popup-opener": true, + "popup-not-current": true, + "window-open-url": "PRIMARY/aria.html", + "background-keeps-current": true, + "get-by-id": true, + "one-active": 1, + "use-switches": true, + "use-title": "ARIA Fixture", + "use-by-id": true, + "closed": true, + "after-close": 3 + }, + "cmux": { + "snapshot-other-tab": "title: ARIA Fixture" + } +} diff --git a/tests/browser-parity/goldens/14-screenshots.json b/tests/browser-parity/goldens/14-screenshots.json new file mode 100644 index 000000000000..301351126f8e --- /dev/null +++ b/tests/browser-parity/goldens/14-screenshots.json @@ -0,0 +1,28 @@ +{ + "oracle": { + "viewport": { + "type": "png", + "width": 1280, + "height": 800 + }, + "full-page-taller": true, + "locator-smaller": true, + "ref-matches-locator": true, + "jpeg": "jpeg", + "buffer": { + "isBuffer": true, + "magic": "PNG" + }, + "pdf-magic": "%PDF-", + "screenshot-path": "PNG" + }, + "cmux": { + "annotated-size": [ + 1280, + 800 + ], + "annotated-differs": true, + "annotations-removed": true, + "no-overlay-in-dom": 0 + } +} diff --git a/tests/browser-parity/goldens/15-locators.json b/tests/browser-parity/goldens/15-locators.json new file mode 100644 index 000000000000..44b30a155c5f --- /dev/null +++ b/tests/browser-parity/goldens/15-locators.json @@ -0,0 +1,50 @@ +{ + "oracle": { + "role-tabs": 2, + "role-tab-selected": "First", + "by-text": 1, + "by-label": "prefilled", + "labelledby": 1, + "heading-3": "Level three", + "by-title": 1, + "nth": "Described", + "first-last": [ + "Name", + "Score" + ], + "filter": 2, + "and-or": [ + 1, + 2 + ], + "visible": [ + false, + true + ], + "states": [ + true, + true, + true + ], + "attr": "page", + "all-text": [ + "Ada", + "9" + ], + "inner-text": "Heads up", + "eval-all": [ + "Name", + "Score" + ], + "$$eval": [ + "Ada", + "9" + ], + "evaluate-arg": 42, + "locator-evaluate": "ARIA roles!", + "count-all": 3, + "strict": true, + "timeout": "TimeoutError" + }, + "cmux": {} +} diff --git a/tests/browser-parity/goldens/16-network-console.json b/tests/browser-parity/goldens/16-network-console.json new file mode 100644 index 000000000000..58dc6b17cd46 --- /dev/null +++ b/tests/browser-parity/goldens/16-network-console.json @@ -0,0 +1,42 @@ +{ + "oracle": { + "events": [ + "console:error:parity error", + "console:log:parity log 42", + "console:warning:parity warn", + "pageerror:parity rejection", + "request:GET:/api/data", + "response:200" + ], + "history": [ + "log:parity log 42", + "warning:parity warn", + "error:parity error" + ], + "history-level": [ + "parity warn" + ], + "history-warn-alias": [ + "parity warn" + ], + "history-filter": [ + "parity error" + ], + "history-limit": 1, + "errors": [ + "parity rejection" + ], + "fetch-json": { + "ok": true, + "q": "repl" + }, + "fetch-status": [ + true, + 200, + "application/json" + ], + "cookie-set": true, + "fetch-sends-cookies": true + }, + "cmux": {} +} diff --git a/tests/browser-parity/goldens/17-refs.json b/tests/browser-parity/goldens/17-refs.json new file mode 100644 index 000000000000..653976205155 --- /dev/null +++ b/tests/browser-parity/goldens/17-refs.json @@ -0,0 +1,20 @@ +{ + "oracle": {}, + "cmux": { + "s1": "title: Parity Fixture\nurl: PRIMARY/\n- button \"Alpha\" [ref=e1]\n- button \"Beta\" [ref=e2]\n- button \"Out\" [ref=e3]", + "s2": "title: Parity Fixture\nurl: PRIMARY/\n- button \"Zero\" [ref=e4]\n- button \"Beta renamed\" [ref=e2]\n- button \"Out\" [ref=e3]", + "s2-printed": "title: Parity Fixture\nurl: PRIMARY/\n- button \"Zero\" [ref=e4]\n- button \"Beta renamed\" [ref=e2]\n- button \"Out\" [ref=e3]", + "removed": "ref e1 is stale: the element was removed; take a new snapshot", + "unknown": "ref e99 does not exist; take a new snapshot", + "unknown-frame": "ref f9e1 does not exist; take a new snapshot", + "moved-still-works": "Out clicked", + "renamed-still-works": "Beta renamed", + "never-reused": "e5", + "scoped": "title: Parity Fixture\nurl: PRIMARY/\n- button \"Beta renamed\" [ref=e2]", + "not-invalidated-by-scope": "Out clicked", + "ref-validates": "page.ref: expected a snapshot ref such as \"e5\" or \"f1e2\", got \"button\"", + "is-visible-stale": false, + "after-navigation": "ref e2 is stale: the element was removed; take a new snapshot", + "new-document-continues": "e6" + } +} diff --git a/tests/browser-parity/goldens/18-print.json b/tests/browser-parity/goldens/18-print.json new file mode 100644 index 000000000000..df7a051b8c2d --- /dev/null +++ b/tests/browser-parity/goldens/18-print.json @@ -0,0 +1,20 @@ +{ + "oracle": {}, + "cmux": { + "output:1": "2", + "output:2": "", + "output:3": "{ a: 1, b: [ 1, 2, 's' ], c: null, nested: { deep: true } }", + "output:4": "plain string", + "output:5": "42", + "output:6": "", + "output:7": "title: ARIA Fixture\nurl: PRIMARY/aria.html\n- banner:\n - heading \"ARIA roles\" [level=1]\n- navigation \"Breadcrumb\" [ref=e1]:\n - link \"Root\" [ref=e2]\n - link \"Here\" [ref=e3]\n- main:\n - heading \"Names\" [level=2]\n - textbox \"Labelled by span\" [ref=e4]\n - textbox \"Label for\" [ref=e5]: \"prefilled\"\n - textbox \"Title only\" [ref=e6]\n - button \"Icon button\" [ref=e7]\n - button \"Described\" [ref=e8]\n - heading \"Level three\" [level=3]\n - heading \"Level four\" [level=4]\n - heading \"Widgets\" [level=2]\n - tablist \"Tabs\" [ref=e9]:\n - tab \"First\" [ref=e10] [selected]\n - tab \"Second\" [ref=e11]\n - button \"Menu\" [ref=e12] [expanded=false]\n - button \"Bold\" [ref=e13] [pressed]\n - switch \"Wifi\" [ref=e14] [checked]\n - slider \"Volume\" [ref=e15]: \"3\"\n - group \"Size\":\n - radio \"Small\" [ref=e16] [checked]\n - radio \"Large\" [ref=e17]\n - tree \"Files\" [ref=e18]:\n - treeitem \"src\" [ref=e19] [expanded]:\n - treeitem \"main.swift\" [ref=e20]\n - button \"More\" [ref=e21] [expanded=false]\n - heading \"Visibility\" [level=2]\n - dialog \"Inline dialog\" [ref=e22]:\n - button \"OK\" [ref=e23]\n - link \"Anchor link\" [ref=e24]\n - generic [ref=e25]: \"Clickable div\"\n - textbox \"Editor\" [ref=e26]: \"Editable text\"", + "output:8": "title: ARIA Fixture\nurl: PRIMARY/aria.html\n# changes since the previous snapshot (+ added, - removed, ~ changed):\n - banner:\n- - heading \"ARIA roles\" [level=1]\n+ - heading \"Renamed\" [level=1]", + "output:9": "title: ARIA Fixture\nurl: PRIMARY/aria.html\n- banner:\n - heading \"Renamed\" [level=1]\n- navigation \"Breadcrumb\" [ref=e1]:\n - list:\n - link \"Root\" [ref=e2]\n - link \"Here\" [ref=e3]\n- main:\n - heading \"Names\" [level=2]\n - text: \"Labelled by span\"\n - textbox \"Labelled by span\" [ref=e4]\n - textbox \"Label for\" [ref=e5]: \"prefilled\"\n - textbox \"Title only\" [ref=e6]\n - button \"Icon button\" [ref=e7]\n - button \"Described\" [ref=e8]\n - text: \"extra description\"\n - heading \"Level three\" [level=3]\n - heading \"Level four\" [level=4]\n - heading \"Widgets\" [level=2]\n - tablist \"Tabs\" [ref=e9]:\n - tab \"First\" [ref=e10] [selected]\n - tab \"Second\" [ref=e11]\n - tabpanel: \"Panel one\"\n - button \"Menu\" [ref=e12] [expanded=false]\n - button \"Bold\" [ref=e13] [pressed]\n - switch \"Wifi\" [ref=e14] [checked]\n - slider \"Volume\" [ref=e15]: \"3\"\n - group \"Size\":\n - radio \"Small\" [ref=e16] [checked]\n - radio \"Large\" [ref=e17]\n - progressbar \"Upload\": \"40\"\n - alert: \"Heads up\"\n - table \"Scores\":\n - row [header]: \"Name | Score\"\n - row: \"Ada | 9\"\n - tree \"Files\" [ref=e18]:\n - treeitem \"src\" [ref=e19] [expanded]:\n - treeitem \"main.swift\" [ref=e20]\n - button \"More\" [ref=e21] [expanded=false]\n - heading \"Visibility\" [level=2]\n - text: \"Offscreen text\"\n - dialog \"Inline dialog\" [ref=e22]:\n - text: \"Dialog body\"\n - button \"OK\" [ref=e23]\n - link \"Anchor link\" [ref=e24]\n - generic [ref=e25]: \"Clickable div\"\n - textbox \"Editor\" [ref=e26]: \"Editable text\"\n- contentinfo: \"Footer text\"", + "output:10": "title: ARIA Fixture\nurl: PRIMARY/aria.html\n# changes since the previous snapshot (+ added, - removed, ~ changed):\n - banner:\n- - heading \"Renamed\" [level=1]\n+ - heading \"Renamed again\" [level=1]", + "output:11": "[Image 1280x800 png: /cmux-browser-repl//image-1.png]", + "output:12": "{ shown: true }\n[Image 400x37 png: /cmux-browser-repl//image-2.png]\nlog [ 1, 2 ] { k: 'v' }", + "output:13": "PRIMARY/aria.html", + "output:14": "Map(1) { 'k' => 1 }", + "output:15": "[]" + } +} diff --git a/tests/browser-parity/goldens/19-clipboard.json b/tests/browser-parity/goldens/19-clipboard.json new file mode 100644 index 000000000000..a860bba82d3c --- /dev/null +++ b/tests/browser-parity/goldens/19-clipboard.json @@ -0,0 +1,100 @@ +{ + "oracle": {}, + "cmux": { + "read-text": "clip text", + "pasted": "clip text", + "copied": "copy me", + "cut": [ + "copy me", + "" + ], + "read-items": [ + [ + "text/plain", + "via write" + ] + ], + "trusted-paste": [ + "input #clip" + ], + "custom-copy": [ + "copy: MAKE IT LOUD", + true, + "make it loud" + ], + "custom-cut": [ + "cut: MAKE IT LOUD", + true, + "make it loud" + ], + "plain-cut": [ + "make it loud", + "" + ], + "clipboard-events": [ + "copy", + "cut", + "cut" + ], + "alert-copy": { + "pending": null, + "clipboard": "after copy: alerted", + "header": [ + "dialog dismissed: alert \"copy alert\" (it opened during a copy)" + ] + }, + "alert-copy-reported-once": [], + "confirm-cut-listener": { + "seen": [ + [ + "confirm", + "cut confirm" + ] + ], + "clipboard": "after cut: false", + "value": "ask first" + }, + "late-copy": { + "late": { + "code": "timeout", + "endedWebContent": true + }, + "crashed": true, + "clipboard": "before the late copy", + "reloaded": "" + }, + "page-write-text": [ + "ok", + "from writeText" + ], + "page-write-item": [ + "ok", + "from a late item" + ], + "page-exec-copy": [ + "true", + "from execCommand" + ], + "page-read-text": [ + "NotAllowedError", + "from execCommand" + ], + "user-tab-clipboard": { + "shortcuts": [ + [ + "unsupported", + true + ], + [ + "unsupported", + true + ], + [ + "unsupported", + true + ] + ], + "value": "the user's text" + } + } +} diff --git a/tests/browser-parity/goldens/20-session.json b/tests/browser-parity/goldens/20-session.json new file mode 100644 index 000000000000..52532633d654 --- /dev/null +++ b/tests/browser-parity/goldens/20-session.json @@ -0,0 +1,26 @@ +{ + "oracle": {}, + "cmux": { + "open-during-run": [ + "PRIMARY/aria.html?oneshot", + "PRIMARY/dynamic.html?closed", + "PRIMARY/dynamic.html?kept" + ], + "after-one-shot": [ + "PRIMARY/dynamic.html?kept" + ], + "attach-kept": "Dynamic Fixture", + "session-name": "parity work", + "guide": true, + "bindings-persist": [ + 2, + "first" + ], + "page-persists": true, + "functions-persist": 20, + "session-id": "string", + "named-tabs-open": [ + "PRIMARY/aria.html?named" + ] + } +} diff --git a/tests/browser-parity/goldens/21-fs.json b/tests/browser-parity/goldens/21-fs.json new file mode 100644 index 000000000000..ecdb0c1e1cfb --- /dev/null +++ b/tests/browser-parity/goldens/21-fs.json @@ -0,0 +1,54 @@ +{ + "oracle": { + "read": "hello world", + "read-buffer": true, + "readdir": [ + "sub" + ], + "readdir-types": [ + [ + "sub", + true + ] + ], + "stat": [ + 11, + true + ], + "exists": [ + true, + false + ], + "rename-copy": [ + false, + "hello world" + ], + "cwd": true, + "promises": "promised", + "import-same": [ + true, + true, + true, + true + ], + "import-promises": true, + "temp": "temp ok", + "temp-removed": false, + "path": [ + "b/c.txt", + "y", + ".gz", + true + ], + "buffer": [ + "aMOpbGxv", + "hi", + "6162" + ], + "enoent": "ENOENT" + }, + "cmux": { + "outside": "EACCES", + "outside-write": "EACCES" + } +} diff --git a/tests/browser-parity/goldens/22-api-surface.json b/tests/browser-parity/goldens/22-api-surface.json new file mode 100644 index 000000000000..1cc8613bafbb --- /dev/null +++ b/tests/browser-parity/goldens/22-api-surface.json @@ -0,0 +1,67 @@ +{ + "oracle": { + "content": true, + "$": "Surface", + "$$": 3, + "frames": 3, + "main-frame": true, + "video": null, + "viewport": { + "width": 900, + "height": 700 + }, + "bring-to-front": true, + "off": 0, + "page-click-fill": [ + "Count 1", + "q" + ], + "type-press": "acd", + "focus-blur": "focus;blur;", + "check": [ + true, + false, + true + ], + "scrolled-into-view": true, + "inner-html": "More

    Details body

    ", + "hidden-disabled": [ + true, + true + ], + "wait-for": true, + "dispatch-event": "Count 2", + "chained": "More", + "placeholder-testid": [ + "q", + "Count 2" + ], + "scoped-getby": [ + 1, + 1, + 1, + 1, + 1 + ], + "expand": true, + "nested-frames": [ + "nested", + 1, + 1, + "Inner text", + 1 + ], + "keyboard-primitives": "qAé", + "mouse-down-up": [ + "1", + "1" + ], + "mouse-dblclick": "pad dbl", + "mouse-drag": "Dropped mouse", + "export-content": true, + "untrusted": [] + }, + "cmux": { + "tap": "Count 3" + } +} diff --git a/tests/browser-parity/goldens/23-insecure-http.json b/tests/browser-parity/goldens/23-insecure-http.json new file mode 100644 index 000000000000..f131bfc2c335 --- /dev/null +++ b/tests/browser-parity/goldens/23-insecure-http.json @@ -0,0 +1,8 @@ +{ + "oracle": { + "goto-title": "ARIA Fixture", + "open-title": "Dynamic Fixture", + "link-title": "Files Fixture" + }, + "cmux": {} +} diff --git a/tests/browser-parity/goldens/24-visibility.json b/tests/browser-parity/goldens/24-visibility.json new file mode 100644 index 000000000000..a0aa66ca69ae --- /dev/null +++ b/tests/browser-parity/goldens/24-visibility.json @@ -0,0 +1,9 @@ +{ + "oracle": {}, + "cmux": { + "full": "title: Visibility Fixture\nurl: PRIMARY/visibility.html\n- heading \"Visibility\" [level=1]\n- button \"Closed details\" [ref=e1] [expanded=false]\n- table:\n - row:\n - columnheader:\n - button \"[show]\" [ref=e2] [expanded=false]\n - text: \"Navbox title\"\n- text: \"Visible child text\"\n- text: \"Screen reader text\"\n- heading \"Names\" [level=2]\n- table \"Scores\":\n - row [header]: \"Name | Score\"\n - row: \"Ada | 9\"\n - row:\n - cell: \"Linus\"\n - cell:\n - link \"Profile\" [ref=e3]\n - text: \"and notes\"\n- list:\n - link \"Card title Card body text that describes the card.\" [ref=e4]\n - link \"Single link item\" [ref=e5]\n - listitem: \"Plain item\"\n - listitem:\n - link \"First of two\" [ref=e6]\n - link \"Second of two\" [ref=e7]\n- link [ref=e8]: \"A link whose text runs long: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor.\"\n- heading \"Layout table\" [level=2]\n- link \"Site\" [ref=e9]\n- link \"new\" [ref=e10]\n- text: \"1.\"\n- link \"upvote\" [ref=e11]\n- link \"Story one\" [ref=e12]\n- text: \"(example.com)\"\n- text: \"10 points by\"\n- link \"ada\" [ref=e13]\n- link \"3 comments\" [ref=e14]\n- text: \"2.\"\n- link \"upvote\" [ref=e15]\n- link \"Story two\" [ref=e16]", + "urls": "title: Visibility Fixture\nurl: PRIMARY/visibility.html\n- list:\n - link \"Card title Card body text that describes the card.\" [ref=e4] [url=/card]\n - link \"Single link item\" [ref=e5] [url=/plain]\n - listitem: \"Plain item\"\n - listitem:\n - link \"First of two\" [ref=e6] [url=/two-a]\n - link \"Second of two\" [ref=e7] [url=/two-b]", + "interactive": "title: Visibility Fixture\nurl: PRIMARY/visibility.html\n- heading \"Visibility\" [level=1]\n- button \"Closed details\" [ref=e1] [expanded=false]\n- button \"[show]\" [ref=e2] [expanded=false]\n- heading \"Names\" [level=2]\n- table \"Scores\":\n - link \"Profile\" [ref=e3]\n- link \"Card title Card body text that describes the card.\" [ref=e4]\n- link \"Single link item\" [ref=e5]\n- link \"First of two\" [ref=e6]\n- link \"Second of two\" [ref=e7]\n- link [ref=e8]: \"A link whose text runs long: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor.\"\n- heading \"Layout table\" [level=2]\n- link \"Site\" [ref=e9]\n- link \"new\" [ref=e10]\n- link \"upvote\" [ref=e11]\n- link \"Story one\" [ref=e12]\n- link \"ada\" [ref=e13]\n- link \"3 comments\" [ref=e14]\n- link \"upvote\" [ref=e15]\n- link \"Story two\" [ref=e16]", + "show-hidden-has-navbox-link": true + } +} diff --git a/tests/browser-parity/goldens/25-nested-frames.json b/tests/browser-parity/goldens/25-nested-frames.json new file mode 100644 index 000000000000..af4a5ab007d2 --- /dev/null +++ b/tests/browser-parity/goldens/25-nested-frames.json @@ -0,0 +1,15 @@ +{ + "oracle": { + "deep-chain-click": "deep clicked true", + "srcdoc-click": "srcdoc clicked true", + "l1-fill": "level one", + "frame-count": 5, + "deep-frame-url": 1, + "deep-ref-click": "deep clicked true" + }, + "cmux": { + "full": "title: Nested Frames\nurl: PRIMARY/nest-top.html\n- heading \"Top\" [level=1]\n- button \"Top button\" [ref=e1]\n- iframe \"Level one\" [ref=e2]:\n - text: \"Level one (peer origin)\"\n - textbox \"L1 field\" [ref=f1e1]\n - iframe \"Level two\" [ref=f1e2]:\n - text: \"Level two (primary origin)\"\n - iframe \"Level three\" [ref=f2e1]:\n - button \"Deep button\" [ref=f3e1]\n - button \"Closed shadow button\" [ref=f3e2]\n- iframe \"Srcdoc\" [ref=e3]:\n - button \"Srcdoc button\" [ref=f4e1]", + "closed-in-frame-click": "closed clicked true", + "closed-in-frame-ref": true + } +} diff --git a/tests/browser-parity/goldens/26-closed-shadow.json b/tests/browser-parity/goldens/26-closed-shadow.json new file mode 100644 index 000000000000..323c154b7ea4 --- /dev/null +++ b/tests/browser-parity/goldens/26-closed-shadow.json @@ -0,0 +1,10 @@ +{ + "oracle": {}, + "cmux": { + "full": "title: Closed Shadow Fixture\nurl: PRIMARY/closed-shadow.html\n- heading \"Closed shadow\" [level=1]\n- textbox \"Closed input\" [ref=e1]\n- button \"Closed button\" [ref=e2]\n- text: \"none\"\n- link \"Nested closed link\" [ref=e3]", + "ref-fill": "typed", + "locator-click": "closed true", + "nested-link": 1, + "css-pierces": 1 + } +} diff --git a/tests/browser-parity/goldens/27-corpus.json b/tests/browser-parity/goldens/27-corpus.json new file mode 100644 index 000000000000..de286acb0292 --- /dev/null +++ b/tests/browser-parity/goldens/27-corpus.json @@ -0,0 +1,32 @@ +{ + "oracle": {}, + "cmux": { + "wikipedia:missing": [], + "wikipedia:leaks": [], + "wikipedia:within-reference-a-10pct": true, + "hackernews:missing": [], + "hackernews:leaks": [], + "hackernews:within-reference-a-10pct": true, + "github:missing": [], + "github:leaks": [], + "github:within-reference-a-10pct": true, + "mdn:missing": [], + "mdn:leaks": [], + "mdn:within-reference-a-10pct": true, + "mdn-iframe:missing": [], + "mdn-iframe:leaks": [], + "mdn-iframe:within-reference-a-10pct": true, + "npr:missing": [], + "npr:leaks": [], + "npr:within-reference-a-10pct": true, + "bbc:missing": [], + "bbc:leaks": [], + "bbc:within-reference-a-10pct": true, + "books:missing": [], + "books:leaks": [], + "books:within-reference-a-10pct": true, + "vercel:missing": [], + "vercel:leaks": [], + "vercel:within-reference-a-10pct": true + } +} diff --git a/tests/browser-parity/goldens/28-compact.json b/tests/browser-parity/goldens/28-compact.json new file mode 100644 index 000000000000..4b84eff840b4 --- /dev/null +++ b/tests/browser-parity/goldens/28-compact.json @@ -0,0 +1,9 @@ +{ + "oracle": {}, + "cmux": { + "full": "title: Compact Fixture\nurl: PRIMARY/compact.html\n- banner:\n - link [ref=e1] [url=/]\n - link \"Site logo\" [ref=e2] [url=/home]\n- navigation \"Belt\" [ref=e3]:\n - link \"One\" [ref=e4]\n - link \"Two\" [ref=e5]\n - link \"Three\" [ref=e6]\n - link \"Four\" [ref=e7]\n- main:\n - heading \"Compact\" [level=1]\n - text: \"Keep the mail broker from orphaning a reply to an unknown parent message\"\n - text: \"Menu\"\n - link \"Popup link\" [ref=e8]\n - link \"new\" [ref=e9]\n - link \"past\" [ref=e10]\n - link \"ask\" [ref=e11]\n - link \"example.com\" [ref=e12]\n - text: \"10 points by\"\n - link \"ada\" [ref=e13]\n - text: \"3 comments\"\n - textbox \"Email\" [ref=e14]\n - combobox \"Plan\" [ref=e15] [options: Free, Pro, Team]: \"Pro\"\n - combobox \"Country\" [ref=e16] [options: Argentina, Brazil, Canada, Denmark, Egypt, France, Germany, Hungary, India, Japan, +2 more]: \"Argentina\"\n - button \"Create account\" [ref=e17]\n - table:\n - row [header]: \"User | Action\"\n - row: \"Ada | Edit\"\n - heading \"Details\" [level=2]\n - region \"Far section\" [ref=e18]:\n - heading \"Far away\" [level=2]\n - button \"Far button\" [ref=e19]\n - link \"Far link\" [ref=e20]", + "interactive": "title: Compact Fixture\nurl: PRIMARY/compact.html\n- banner:\n - link [ref=e1] [url=/]\n - link \"Site logo\" [ref=e2] [url=/home]\n- navigation \"Belt\" [ref=e3]:\n - link \"One\" [ref=e4]\n - link \"Two\" [ref=e5]\n - link \"Three\" [ref=e6]\n - link \"Four\" [ref=e7]\n- main:\n - heading \"Compact\" [level=1]\n - link \"Popup link\" [ref=e8]\n - link \"new\" [ref=e9]\n - link \"past\" [ref=e10]\n - link \"ask\" [ref=e11]\n - link \"example.com\" [ref=e12]\n - link \"ada\" [ref=e13]\n - textbox \"Email\" [ref=e14]\n - combobox \"Plan\" [ref=e15] [options: Free, Pro, Team]: \"Pro\"\n - combobox \"Country\" [ref=e16] [options: Argentina, Brazil, Canada, Denmark, Egypt, France, Germany, Hungary, India, Japan, +2 more]: \"Argentina\"\n - button \"Create account\" [ref=e17]\n - heading \"Details\" [level=2]\n - region \"Far section\" [ref=e18]:\n - heading \"Far away\" [level=2]\n - button \"Far button\" [ref=e19]\n - link \"Far link\" [ref=e20]", + "viewport": "title: Compact Fixture\nurl: PRIMARY/compact.html\n- banner:\n - link [ref=e1] [url=/]\n - link \"Site logo\" [ref=e2] [url=/home]\n- navigation \"Belt\" [ref=e3]:\n - link \"One\" [ref=e4]\n - link \"Two\" [ref=e5]\n - link \"Three\" [ref=e6]\n - link \"Four\" [ref=e7]\n- main:\n - heading \"Compact\" [level=1]\n - text: \"Keep the mail broker from orphaning a reply to an unknown parent message\"\n - text: \"Menu\"\n - link \"Popup link\" [ref=e8]\n - link \"new\" [ref=e9]\n - link \"past\" [ref=e10]\n - link \"ask\" [ref=e11]\n - link \"example.com\" [ref=e12]\n - text: \"10 points by\"\n - link \"ada\" [ref=e13]\n - text: \"3 comments\"\n - textbox \"Email\" [ref=e14]\n - combobox \"Plan\" [ref=e15] [options: Free, Pro, Team]: \"Pro\"\n - combobox \"Country\" [ref=e16] [options: Argentina, Brazil, Canada, Denmark, Egypt, France, Germany, Hungary, India, Japan, +2 more]: \"Argentina\"\n - button \"Create account\" [ref=e17]\n - table:\n - row [header]: \"User | Action\"\n - row: \"Ada | Edit\"\n - heading \"Details\" [level=2]\n# 2 interactive elements outside the viewport are not shown; snapshot() shows the whole page", + "output:2": "title: Compact Fixture\nurl: PRIMARY/compact.html\n# changes since the previous snapshot (+ added, - removed, ~ changed):\n - main:\n~ - textbox \"Email\" [ref=e14] [focused]: \"me@x.com\"" + } +} diff --git a/tests/browser-parity/goldens/29-targets.json b/tests/browser-parity/goldens/29-targets.json new file mode 100644 index 000000000000..fd9d8dd32069 --- /dev/null +++ b/tests/browser-parity/goldens/29-targets.json @@ -0,0 +1,7 @@ +{ + "oracle": {}, + "cmux": { + "full": "title: Targets Fixture\nurl: PRIMARY/targets.html\n- main:\n - heading \"Targets\" [level=1]\n - text: \"Cited claim and more text.\"\n - link \"★\" [ref=e1]\n - link \"main content\" [ref=e2]\n - link \"Docs elsewhere\" [ref=e3] [url=example.org/docs/…]\n - link \"Example net\" [ref=e4] [url=example.net]\n - link \"Local page\" [ref=e5]\n - button \"Guides\" [ref=e6] [expanded=false]:\n - link \"Guides\" [ref=e7]\n - text: \"A commit message long enough to run past the edge of its cell\"\n - textbox \"Email\" [ref=e8]\n - button \"Send\" [ref=e9]\n - text: \"Waiting\"", + "output:2": "title: Targets Fixture\nurl: PRIMARY/targets.html\n# changes since the previous snapshot (+ added, - removed, ~ changed):\n - main:\n~ - textbox \"Email\" [ref=e8]: \"me@x.com\"\n~ - button \"Send\" [ref=e9] [focused]\n+ - text: \"Submitted me@x.com\"" + } +} diff --git a/tests/browser-parity/goldens/30-stress.json b/tests/browser-parity/goldens/30-stress.json new file mode 100644 index 000000000000..661a987c9675 --- /dev/null +++ b/tests/browser-parity/goldens/30-stress.json @@ -0,0 +1,30 @@ +{ + "oracle": { + "list-count": 5000, + "list-last": "Item 4999", + "table-rows": 10001, + "table-cell": "open 9990", + "select-option": [ + "4999" + ], + "select-value": "4999", + "select-many": [ + "3", + "4998" + ], + "shadow-last": "Shadow 1999", + "shadow-count": 2000, + "frame-cross": "Frame 28", + "frame-nested": "Frame 29.1", + "virtual-row": "Row 5000" + }, + "cmux": { + "list-printed-within-budget": true, + "list-tree-complete": 5000, + "list-condensed-note": true, + "list-cut-line": "- … N more listitem (N refs): snapshot(\"eN\")", + "list-cut-ref-resolves": "Item 4000", + "list-all": true, + "select-inline": " - combobox \"Pick\" [ref=e5002] [options: Option 0, Option 1, Option 2, Option 3, Option 4, Option 5, Option 6, Option 7, Option 8, Option 9, +4990 more]: \"Option 0\"" + } +} diff --git a/tests/browser-parity/goldens/31-frame-calls.json b/tests/browser-parity/goldens/31-frame-calls.json new file mode 100644 index 000000000000..740eca5a5ef4 --- /dev/null +++ b/tests/browser-parity/goldens/31-frame-calls.json @@ -0,0 +1,11 @@ +{ + "oracle": {}, + "cmux": { + "frame-buttons": 400, + "snapshot-scales-linearly": true, + "main-calls": true, + "main-calls-scale-linearly": true, + "frame-calls": "400 frames, 400 with a button", + "frame-calls-finish": true + } +} diff --git a/tests/browser-parity/goldens/32-agent-tools.json b/tests/browser-parity/goldens/32-agent-tools.json new file mode 100644 index 000000000000..8f1a700985b2 --- /dev/null +++ b/tests/browser-parity/goldens/32-agent-tools.json @@ -0,0 +1,279 @@ +{ + "oracle": {}, + "cmux": { + "markdown": "[Home](PRIMARY/index.html) [Docs](PRIMARY/aria.html)\n\n## Related\n\n[States](PRIMARY/states.html)\n\n# Field notes\n\nPlain text with **bold**, *emphasis*, `code()` and a [relative link](PRIMARY/aria.html?from=notes).\n\nbut this span shows\n\n- Alpha\n- Beta\n 1. Beta one\n 2. Beta two\n\n| Name | Score |\n| --- | --- |\n| Ada | 9 |\n| Linus \\| Torvalds | 7 |\n\n```\nline one\n indented line\n```\n\n> Quoted words.\n\nMore\n\n## Shadow heading\n\nShadow paragraph with slotted text.\n\n## Frame note\n\nText inside the cross-origin frame.\n\nFrame password Frame field\n\n### Lamp\n\n$19.00[View](PRIMARY/aria.html?p=lamp)\n\n- home\n- light\n\n### Desk\n\n$120.00[View](PRIMARY/aria.html?p=desk)\n\n- office\n\n### Chair\n\n$75.50[View](PRIMARY/aria.html?p=chair)\n\nUsername Password API key One-time code Sign in\n\nSigned out\n\nPlan `Pro`\n\nChoose fruit\n\nlog line 1\n\nlog line 2\n\nlog line 3\n\nlog line 4\n\nlog line 5\n\nlog line 6\n\nlog line 7\n\nlog line 8\n\nlog line 9\n\nlog line 10\n\nlog line 11\n\nlog line 12\n\nlog line 13\n\nlog line 14\n\nlog line 15\n\nlog line 16\n\nlog line 17\n\nlog line 18\n\nlog line 19\n\nlog line 20\n\nlog line 21\n\nlog line 22\n\nlog line 23\n\nlog line 24\n\nlog line 25\n\nlog line 26\n\nlog line 27\n\nlog line 28\n\nlog line 29\n\nlog line 30\n\nlog line 31\n\nlog line 32\n\nlog line 33\n\nlog line 34\n\nlog line 35\n\nlog line 36\n\nlog line 37\n\nlog line 38\n\nlog line 39\n\nlog line 40\n\nlog line 41\n\nlog line 42\n\nlog line 43\n\nlog line 44\n\nlog line 45\n\nlog line 46\n\nlog line 47\n\nlog line 48\n\nlog line 49\n\nlog line 50\n\nlog line 51\n\nlog line 52\n\nlog line 53\n\nlog line 54\n\nlog line 55\n\nlog line 56\n\nlog line 57\n\nlog line 58\n\nlog line 59\n\nlog line 60\n\n[Download the report](PRIMARY/download.txt)\n\n[Peer page](PEER/aria.html?linked) Go to peer by script\n\nDeep anchor text at the bottom.\n\nFooter words\n", + "markdown-main-chunk": "# Field notes\n\nPlain text with **bold**, *emphasis*, `code()` and a [relative link](PRIMARY/aria.html?from=notes).\n\nbut this span shows\n\n- Alpha\n- Beta\n 1. Beta one\n 2. Beta two\n\n| Name | Score |\n| --- | --- |\n| Ada | 9 |\n| Linus \\| Torvalds | 7 |\n\n```\nline one\n indented line\n```\n\n\n", + "markdown-main-next": [ + "> Quoted words.", + "", + "More" + ], + "markdown-no-links": "Plain text with **bold**, *emphasis*, `code()` and a relative link.", + "extract": { + "title": "Field notes", + "products": [ + { + "name": "Lamp", + "price": "$19.00", + "url": "PRIMARY/aria.html?p=lamp", + "sku": "A1", + "tags": [ + "home", + "light" + ] + }, + { + "name": "Desk", + "price": "$120.00", + "url": "PRIMARY/aria.html?p=desk", + "sku": "B2", + "tags": [ + "office" + ] + }, + { + "name": "Chair", + "price": "$75.50", + "url": "PRIMARY/aria.html?p=chair", + "sku": "C3", + "tags": [] + } + ], + "scores": [ + "Ada", + "9", + "Linus | Torvalds", + "7" + ] + }, + "extract-scoped": { + "name": "Desk", + "missing": null + }, + "search-text": { + "total": 3, + "matches": [ + { + "match": "Beta", + "context": "…shows Alpha Beta Beta one B…", + "ref": "string" + }, + { + "match": "Beta", + "context": "…Alpha Beta Beta one Beta tw…", + "ref": "string" + }, + { + "match": "Beta", + "context": "…a Beta one Beta two Name Sc…", + "ref": "string" + } + ] + }, + "search-text-ref-works": "LI", + "search-text-regex": [ + "log line 15", + "log line 25" + ], + "dropdown-select": [ + { + "index": 0, + "label": "Free", + "value": "free", + "selected": false, + "disabled": false + }, + { + "index": 1, + "label": "Pro", + "value": "pro", + "selected": true, + "disabled": false + }, + { + "index": 2, + "label": "Team", + "value": "team", + "selected": false, + "disabled": true + } + ], + "dropdown-closed-aria": "page.dropdownOptions: the listbox of shows no options; click it to open it, then call again", + "dropdown-aria": [ + { + "index": 0, + "label": "Apple", + "value": "apple", + "selected": false, + "disabled": false, + "ref": "string" + }, + { + "index": 1, + "label": "Pear", + "value": "pear", + "selected": true, + "disabled": false, + "ref": "string" + }, + { + "index": 2, + "label": "Plum", + "value": "plum", + "selected": false, + "disabled": true, + "ref": "string" + } + ], + "dropdown-aria-ref-clicks": "Apple", + "scroll-to-text": [ + "string", + true + ], + "scroll-page": [ + 0, + true, + true + ], + "scroll-element": [ + 120, + true, + true + ], + "highlight": 3, + "search-duckduckgo": [ + { + "title": "cmux - terminal for agents", + "url": "https://example.com/cmux", + "snippet": "A Ghostty-based macOS terminal with vertical tabs." + }, + { + "title": "Browser automation docs", + "url": "https://docs.example.org/browser", + "snippet": "Drive browser panes from scripts." + } + ], + "search-bing": [ + { + "title": "cmux on Bing", + "url": "https://example.com/cmux", + "snippet": "Terminal with notifications for agents." + } + ], + "search-bad-engine": "search: engine: expected one of duckduckgo, bing, google, got \"altavista\"", + "tools-list": [ + { + "name": "price", + "description": "Price of a product by SKU", + "params": { + "sku": "string" + }, + "domains": null + }, + { + "name": "onlyElsewhere", + "description": "", + "params": null, + "domains": [ + "example.com" + ] + } + ], + "tools-call": [ + "$120.00", + "$75.50" + ], + "tools-errors": [ + "tools.price: sku: required (string)", + "tools.price: sku: expected string, got number", + "tools.price: unknown argument extra; expected sku", + "tools.onlyElsewhere: available only on example.com; the current tab is " + ], + "downloads": [ + { + "url": "PRIMARY/download.txt", + "suggestedFilename": "parity-download.txt", + "state": "finished", + "readable": "parity download body\n" + } + ], + "record": { + "frames": 3, + "steps": [ + "tab.navigate", + "tab.navigate", + "after-action" + ], + "apng": [ + "PNG", + "acTL" + ] + }, + "secret-status": "Signed in as ada with key (password 13 chars, otp 0 digits)", + "secret-snapshot": [ + "title: Agent Tools ", + "url: PRIMARY/agent-tools.html?peer=PEER&key=", + " - textbox \"API key\": \"\"", + " - text: \"Signed in as ada with key (password 13 chars, otp 0 digits)\"" + ], + "secret-evaluate": [ + "", + "?peer=PEER&key=" + ], + "secret-console": [ + "signing in ada " + ], + "secret-value": [ + "", + "{\"k\":\"\"}", + [ + { + "name": "key", + "domains": [ + "localhost" + ], + "totp": false + }, + { + "name": "pw", + "domains": [ + "localhost" + ], + "totp": false + } + ] + ], + "secret-frame-refused": "locator.fill: secret \"key\" may not be typed into PEER; its domains are localhost", + "secret-keyboard-refused": "keyboard.type: a secret is typed into an element, so its domain can be checked: use locator.fill(secret(name)) or locator.type(secret(name))", + "secret-fetch-text": false, + "output:10": "typed and \nError: failed with ", + "policy-goto": "page.goto: PEER/aria.html is blocked: not in session.allowedDomains (http://localhost)", + "policy-fetch": "fetch: PEER/api/data is blocked: not in session.allowedDomains (http://localhost)", + "policy-tabs-open": "tabs.open: PEER/aria.html is blocked: not in session.allowedDomains (http://localhost)", + "policy-subresources": [ + "loaded", + "blocked" + ], + "policy-after-link": [ + "PRIMARY/agent-tools.html?peer=PEER", + [ + "before PEER/aria.html", + "before PEER/api/data", + "cancelled PEER/aria.html?linked" + ] + ], + "policy-prohibited": "page.goto: PEER/aria.html is blocked: prohibited by http://127.0.0.1 (session.prohibitedDomains)", + "policy-ip": "page.goto: PEER/aria.html is blocked: IP addresses are blocked (session.blockIPAddresses)", + "policy-locked": "session.allowedDomains: the domain policy is locked for this session", + "secret-screenshot": { + "maskedLikeAnySecret": true, + "textHidden": true, + "restored": true + }, + "secret-screenshot-concurrent": { + "masked": true, + "restored": true + }, + "secret-needs-domains": "secrets.set: x: domains: expected the domains it may be typed into, such as [\"example.com\"]; a secret without domains is not accepted" + } +} diff --git a/tests/browser-parity/goldens/33-markdown-corpus.json b/tests/browser-parity/goldens/33-markdown-corpus.json new file mode 100644 index 000000000000..1b30b4b8fabe --- /dev/null +++ b/tests/browser-parity/goldens/33-markdown-corpus.json @@ -0,0 +1,41 @@ +{ + "oracle": {}, + "cmux": { + "wikipedia:missing-links": [], + "wikipedia:leaks": [], + "wikipedia:main-is-a-part": true, + "wikipedia:no-markup": true, + "hackernews:missing-links": [], + "hackernews:leaks": [], + "hackernews:main-is-a-part": true, + "hackernews:no-markup": true, + "github:missing-links": [], + "github:leaks": [], + "github:main-is-a-part": true, + "github:no-markup": true, + "mdn:missing-links": [], + "mdn:leaks": [], + "mdn:main-is-a-part": true, + "mdn:no-markup": true, + "mdn-iframe:missing-links": [], + "mdn-iframe:leaks": [], + "mdn-iframe:main-is-a-part": true, + "mdn-iframe:no-markup": true, + "npr:missing-links": [], + "npr:leaks": [], + "npr:main-is-a-part": true, + "npr:no-markup": true, + "bbc:missing-links": [], + "bbc:leaks": [], + "bbc:main-is-a-part": true, + "bbc:no-markup": true, + "books:missing-links": [], + "books:leaks": [], + "books:main-is-a-part": true, + "books:no-markup": true, + "vercel:missing-links": [], + "vercel:leaks": [], + "vercel:main-is-a-part": true, + "vercel:no-markup": true + } +} diff --git a/tests/browser-parity/goldens/34-cookie-scope.json b/tests/browser-parity/goldens/34-cookie-scope.json new file mode 100644 index 000000000000..aa4c05f37e20 --- /dev/null +++ b/tests/browser-parity/goldens/34-cookie-scope.json @@ -0,0 +1,28 @@ +{ + "oracle": {}, + "cmux": { + "start": [ + "peer b1", + "peer parity", + "primary a1", + "primary a2", + "primary parity" + ], + "by-name": [ + "peer b1", + "peer parity", + "primary a2", + "primary parity" + ], + "by-regexp": [ + "peer b1", + "peer parity", + "primary a2" + ], + "this-site": [ + "peer b1", + "peer parity" + ], + "peer-site": [] + } +} diff --git a/tests/browser-parity/goldens/35-pointer-owner.json b/tests/browser-parity/goldens/35-pointer-owner.json new file mode 100644 index 000000000000..dbda648f8434 --- /dev/null +++ b/tests/browser-parity/goldens/35-pointer-owner.json @@ -0,0 +1,9 @@ +{ + "oracle": {}, + "cmux": { + "held": true, + "bounded": true, + "names-holder": true, + "released": true + } +} diff --git a/tests/browser-parity/goldens/36-session-tabs.json b/tests/browser-parity/goldens/36-session-tabs.json new file mode 100644 index 000000000000..a1c7dfdf6b2b --- /dev/null +++ b/tests/browser-parity/goldens/36-session-tabs.json @@ -0,0 +1,16 @@ +{ + "oracle": {}, + "cmux": { + "popup-store-cookie": [ + "brepl_store=session" + ], + "user-tab-notifications": "denied", + "user-tab-dialog-listener": "confirm true", + "user-tab-download-listener": "parity-download.txt", + "released-on-session-end": [ + "keyup A true", + "keyup Shift true", + "mouseup 0 true" + ] + } +} diff --git a/tests/browser-parity/goldens/37-session-context.json b/tests/browser-parity/goldens/37-session-context.json new file mode 100644 index 000000000000..df57b2d3edf9 --- /dev/null +++ b/tests/browser-parity/goldens/37-session-context.json @@ -0,0 +1,8 @@ +{ + "oracle": {}, + "cmux": { + "user-tab-keeps-its-user-agent": true, + "created-tab-gets-the-user-agent": true, + "owner-options-survive-another-session": true + } +} diff --git a/tests/browser-parity/goldens/38-cookie-guards.json b/tests/browser-parity/goldens/38-cookie-guards.json new file mode 100644 index 000000000000..6a8098e3a364 --- /dev/null +++ b/tests/browser-parity/goldens/38-cookie-guards.json @@ -0,0 +1,21 @@ +{ + "oracle": {}, + "cmux": { + "get-blocked-url": "blocked", + "get-hides-blocked-site": 0, + "set-blocked-site": "blocked", + "clear-blocked-tab": "blocked", + "peer-cookies-after-policy": [ + "parity" + ], + "named-site-ignored": [ + "parity" + ], + "clear-all-profile": "refused: profile", + "profile-kept-after-clear-all": [ + "parity" + ], + "set-parent-of-allowed": "blocked", + "set-allowed-host": "done" + } +} diff --git a/tests/browser-parity/goldens/39-policy-rules.json b/tests/browser-parity/goldens/39-policy-rules.json new file mode 100644 index 000000000000..f6a5666ce320 --- /dev/null +++ b/tests/browser-parity/goldens/39-policy-rules.json @@ -0,0 +1,8 @@ +{ + "oracle": {}, + "cmux": { + "next-call-fails": "policy not applied", + "later-calls-fail": "policy not applied", + "a-compiling-policy-recovers": "done" + } +} diff --git a/tests/browser-parity/lib/corpus.mjs b/tests/browser-parity/lib/corpus.mjs new file mode 100644 index 000000000000..66fc7e38c836 --- /dev/null +++ b/tests/browser-parity/lib/corpus.mjs @@ -0,0 +1,307 @@ +#!/usr/bin/env node +// Real-site snapshot corpus: public pages frozen as static HTML so snapshot +// invariants can be checked offline and deterministically. +// +// node tests/browser-parity/lib/corpus.mjs capture [--only NAME] +// Loads each page in headless Chrome (throwaway profile, logged out), +// waits for it to settle, and writes fixtures/corpus/NAME.html: the +// post-JavaScript DOM with scripts removed, stylesheets inlined and +// pruned to rules that match, fonts and remote images replaced, and +// iframes inlined as srcdoc. +// node tests/browser-parity/lib/corpus.mjs oracle [--only NAME] +// Serves the frozen pages and records, from Chrome, what the snapshot +// invariants compare against (fixtures/corpus/NAME.oracle.json): the +// interactive elements in Playwright's AI snapshot with each element's +// path, and text Chrome does not render. Scenario 27 judges each path's +// visibility in the engine that renders cmux (fixtures/corpus/gt.js). +// +// Sizes of reference A's snapshots of the same frozen pages live in +// fixtures/corpus/reference-a-sizes.json (recorded once with reference A's REPL). +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadPlaywright } from "./dev-driver.mjs"; +import { startFixtureServers } from "./fixture-server.mjs"; + +const corpusDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "fixtures", "corpus"); + +export const PAGES = [ + { name: "wikipedia", url: "https://en.wikipedia.org/wiki/WebKit" }, + { name: "hackernews", url: "https://news.ycombinator.com/" }, + { name: "github", url: "https://github.com/manaflow-ai/cmux" }, + { name: "mdn", url: "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map" }, + { name: "mdn-iframe", url: "https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe" }, + { name: "npr", url: "https://text.npr.org/" }, + { name: "bbc", url: "https://www.bbc.com/news" }, + { name: "books", url: "https://books.toscrape.com/" }, + { name: "vercel", url: "https://vercel.com/" }, +]; + +// Roles whose elements an agent acts on; recall is checked for these. +export const INTERACTIVE_ROLES = ["button", "link", "textbox", "searchbox", "checkbox", "radio", "combobox", "listbox", + "option", "menuitem", "menuitemcheckbox", "menuitemradio", "slider", "spinbutton", "switch", "tab", "treeitem"]; + +// Runs in the page: freezes the document into static HTML. +function freezeDocument() { + const TRANSPARENT = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; + const doc = document; + // Stylesheets: keep rules whose selectors match something, drop fonts, + // imports and remote URLs. + const stripPseudo = (sel) => + sel.replace(/::?[\w-]+(\((?:[^()]|\([^()]*\))*\))?/g, (m) => (/^:(not|is|where|has)\(/.test(m) ? m : "")).trim() || "*"; + const matches = (selectorText) => { + for (const part of selectorText.split(",")) { + try { + if (doc.querySelector(stripPseudo(part))) return true; + } catch { + return true; + } + } + return false; + }; + const cleanCss = (text) => + text + .replace(/cursor:\s*(url\([^)]*\)\s*(\d+\s+\d+\s*)?,\s*)+/g, "cursor: ") + .replace(/url\((?!["']?data:)[^)]*\)/g, "none"); + const ruleText = (rule) => { + if (rule instanceof CSSStyleRule) return matches(rule.selectorText) ? cleanCss(rule.cssText) : ""; + if (rule instanceof CSSMediaRule || rule instanceof CSSSupportsRule || (typeof CSSLayerBlockRule !== "undefined" && rule instanceof CSSLayerBlockRule) || (typeof CSSContainerRule !== "undefined" && rule instanceof CSSContainerRule)) { + const inner = [...rule.cssRules].map(ruleText).filter(Boolean).join("\n"); + if (!inner) return ""; + const head = rule.cssText.slice(0, rule.cssText.indexOf("{")); + return `${head}{\n${inner}\n}`; + } + if (rule instanceof CSSFontFaceRule || rule instanceof CSSImportRule || rule instanceof CSSKeyframesRule) return ""; + return cleanCss(rule.cssText); + }; + let css = ""; + for (const sheet of doc.styleSheets) { + let rules; + try { + rules = sheet.cssRules; + } catch { + continue; + } + if (sheet.media && sheet.media.mediaText && !/all|screen/.test(sheet.media.mediaText)) continue; + css += [...rules].map(ruleText).filter(Boolean).join("\n") + "\n"; + } + // Custom properties nothing reads (theme palettes) are most of some sites' CSS. + const inlineStyles = [...doc.querySelectorAll("[style]")].map((e) => e.getAttribute("style")).join("\n"); + const used = new Set(); + let frontier = css + inlineStyles; + for (let round = 0; round < 8 && frontier; round++) { + const found = new Set([...frontier.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1]).filter((v) => !used.has(v))); + for (const v of found) used.add(v); + frontier = [...css.matchAll(/[{;]\s*(--[\w-]+)\s*:([^;{}]*)/g)].filter((m) => found.has(m[1])).map((m) => m[2]).join("\n"); + } + // Only declarations (after `{` or `;`); BEM selectors also contain `--x:`. + css = css.replace(/([{;]\s*)(--[\w-]+)\s*:[^;{}]*;?/g, (decl, lead, name) => (used.has(name) ? decl : lead)); + for (const el of [...doc.querySelectorAll("style, link[rel~=stylesheet], link[rel=preload], link[rel=prefetch], link[rel=modulepreload], link[rel=icon], link[rel~=alternate], link[rel=manifest], link[rel=preconnect], link[rel=dns-prefetch]")]) el.remove(); + const style = doc.createElement("style"); + style.textContent = css; + doc.head.appendChild(style); + // Scripts, event handlers, and anything that fetches or tracks. + for (const el of [...doc.querySelectorAll("script, noscript, object, embed, base, meta[http-equiv], template")]) el.remove(); + for (const el of doc.querySelectorAll("meta")) { + if (!el.hasAttribute("charset") && el.getAttribute("name") !== "viewport") el.remove(); + } + const walker = doc.createTreeWalker(doc.documentElement, NodeFilter.SHOW_COMMENT); + const comments = []; + while (walker.nextNode()) comments.push(walker.currentNode); + for (const c of comments) c.remove(); + for (const el of doc.querySelectorAll("*")) { + for (const attr of [...el.attributes]) { + const n = attr.name; + if (/^on/i.test(n) || n === "nonce" || n === "integrity" || n === "ping" || n === "srcset" || n === "imagesrcset") el.removeAttribute(n); + else if (n === "style" && /url\(/.test(attr.value)) el.setAttribute("style", cleanCss(attr.value)); + // Long tooltips (full commit messages) and bulky data blobs (MediaWiki's + // data-mw) do not change what renders; keep the corpus small. + else if (n === "title" && attr.value.length > 300) el.setAttribute("title", attr.value.slice(0, 300)); + else if (n.startsWith("data-") && attr.value.length > 200) el.removeAttribute(n); + } + const tag = el.localName; + if (tag === "input" && el.type === "hidden") el.removeAttribute("value"); + if (tag === "img" || (tag === "input" && el.type === "image")) { + const r = el.getBoundingClientRect(); + if (!el.hasAttribute("width") && r.width) el.setAttribute("width", String(Math.round(r.width))); + if (!el.hasAttribute("height") && r.height) el.setAttribute("height", String(Math.round(r.height))); + el.setAttribute("src", TRANSPARENT); + el.removeAttribute("loading"); + } + if (tag === "source") el.remove(); + if (tag === "video" || tag === "audio") { + el.removeAttribute("src"); + el.removeAttribute("poster"); + } + if (tag === "use" && /^(https?:)?\/\//.test(el.getAttribute("href") || "")) el.removeAttribute("href"); + if (tag === "path") el.removeAttribute("d"); + // Links on the page's own host stay root-relative, so they stay on-site + // when the fixture server serves the page; others become absolute. + if (tag === "a" && el.getAttribute("href")) { + el.setAttribute("href", el.host === location.host ? el.pathname + el.search + el.hash : el.href); + } + if (tag === "form" && el.getAttribute("action")) el.setAttribute("action", el.action); + // Live form state becomes markup. + if (tag === "input" && el.type !== "hidden" && el.type !== "file") { + if (el.type === "checkbox" || el.type === "radio") el.toggleAttribute("checked", el.checked); + else el.setAttribute("value", el.value); + } + } + return "\n" + doc.documentElement.outerHTML; +} + +async function settle(page) { + await page.waitForLoadState("load").catch(() => {}); + await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => {}); + // Load lazy content the way a reader scrolling the page would. + await page.evaluate(async () => { + for (let y = 0; y < document.body.scrollHeight; y += 700) { + window.scrollTo(0, y); + await new Promise((r) => setTimeout(r, 60)); + } + window.scrollTo(0, 0); + }); + await page.waitForTimeout(800); +} + +// Freezes child frames first, then this one, so each iframe carries its +// content as srcdoc. Tiny frames (trackers, ads' pixels) are removed. +async function freezeFrame(frame) { + for (const child of frame.childFrames()) { + const owner = await child.frameElement().catch(() => null); + if (!owner) continue; + const box = await owner.boundingBox().catch(() => null); + if (!box || box.width < 50 || box.height < 50) { + await owner.evaluate((el) => el.remove()).catch(() => {}); + continue; + } + const html = await freezeFrame(child).catch(() => null); + await owner.evaluate((el, h) => { + el.removeAttribute("src"); + if (h) el.setAttribute("srcdoc", h); + }, html).catch(() => {}); + } + return frame.evaluate(freezeDocument); +} + +async function capture(only) { + const { chromium } = loadPlaywright(); + const browser = await chromium.launch({ channel: "chrome", headless: true }); + const context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, + locale: "en-US", + timezoneId: "UTC", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36", + }); + fs.mkdirSync(corpusDir, { recursive: true }); + try { + for (const entry of PAGES.filter((p) => !only || p.name === only)) { + const page = await context.newPage(); + await page.goto(entry.url, { waitUntil: "domcontentloaded", timeout: 60_000 }); + await settle(page); + const html = await freezeFrame(page.mainFrame()); + const out = path.join(corpusDir, `${entry.name}.html`); + fs.writeFileSync(out, `\n` + html.replace(/^\n/, "\n")); + console.log(`${entry.name}: ${(fs.statSync(out).size / 1024).toFixed(0)} KB`); + await page.close(); + } + } finally { + await browser.close(); + } +} + +// Runs in the page: text Chrome does not render, minus any text that is +// also rendered somewhere, so a match in a snapshot can only be a leak. +function hiddenTexts() { + const rendered = (el) => { + for (let e = el; e; e = e.parentElement || (e.getRootNode() && e.getRootNode().host)) { + if (e.nodeType !== 1) continue; + if (e.hasAttribute("inert")) return false; + } + return el.checkVisibility({ visibilityProperty: true }); + }; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + const visible = []; + const hidden = []; + while (walker.nextNode()) { + const node = walker.currentNode; + const text = node.nodeValue.replace(/\s+/g, " ").trim(); + const parent = node.parentElement; + if (!text || !parent || /^(script|style|noscript|template|title)$/i.test(parent.localName)) continue; + (rendered(parent) ? visible : hidden).push(text); + } + const seen = visible.join(" \n "); + const out = []; + for (const text of new Set(hidden)) if (text.length >= 16 && !seen.includes(text)) out.push(text); + return out.slice(0, 300); +} + +function interactiveFromAiSnapshot(text) { + const out = []; + for (const line of text.split("\n")) { + const m = /^\s*- ([\w-]+)(?: "((?:[^"\\]|\\.)*)")?(.*)$/.exec(line); + if (!m || !INTERACTIVE_ROLES.includes(m[1])) continue; + const ref = /\[ref=(\w+)\]/.exec(m[3]); + out.push({ role: m[1], name: m[2] ? JSON.parse(`"${m[2]}"`) : "", ref: ref && ref[1] }); + } + return out; +} + +// Visibility ground truth shared with scenario 27 (fixtures/corpus/gt.js). +const gtSource = fs.readFileSync(path.join(corpusDir, "gt.js"), "utf8"); + +async function oracle(only) { + const { chromium } = loadPlaywright(); + const servers = await startFixtureServers(); + const browser = await chromium.launch({ channel: "chrome", headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, locale: "en-US", timezoneId: "UTC" }); + try { + for (const entry of PAGES.filter((p) => !only || p.name === only)) { + const page = await context.newPage(); + await page.goto(`${servers.origins.primary}/corpus/${entry.name}.html`, { waitUntil: "load" }); + await page.waitForTimeout(300); + const ai = await page._snapshotForAI(); + const aiText = typeof ai === "string" ? ai : ai.full; + // _snapshotForAI inlines frames; parse the whole text once. Each entry + // carries its element's path, so a check can judge visibility in the + // engine that renders cmux (scenario 27); Chrome's own judgement is + // only counted here. + const interactive = []; + let hiddenInChrome = 0; + for (const item of interactiveFromAiSnapshot(aiText)) { + const r = item.ref + ? await page.locator(`aria-ref=${item.ref}`).evaluate((el, src) => { + const gt = (0, eval)(src + "; ({ shown, pathOf })"); + return { shown: gt.shown(el), path: gt.pathOf(el) }; + }, gtSource).catch(() => null) + : null; + if (r && !r.shown) hiddenInChrome++; + // Playwright gives no ref to an element without a visible box. + if (!item.ref) interactive.push({ role: item.role, name: item.name, boxless: true }); + else interactive.push({ role: item.role, name: item.name, path: r ? r.path : null }); + } + // Hidden text can still name an element (aria-labelledby a hidden + // tooltip); Chrome prints such names, so they are not leaks. + const squash = (t) => t.replace(/[\s\u200b-\u200d\u2060\ufeff]+/g, "").toLowerCase(); + const aiSquashed = squash(aiText); + const hidden = []; + for (const frame of page.frames()) hidden.push(...(await frame.evaluate(hiddenTexts).catch(() => []))); + hidden.splice(0, hidden.length, ...hidden.filter((t) => !aiSquashed.includes(squash(t)))); + const record = { url: entry.url, interactive, hiddenInChrome, hidden, chromeAiSnapshotBytes: Buffer.byteLength(aiText) }; + fs.writeFileSync(path.join(corpusDir, `${entry.name}.oracle.json`), JSON.stringify(record, null, 1) + "\n"); + console.log(`${entry.name}: ${interactive.length} interactive (${hiddenInChrome} not shown in Chrome), ${hidden.length} hidden texts`); + await page.close(); + } + } finally { + await browser.close(); + await servers.close(); + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const [mode, flag, value] = process.argv.slice(2); + const only = flag === "--only" ? value : null; + if (mode === "capture") await capture(only); + else if (mode === "oracle") await oracle(only); + else throw new Error("usage: corpus.mjs capture|oracle [--only NAME]"); +} diff --git a/tests/browser-parity/lib/dev-driver.mjs b/tests/browser-parity/lib/dev-driver.mjs new file mode 100644 index 000000000000..87d489073b6b --- /dev/null +++ b/tests/browser-parity/lib/dev-driver.mjs @@ -0,0 +1,1367 @@ +// `dev` browser driver: implements docs/browser-repl/driver-protocol.md on +// Playwright WebKit so the engine-neutral runtime in Resources/browser-repl can +// be developed and checked without an app build. +// +// Playwright cannot choose a content world on WebKit, so the "agent" world is +// the main world and the page agent lives under a non-enumerable symbol. +// Input goes through page.mouse / page.keyboard, which WebKit delivers as +// trusted events. +// +// One browser serves several REPL sessions, as one cmux window does: each +// session gets its own driver, and a session's detach closes the tabs it +// opened unless they were kept (tab.keep), like the app's one-shot runs. +import fs from "node:fs"; +import path from "node:path"; +import vm from "node:vm"; +import crypto from "node:crypto"; +import os from "node:os"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { createBoundary } from "./native-boundary.mjs"; +import { siteOf } from "./public-suffix.mjs"; +import { makeTestDir, removeTestDir, removeTestDirIfEmpty } from "./test-dirs.mjs"; + +const require = createRequire(import.meta.url); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +export const runtimeDir = path.join(repoRoot, "Resources/browser-repl"); + +const AGENT_KEY = 'Symbol.for("cmux.browserRepl.agent")'; +const NEEDS_AGENT = "__cmuxNeedsAgent__"; +const ERROR_KEY = "__cmuxError__"; +const JSON_KEY = "__cmuxJson__"; + +export function loadPlaywright() { + process.env.PLAYWRIGHT_BROWSERS_PATH ??= path.join(process.env.HOME, ".cache/cmux-parity-browsers"); + // A node_modules directory with playwright: PARITY_PLAYWRIGHT_DIR, else the + // copy bundled with reference B's runtime (lib/references.mjs), else the + // usual resolution from here. + const runtime = process.env.PARITY_REFERENCE_B_RUNTIME || process.env.CUA_REFERENCE_RUNTIME; + const dirs = [process.env.PARITY_PLAYWRIGHT_DIR, runtime && path.join(runtime, "lib/node_modules")].filter(Boolean); + for (const d of dirs) { + try { + return require(path.join(d, "playwright")); + } catch {} + } + try { + return require("playwright"); + } catch (e) { + throw new Error(`playwright not found: set PARITY_PLAYWRIGHT_DIR to a node_modules directory that holds it (${e.message.split("\n")[0]})`); + } +} + +// Builds the install script from the recipe in page-agent.js. +export function agentInstallSource() { + const injected = fs.readFileSync(path.join(runtimeDir, "vendor/playwright-injected.js"), "utf8"); + const agent = fs.readFileSync(path.join(runtimeDir, "page-agent.js"), "utf8"); + return `(() => {\nconst module = {};\n${injected}\n;const __cmuxInjectedScriptFactory = module.exports.InjectedScript;\n${agent}\n})()`; +} + +// The app's page clipboard guard (BrowserReplPageClipboard): in a tab a +// session created, the page's Clipboard API and execCommand("copy" | "cut") +// write the tab's clipboard, never the system's. The app also switches +// WebKit's asynchronous Clipboard API off; Playwright cannot, so here the +// same page-clipboard.js replaces it in every document the page loads. +export function pageClipboardInitScript() { + const shim = fs.readFileSync(path.join(runtimeDir, "page-clipboard.js"), "utf8"); + return `(${shim})(((post) => (message) => post ? post(message) : Promise.reject(new Error("the tab's clipboard is unavailable")))(globalThis.__cmuxReplClipboard));`; +} + +class DriverError extends Error { + constructor(code, message) { + super(message); + this.code = code; + } +} + +const hexId = () => crypto.randomBytes(16).toString("hex").toUpperCase(); + +function pngSize(buf) { + if (buf.length > 24 && buf.toString("ascii", 1, 4) === "PNG") return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; + return { width: 0, height: 0 }; +} + +// Playwright WebKit cannot print. A one-page PDF that carries the page text +// keeps page.pdf() usable in dev runs; the app driver uses WKWebView.createPDF. +function textPdf(text) { + const lines = text.split("\n").map((l) => l.trim()).filter(Boolean).slice(0, 60); + const esc = (s) => s.replace(/[\\()]/g, (c) => "\\" + c).replace(/[^\x20-\x7e]/g, "?"); + const stream = ["BT", "/F1 11 Tf", "50 800 Td", "14 TL", ...lines.map((l) => `(${esc(l)}) '`), "ET"].join("\n"); + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", + `<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}\nendstream`, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ]; + let out = "%PDF-1.4\n"; + const offsets = []; + objects.forEach((o, i) => { + offsets.push(Buffer.byteLength(out)); + out += `${i + 1} 0 obj\n${o}\nendobj\n`; + }); + const xref = Buffer.byteLength(out); + out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n` + offsets.map((o) => `${String(o).padStart(10, "0")} 00000 n \n`).join(""); + out += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; + return Buffer.from(out); +} + +// `setupContext(context)` runs once on the Playwright context before any tab +// opens (site-tool tests route real hostnames to local mock sites with it). +// The id `tabs.list` and `tabs.dataStore` report for the context's one store. +const DATA_STORE = "default"; + +export async function createDevBrowser({ headless = true, viewport = { width: 1280, height: 800 }, setupContext } = {}) { + const { webkit } = loadPlaywright(); + const installSource = agentInstallSource(); + const browser = await webkit.launch({ headless }); + const context = await browser.newContext({ viewport, acceptDownloads: true }); + if (setupContext) await setupContext(context); + // The app's agent world sees closed shadow roots (WebKit's + // allowAccessToClosedShadowRoots world option). Playwright cannot configure + // a world, and this driver's agent shares the main world, so closed roots + // are exposed through `shadowRoot` in the main world. Pages here therefore + // see their own closed roots too; no fixture depends on the difference. + await context.addInitScript(() => { + const attach = Element.prototype.attachShadow; + const getter = Object.getOwnPropertyDescriptor(Element.prototype, "shadowRoot").get; + const closed = new WeakMap(); + Element.prototype.attachShadow = function (init) { + const root = attach.call(this, init); + if (init && init.mode === "closed") closed.set(this, root); + return root; + }; + Object.defineProperty(Element.prototype, "shadowRoot", { + configurable: true, + enumerable: true, + get() { + return getter.call(this) || closed.get(this) || null; + }, + }); + }); + const drivers = new Set(); + const tabs = new Map(); + // Visits most recent first, one row per URL. + const history = []; + const recordVisit = (url, title) => { + const at = history.findIndex((h) => h.url === url); + if (at >= 0) history.splice(at, 1); + history.unshift({ url, title: title || "", dateVisited: Date.now() }); + }; // targetId -> tab record + const tabOf = new WeakMap(); // page -> tab record + const dialogs = new Map(); + const choosers = new Map(); + const downloads = new Map(); + let activeTarget = null; + let nextId = 1; + const modifiersDown = new Set(); + + const emit = (event, payload) => { + for (const d of drivers) { + for (const h of d.listeners.get(event) ?? []) { + try { + h(payload); + } catch (e) { + console.error(`driver listener for ${event} failed:`, e); + } + } + } + }; + + function frameId(tab, frame) { + let id = tab.frameIds.get(frame); + if (!id) { + id = frame === tab.page.mainFrame() ? `${tab.targetId}:main` : `${tab.targetId}:f${nextId++}`; + tab.frameIds.set(frame, id); + tab.frames.set(id, frame); + } + return id; + } + + function register(page) { + if (tabOf.has(page)) return tabOf.get(page); + const tab = { targetId: hexId(), page, frameIds: new WeakMap(), frames: new Map(), clipboard: [], clipboardCommand: null, openerTargetId: undefined, openDialogs: 0, title: "", loadState: "commit", creator: null, handled: new Map(), inputDrivers: [], heldKeys: new Map(), heldButtons: new Map() }; + tabs.set(tab.targetId, tab); + tabOf.set(page, tab); + frameId(tab, page.mainFrame()); + const targetId = tab.targetId; + page.on("console", (m) => emit("console", { targetId, type: m.type(), text: m.text(), location: m.location() })); + page.on("pageerror", (e) => { + // WebKit reports unhandled rejections as "Error: "; Chromium + // and the protocol carry the bare message. + let message = e.message; + if (e.name === "Unhandled Promise Rejection") message = message.replace(/^[A-Za-z]*Error: /, ""); + emit("pageerror", { targetId, message, stack: e.stack ?? "" }); + }); + page.on("dialog", (d) => { + // A user's tab keeps its own UI for dialogs no session handles; this + // driver stands in for the user: it lets the page leave on + // beforeunload, as cmux does with no session, and dismisses the rest. + if (!routesToSessions(tab, "dialog")) { + (d.type() === "beforeunload" ? d.accept() : d.dismiss()).catch(() => {}); + return; + } + const dialogId = `d${nextId++}`; + // As in the app: a dialog during Copy, Cut or Paste is dismissed at + // once, so it cannot hold the command, and reported. + if (tab.clipboardCommand) { + d.dismiss().catch(() => {}); + emit("dialog.opened", { targetId, dialogId, type: d.type(), message: d.message(), defaultValue: d.defaultValue(), dismissedDuring: tab.clipboardCommand }); + return; + } + dialogs.set(dialogId, d); + tab.openDialogs++; + emit("dialog.opened", { targetId, dialogId, type: d.type(), message: d.message(), defaultValue: d.defaultValue() }); + }); + page.on("filechooser", async (c) => { + // The user's own file panel: nobody answers it here. + if (!routesToSessions(tab, "filechooser")) return; + const chooserId = `c${nextId++}`; + choosers.set(chooserId, c); + const frame = c.element().ownerFrame ? await c.element().ownerFrame() : page.mainFrame(); + await ensureAgent(frame); + const element = await c.element().evaluate((el, key) => globalThis[Symbol.for(key)].handleFor(el), "cmux.browserRepl.agent"); + emit("filechooser.opened", { targetId, chooserId, frameId: frameId(tab, frame), element, multiple: c.isMultiple() }); + }); + page.on("download", (d) => { + // The user's download: it is not reported to the sessions. + if (!routesToSessions(tab, "download")) return; + const downloadId = `dl${nextId++}`; + downloads.set(downloadId, d); + emit("download.started", { targetId, downloadId, url: d.url(), suggestedFilename: d.suggestedFilename() }); + d.path().then( + (p) => emit("download.finished", { targetId, downloadId, path: p }), + (e) => emit("download.finished", { targetId, downloadId, error: String(e.message) }), + ); + }); + const net = (event) => (r) => { + const req = r.request ? r.request() : r; + emit(event, { + targetId, + requestId: req._guid ?? req.url(), + url: req.url(), + method: req.method(), + resourceType: req.resourceType(), + status: r.status ? r.status() : undefined, + headers: r.headers ? r.headers() : undefined, + }); + }; + page.on("request", net("request")); + page.on("response", net("response")); + page.on("requestfailed", net("requestfailed")); + page.on("requestfinished", net("requestfinished")); + page.on("domcontentloaded", () => emit("tab.loadState", { targetId, state: "domcontentloaded" })); + page.on("load", () => { + emit("tab.loadState", { targetId, state: "load" }); + // Browser history, as cmux records it: http(s) main-frame loads. + const url = page.url(); + if (/^https?:/.test(url)) page.title().then((title) => recordVisit(url, title), () => recordVisit(url, "")); + }); + page.on("framenavigated", (f) => emit("tab.navigated", { targetId, frameId: frameId(tab, f), url: f.url() })); + page.on("close", () => { + tabs.delete(targetId); + if (activeTarget === targetId) activeTarget = [...tabs.keys()].at(-1) ?? null; + emit("tab.closed", { targetId }); + }); + return tab; + } + + // Session behaviors apply to tabs a session opened (and their popups) + // while it is attached; in any other tab only the events a session + // registered a handler for (tab.handleEvents) reach the sessions. + async function guardPageClipboard(tab) { + if (tab.pageClipboardGuarded) return; + tab.pageClipboardGuarded = true; + await tab.page.exposeBinding("__cmuxReplClipboard", (_source, message) => { + const items = message && Array.isArray(message.items) ? message.items : null; + if (!items || items.some((i) => !i || typeof i.type !== "string" || typeof i.base64 !== "string")) { + throw new Error("the clipboard write is not a list of typed items within the size limit"); + } + tab.clipboard = items.map((i) => ({ type: i.type, base64: i.base64 })); + }); + await tab.page.addInitScript({ content: pageClipboardInitScript() }); + } + + function routesToSessions(tab, event) { + if (tab.creator && drivers.has(tab.creator)) return true; + for (const [driver, events] of tab.handled) if (drivers.has(driver) && events.has(event)) return true; + // As in the app: a dialog or file chooser the page opens while it + // handles a session's input or navigation goes to that session. + if (event !== "download" && tab.inputDrivers.some((driver) => drivers.has(driver))) return true; + return false; + } + + context.on("page", async (page) => { + const tab = register(page); + const opener = await page.opener().catch(() => null); + if (opener && tabOf.has(opener)) { + tab.openerTargetId = tabOf.get(opener).targetId; + tab.creator ??= tabOf.get(opener).creator; + if (tab.creator) await guardPageClipboard(tab).catch(() => {}); + } + if (tab.openerTargetId) activeTarget = tab.targetId; + emit("tab.created", { targetId: tab.targetId, openerTargetId: tab.openerTargetId, url: page.url() }); + }); + + function tabFor(targetId) { + const tab = tabs.get(targetId); + if (!tab) throw new DriverError("closed", `Tab ${targetId} is closed`); + return tab; + } + function frameFor(targetId, id) { + const tab = tabFor(targetId); + if (!id) return tab.page.mainFrame(); + const frame = tab.frames.get(id); + if (!frame || frame.isDetached()) throw new DriverError("stale", `Frame ${id} is detached`); + return frame; + } + + async function ensureAgent(frame) { + const has = await frame.evaluate(`!!globalThis[${AGENT_KEY}]`); + if (!has) await frame.evaluate(installSource); + } + + async function evaluate(frame, { world = "page", source, args = [], handles = [], timeoutMs }) { + const needsAgent = world === "agent" || handles.length > 0; + const expr = `(async () => { + const __agent = globalThis[${AGENT_KEY}]; + if (${needsAgent} && !__agent) return { ${NEEDS_AGENT}: true }; + try { + const __handles = ${JSON.stringify(handles)}.map((h) => __agent.element(h)); + const __result = await (${source})(...__handles, ...${JSON.stringify(args)}); + // Agent results cross as JSON text, as in the app's driver: one + // string instead of Playwright's per-value serialization. + return ${world === "agent"} ? { ${JSON_KEY}: __result === undefined ? "null" : JSON.stringify(__result) } : __result; + } catch (e) { + return { ${ERROR_KEY}: { code: (e && e.code) || "evaluation", message: String(e && e.message !== undefined ? e.message : e), name: e && e.name } }; + } + })()`; + const run = async () => { + let result = await frame.evaluate(expr); + if (result && result[NEEDS_AGENT]) { + await frame.evaluate(installSource); + result = await frame.evaluate(expr); + } + if (result && typeof result[JSON_KEY] === "string") return JSON.parse(result[JSON_KEY]); + if (result && result[ERROR_KEY]) { + const e = new DriverError(result[ERROR_KEY].code, result[ERROR_KEY].message); + e.errorName = result[ERROR_KEY].name; + throw e; + } + return result; + }; + try { + if (!timeoutMs) return await run(); + let timer; + return await Promise.race([ + run(), + new Promise((_, reject) => (timer = setTimeout(() => reject(new DriverError("timeout", `Evaluation timed out after ${timeoutMs}ms`)), timeoutMs))), + ]).finally(() => clearTimeout(timer)); + } catch (e) { + if (e instanceof DriverError) throw e; + if (/Execution context was destroyed|Frame was detached|navigat/i.test(e.message)) throw new DriverError("stale", e.message); + if (/closed/i.test(e.message)) throw new DriverError("closed", e.message); + throw new DriverError("evaluation", e.message); + } + } + + async function withModifiers(page, modifiers = [], fn) { + const pressed = []; + for (const m of modifiers) { + if (!modifiersDown.has(m)) { + await page.keyboard.down(m); + pressed.push(m); + } + } + try { + return await fn(); + } finally { + for (const m of pressed.reverse()) await page.keyboard.up(m); + } + } + + const MODIFIER_KEYS = new Set(["Alt", "Control", "Meta", "Shift"]); + + // Meta+C, Meta+X and Meta+V use the tab's virtual clipboard, as the app + // driver does; the system pasteboard is never touched. The app runs + // WebKit's own Copy, Cut and Paste, so the page gets copy, cut and paste + // events with clipboardData. Playwright WebKit's own commands use the + // system clipboard, so this dispatches the events (not trusted) in the + // focused frame and does what WebKit does unless the page cancels them. + // + // As in the app, they run only in tabs a session created, and one the page + // keeps running past 5 s ends the tab's web content process (the app + // contains a late write to the system clipboard that way). Playwright + // cannot end one page's process, so this reports the crash, ignores what + // the page does afterwards, and lets its script run out. + const CLIPBOARD_COMMAND_TIMEOUT_MS = 5000; + async function clipboardShortcut(tab, key) { + const type = { c: "copy", x: "cut", v: "paste" }[key]; + const name = { copy: "Copy", cut: "Cut", paste: "Paste" }[type]; + if (!(tab.creator && drivers.has(tab.creator))) { + throw new DriverError( + "unsupported", + `${name} is refused in a user's tab (one no attached session opened): cmux ends the web content process of a tab whose page keeps a Copy, Cut or Paste running past its timeout, and it never does that to a user's tab. Use page.clipboard here, or open the page with tabs.open()`, + ); + } + let timer; + const expired = new Promise((resolve) => { timer = setTimeout(() => resolve(true), CLIPBOARD_COMMAND_TIMEOUT_MS); }); + try { + const finished = await Promise.race([runClipboardShortcut(tab, type).then(() => false), expired]); + if (finished) { + tab.clipboardRun = null; + tab.clipboardCommand = null; + emit("tab.crashed", { targetId: tab.targetId }); + throw new DriverError( + "timeout", + `${name} did not finish within 5 s, so cmux ended the tab's web content process: nothing the page does later reaches the system clipboard. The tab's clipboard is unchanged; call page.reload() or page.goto() to load the page again`, + ); + } + } finally { + clearTimeout(timer); + } + } + + async function runClipboardShortcut(tab, type) { + const page = tab.page; + let frame = page.mainFrame(); + for (const f of page.frames()) { + if (await f.evaluate(() => document.hasFocus() && !(document.activeElement instanceof HTMLIFrameElement)).catch(() => false)) frame = f; + } + const started = Symbol(type); + tab.clipboardCommand = type; + tab.clipboardRun = started; + // After a timeout the command is abandoned: what it finds later is + // dropped, as the app's ended process drops it. + const current = () => tab.clipboardRun === started; + try { + if (type === "paste") { + const item = tab.clipboard.find((i) => i.type === "text/plain"); + const text = item ? Buffer.from(item.base64, "base64").toString("utf8") : ""; + const entries = tab.clipboard.filter((i) => /^[\w.+-]+\/[\w.+-]+$/.test(i.type)).map((i) => [i.type, Buffer.from(i.base64, "base64").toString("utf8")]); + const cancelled = await frame.evaluate((entries) => { + const data = new DataTransfer(); + for (const [type, value] of entries) data.setData(type, value); + let el = document.activeElement || document.body; + while (el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement; + return !el.dispatchEvent(new ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true, composed: true })); + }, entries); + if (!cancelled && text && current()) await page.keyboard.insertText(text); + return; + } + // WebKit fires copy and cut only when something is selected. + const result = await frame.evaluate((type) => { + let el = document.activeElement || document.body; + while (el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement; + const field = (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) && el.selectionStart !== null; + const selection = field ? el.value.slice(el.selectionStart, el.selectionEnd) : String(getSelection() || ""); + if (!selection) return { selection, items: null }; + const data = new DataTransfer(); + const cancelled = !el.dispatchEvent(new ClipboardEvent(type, { clipboardData: data, bubbles: true, cancelable: true, composed: true })); + return { selection, items: cancelled ? [...data.types].map((t) => [t, data.getData(t)]) : null }; + }, type); + if (!current()) return; + tab.clipboard = result.items + ? result.items.map(([t, value]) => ({ type: t, base64: Buffer.from(value).toString("base64") })) + : [{ type: "text/plain", base64: Buffer.from(result.selection).toString("base64") }]; + // The app sends Cocoa's delete: action; execCommand is its page-side twin. + if (type === "cut" && !result.items && result.selection) await frame.evaluate(() => document.execCommand("delete")); + } finally { + if (current()) { + tab.clipboardCommand = null; + tab.clipboardRun = null; + } + } + } + + async function keyEvent(tab, { type, key, code, text, modifiers = [] }) { + const page = tab.page; + const lower = String(key).toLowerCase(); + if ((modifiers.includes("Meta") || modifiersDown.has("Meta")) && ["c", "x", "v"].includes(lower)) { + if (type === "down") await clipboardShortcut(tab, lower); + return; + } + const names = [key, code].filter(Boolean); + for (const name of names) { + try { + if (type === "down") await page.keyboard.down(name); + else await page.keyboard.up(name); + if (MODIFIER_KEYS.has(key)) type === "down" ? modifiersDown.add(key) : modifiersDown.delete(key); + return; + } catch (e) { + if (!/Unknown key/.test(e.message)) throw e; + } + } + if (type === "down" && text) await page.keyboard.insertText(text); + } + + // WebKit content-blocker rules applied with request routing: the last + // matching block or ignore-previous-rules rule decides. Main-frame + // documents are not routed, as in the app. + const RESOURCE_TYPES = { image: "image", stylesheet: "style-sheet", script: "script", font: "font", media: "media", fetch: "fetch", xhr: "fetch", websocket: "websocket", ping: "ping", other: "other" }; + let contentRules = []; + let routed = false; + // A main-frame navigation of a tab a session opened, to a URL that + // session's domain policy blocks, is cancelled and reported, as the app's + // navigation delegate does. + function cancelsNavigation(request) { + const tab = tabOf.get(request.frame().page()); + if (!tab) return false; + for (const d of drivers) { + if (!d.blockReason || !d.opened.has(tab.targetId)) continue; + const reason = d.blockReason(request.url()); + if (!reason) continue; + for (const h of d.listeners.get("navigation.blocked") ?? []) h({ targetId: tab.targetId, url: request.url(), reason }); + return true; + } + return false; + } + async function setContentRules(rules) { + contentRules = rules.map((r) => ({ re: new RegExp(r.trigger["url-filter"], "i"), types: r.trigger["resource-type"] || null, child: (r.trigger["load-context"] || []).includes("child-frame"), type: r.action.type })); + if (routed || !contentRules.length) return; + routed = true; + await context.route("**/*", (route, request) => { + const isDocument = request.resourceType() === "document"; + if (isDocument && request.frame().parentFrame() === null) return cancelsNavigation(request) ? route.abort("blockedbyclient") : route.fallback(); + const type = isDocument ? "document" : RESOURCE_TYPES[request.resourceType()] || "other"; + let blocked = false; + for (const r of contentRules) { + if (!r.re.test(request.url())) continue; + if (r.types && !r.types.includes(type)) continue; + if (isDocument && !r.child) continue; + blocked = r.type === "block"; + } + return blocked ? route.abort("blockedbyclient") : route.fallback(); + }); + } + + const methods = { + "history.search": async ({ queries = [], from, to, limit = 100 }) => { + const qs = queries.map((q) => String(q).toLowerCase()); + return history + .filter((h) => (from === undefined || h.dateVisited >= from) && (to === undefined || h.dateVisited <= to)) + .filter((h) => !qs.length || qs.some((q) => h.url.toLowerCase().includes(q) || h.title.toLowerCase().includes(q))) + .slice(0, limit); + }, + "tabs.list": async () => + Promise.all([...tabs.values()].map(async (t) => ({ + targetId: t.targetId, + title: await t.page.title().catch(() => ""), + url: t.page.url(), + active: t.targetId === activeTarget, + windowId: 1, + dataStore: DATA_STORE, + ...(t.openerTargetId ? { openerTargetId: t.openerTargetId } : {}), + }))), + // One Playwright context, so one data store for every tab. + "tabs.dataStore": async ({ targetId } = {}) => { + if (targetId !== undefined) tabFor(targetId); + return { dataStore: DATA_STORE }; + }, + "tabs.open": async ({ url, background, dataStore }, driver) => { + if (dataStore !== undefined && dataStore !== DATA_STORE) throw new DriverError("invalid", `tabs.open: no open tab uses data store ${JSON.stringify(dataStore)}`); + const page = await context.newPage(); + const tab = register(page); + tab.blankStart = !url; + tab.creator = driver; + await guardPageClipboard(tab); + driver.opened.add(tab.targetId); + if (!background) activeTarget = tab.targetId; + if (url) await page.goto(url, { waitUntil: "commit" }); + return { targetId: tab.targetId }; + }, + "tab.handleEvents": async ({ targetId, events }, driver) => { + const known = ["dialog", "filechooser", "download"]; + if (!Array.isArray(events) || events.some((e) => !known.includes(e))) { + throw new DriverError("invalid", `tab.handleEvents: events must be an array of ${known.join(", ")}`); + } + tabFor(targetId).handled.set(driver, new Set(events)); + }, + "tab.keep": async ({ targetId }, driver) => { + tabFor(targetId); + driver.opened.delete(targetId); + }, + "session.name": async ({ name }, driver) => { + driver.sessionName = String(name); + }, + // Browser-context options. Playwright fixes the user agent and proxy at + // context creation, so those are unsupported here; headers apply to every + // request (the app adds them to main-frame navigations only). + "session.configure": async (params) => { + if ((params.userAgent !== undefined && params.userAgent !== null) || (params.proxy !== undefined && params.proxy !== null)) { + throw new DriverError("unsupported", "the dev driver cannot change the user agent or proxy of a running context"); + } + if (params.extraHTTPHeaders !== undefined) await context.setExtraHTTPHeaders(params.extraHTTPHeaders || {}); + if (params.permissions !== undefined) { + await context.clearPermissions(); + if ((params.permissions || []).length) await context.grantPermissions(params.permissions); + } + if (params.contentRules !== undefined) await setContentRules(params.contentRules || []); + return { proxy: false }; + }, + "tabs.close": async ({ targetId, runBeforeUnload }) => { + await tabFor(targetId).page.close({ runBeforeUnload: !!runBeforeUnload }); + }, + "tabs.activate": async ({ targetId }) => { + await tabFor(targetId).page.bringToFront(); + activeTarget = targetId; + }, + "tab.navigate": async ({ targetId, url, waitUntil = "load", timeoutMs }) => { + const page = tabFor(targetId).page; + try { + const r = await page.goto(url, { waitUntil, timeout: timeoutMs ?? 30000 }); + return { url: page.url(), status: r ? r.status() : undefined }; + } catch (e) { + if (/Timeout/.test(e.message)) throw new DriverError("timeout", e.message.split("\n")[0]); + throw new DriverError("invalid", e.message.split("\n")[0]); + } + }, + "tab.history": async ({ targetId, delta, waitUntil = "load", timeoutMs }) => { + const page = tabFor(targetId).page; + const before = page.url(); + // The blank page a new tab starts on is not a history entry to go back + // to (Chrome drops it on the first navigation), as the app's driver. + const tab = tabFor(targetId); + if (delta < 0 && tab.blankStart && !tab.wentBack && (await page.evaluate("history.length").catch(() => 0)) === 2) return null; + if (delta < 0) tab.wentBack = true; + const opts = { waitUntil, timeout: timeoutMs ?? 30000 }; + const r = delta < 0 ? await page.goBack(opts) : await page.goForward(opts); + if (!r && page.url() === before) return null; + return { url: page.url() }; + }, + "tab.reload": async ({ targetId, waitUntil = "load", timeoutMs }) => { + const r = await tabFor(targetId).page.reload({ waitUntil, timeout: timeoutMs ?? 30000 }); + return r ? { status: r.status() } : null; + }, + "tab.info": async ({ targetId }) => { + const tab = tabFor(targetId); + const page = tab.page; + // Page script is blocked while a JavaScript dialog is open, so report + // the last known title and load state instead of evaluating. + if (!tab.openDialogs) { + const readyState = await page.mainFrame().evaluate("document.readyState").catch(() => "loading"); + tab.loadState = readyState === "complete" ? "load" : readyState === "interactive" ? "domcontentloaded" : "commit"; + tab.title = await page.title().catch(() => tab.title); + } + return { url: page.url(), title: tab.title, loadState: tab.loadState, viewport: page.viewportSize() ?? viewport, deviceScaleFactor: 1 }; + }, + "tab.setViewport": async ({ targetId, width, height, reset }) => { + await tabFor(targetId).page.setViewportSize(reset ? viewport : { width, height }); + }, + "tab.bringToFront": async ({ targetId }) => { + await tabFor(targetId).page.bringToFront(); + }, + "frames.list": async ({ targetId }) => { + const tab = tabFor(targetId); + const main = tab.page.mainFrame(); + const origin = (u) => { + try { + return new URL(u).origin; + } catch { + return u; + } + }; + const out = []; + const queue = [main]; + while (queue.length) { + const f = queue.shift(); + if (f.isDetached()) continue; + out.push({ + frameId: frameId(tab, f), + parentFrameId: f.parentFrame() ? frameId(tab, f.parentFrame()) : null, + url: f.url(), + name: f.name(), + crossOrigin: origin(f.url()) !== origin(main.url()), + }); + queue.push(...f.childFrames()); + } + return out; + }, + "frame.evaluate": async ({ targetId, frameId: id, ...rest }) => evaluate(frameFor(targetId, id), rest), + "frame.ownerBox": async ({ targetId, frameId: id }) => { + const frame = frameFor(targetId, id); + const owner = await frame.frameElement(); + return owner.evaluate((el) => { + const r = el.getBoundingClientRect(); + const cs = getComputedStyle(el); + const px = (v) => parseFloat(v) || 0; + return { + x: r.left + el.clientLeft + px(cs.paddingLeft), + y: r.top + el.clientTop + px(cs.paddingTop), + width: el.clientWidth - px(cs.paddingLeft) - px(cs.paddingRight), + height: el.clientHeight - px(cs.paddingTop) - px(cs.paddingBottom), + }; + }); + }, + // Proposed protocol addition: child frame of an