From 94dc44d3fc1dfbb57d1be58779d3f74b7a2166c2 Mon Sep 17 00:00:00 2001 From: iamiks Date: Wed, 8 Jul 2026 01:05:54 +0900 Subject: [PATCH] fix(plugin): harden Windows path boundaries --- lib/ourocode/command/registry/skill_loader.ex | 10 +- lib/ourocode/config/raw_loader.ex | 16 +- lib/ourocode/plugin/config_validation.ex | 11 +- lib/ourocode/plugin/path_policy.ex | 58 +++--- lib/ourocode/runtime/seed_artifact.ex | 8 +- lib/ourocode/terminal/file_discovery.ex | 1 + lib/ourocode/terminal/frame_sections.ex | 1 + lib/ourocode/terminal/interview_handoff.ex | 4 +- lib/ourocode/terminal/interview_panel/text.ex | 21 +- test/ourocode/cli_test.exs | 7 +- .../command/registry/skill_loader_test.exs | 47 +++-- test/ourocode/config/raw_loader_test.exs | 24 +++ test/ourocode/mcp/transport/sse_test.exs | 4 +- .../mcp/transport/stdio_cleanup_test.exs | 19 +- test/ourocode/mcp/transport/stdio_test.exs | 124 ++++++------ .../plugin/action_mapping_loader_test.exs | 20 +- .../plugin/adapter_mapping_loader_test.exs | 7 +- .../plugin/config_watcher/sources_test.exs | 48 +++-- test/ourocode/plugin/config_watcher_test.exs | 9 +- .../plugin/hot_reload_boundary_test.exs | 83 ++++---- test/ourocode/plugin/loader_test.exs | 2 +- test/ourocode/plugin/path_policy_test.exs | 54 ++++- .../plugin/renderer_mapping_loader_test.exs | 6 +- .../user_level/artifact_watcher_test.exs | 33 +++- .../runtime/application_bootstrap_test.exs | 9 +- test/ourocode/runtime/mcp_daemon_test.exs | 19 +- .../runtime/stream/resources_test.exs | 33 +++- .../runtime/stream_supervisor_test.exs | 6 +- ..._plugin_invocation_post_execution_test.exs | 41 +++- .../terminal/command_handler_test.exs | 5 +- .../terminal/conversation_store_test.exs | 9 +- test/ourocode/terminal/hud_model_test.exs | 4 +- .../terminal/interview_handoff_test.exs | 9 + .../terminal/interview_panel/text_test.exs | 60 +++++- .../terminal/node_terminal_runtime_test.exs | 30 ++- .../terminal/resume_sessions_test.exs | 32 ++- test/test_helper.exs | 186 ++++++++++++++++++ 37 files changed, 794 insertions(+), 266 deletions(-) diff --git a/lib/ourocode/command/registry/skill_loader.ex b/lib/ourocode/command/registry/skill_loader.ex index a4f128c..a6ab32a 100644 --- a/lib/ourocode/command/registry/skill_loader.ex +++ b/lib/ourocode/command/registry/skill_loader.ex @@ -33,14 +33,20 @@ defmodule Ourocode.Command.Registry.SkillLoader do end @spec parse_frontmatter(String.t()) :: map() - def parse_frontmatter("---\n" <> rest) do + def parse_frontmatter(contents) when is_binary(contents) do + contents + |> String.replace("\r\n", "\n") + |> parse_normalized_frontmatter() + end + + defp parse_normalized_frontmatter("---\n" <> rest) do case String.split(rest, "\n---", parts: 2) do [frontmatter, _body] -> parse_frontmatter_lines(frontmatter) [_without_closing_marker] -> %{} end end - def parse_frontmatter(_contents), do: %{} + defp parse_normalized_frontmatter(_contents), do: %{} defp normalize!(%{root: root, dir: skill_dir, file: skill_file}, source) do metadata = skill_file |> File.read!() |> parse_frontmatter() diff --git a/lib/ourocode/config/raw_loader.ex b/lib/ourocode/config/raw_loader.ex index 75a9984..d685542 100644 --- a/lib/ourocode/config/raw_loader.ex +++ b/lib/ourocode/config/raw_loader.ex @@ -43,7 +43,7 @@ defmodule Ourocode.Config.RawLoader do @spec discover_config_files(Path.t()) :: [Path.t()] def discover_config_files(project_dir) when is_binary(project_dir) do - root_dir = Path.expand(project_dir) + root_dir = absolute_path(project_dir) @supported_config_candidates |> Enum.map(&Path.join(root_dir, &1)) @@ -52,7 +52,7 @@ defmodule Ourocode.Config.RawLoader do @spec load(Path.t()) :: {:ok, RawConfig.t()} | {:error, Ourocode.Config.raw_config_error()} def load(project_dir) when is_binary(project_dir) do - root_dir = Path.expand(project_dir) + root_dir = absolute_path(project_dir) Enum.reduce_while(discover_config_files(root_dir), {:ok, []}, fn path, {:ok, entries} -> case parse_config_file(path, root_dir) do @@ -74,10 +74,10 @@ defmodule Ourocode.Config.RawLoader do @spec parse_config_file(Path.t(), Path.t() | nil) :: {:ok, RawConfig.file_entry()} | {:error, Ourocode.Config.raw_config_error()} def parse_config_file(path, root_dir \\ nil) when is_binary(path) do - expanded_path = Path.expand(path) + expanded_path = absolute_path(path) root_dir = - if is_binary(root_dir), do: Path.expand(root_dir), else: Path.dirname(expanded_path) + if is_binary(root_dir), do: absolute_path(root_dir), else: Path.dirname(expanded_path) with {:ok, format} <- config_format(expanded_path), {:ok, contents} <- read_config_file(expanded_path), @@ -106,6 +106,14 @@ defmodule Ourocode.Config.RawLoader do end end + defp absolute_path(path) do + if Path.type(path) == :absolute do + path + else + Path.expand(path) + end + end + defp config_format(path) do case path |> Path.basename() |> String.downcase() do name when name in ["ourocode.json", ".ourocode.json", "config.json"] -> diff --git a/lib/ourocode/plugin/config_validation.ex b/lib/ourocode/plugin/config_validation.ex index 3af4e4a..98e11bb 100644 --- a/lib/ourocode/plugin/config_validation.ex +++ b/lib/ourocode/plugin/config_validation.ex @@ -20,7 +20,7 @@ defmodule Ourocode.Plugin.ConfigValidation do String.contains?(command, [" ", "\t"]) -> schema_error("plugins[#{index}].entrypoint.command must not include arguments") - Path.type(command) == :absolute -> + absolute_path?(command) -> schema_error("plugins[#{index}].entrypoint.command must be a relative command") path_traverses?(command) -> @@ -53,7 +53,7 @@ defmodule Ourocode.Plugin.ConfigValidation do String.contains?(path, ["\0", "\n", "\r"]) -> schema_error("plugins[#{index}].#{label} must be a single relative path") - Path.type(path) == :absolute or path_traverses?(path) -> + absolute_path?(path) or path_traverses?(path) -> schema_error("plugins[#{index}].#{label} must be a relative path inside the plugin") true -> @@ -64,9 +64,14 @@ defmodule Ourocode.Plugin.ConfigValidation do @spec path_traverses?(String.t()) :: boolean() def path_traverses?(path) when is_binary(path) do path - |> Path.split() + |> String.split(["/", "\\"], trim: true) |> Enum.any?(&(&1 == "..")) end + defp absolute_path?(path) when is_binary(path) do + Path.type(path) == :absolute or String.starts_with?(path, ["/", "\\\\"]) or + Regex.match?(~r/^[A-Za-z]:[\/\\]/, path) + end + defp schema_error(message), do: {:error, {:invalid_plugin_config_schema, message}} end diff --git a/lib/ourocode/plugin/path_policy.ex b/lib/ourocode/plugin/path_policy.ex index bffa0d9..1451211 100644 --- a/lib/ourocode/plugin/path_policy.ex +++ b/lib/ourocode/plugin/path_policy.ex @@ -69,40 +69,42 @@ defmodule Ourocode.Plugin.PathPolicy do end defp canonical_path(path) do - expanded_path = Path.expand(path) - parts = Path.split(expanded_path) + path + |> Path.expand() + |> Path.split() + |> resolve_existing_segments() + end - case deepest_existing_prefix(parts) do - {existing_parts, remaining_parts} -> - Path.join([realpath(Path.join(existing_parts)) | remaining_parts]) + defp resolve_existing_segments([]), do: "" + defp resolve_existing_segments([root | parts]), do: resolve_existing_segments(root, parts) - nil -> - expanded_path - end - end + defp resolve_existing_segments(path, []), do: path - defp realpath(path) do - case System.find_executable("realpath") do - nil -> - path + defp resolve_existing_segments(path, [part | remaining_parts]) do + candidate = Path.join(path, part) - executable -> - case System.cmd(executable, [path], stderr_to_stdout: true) do - {resolved, 0} -> String.trim_trailing(resolved) - {_output, _status} -> path - end + if File.exists?(candidate) do + candidate + |> resolve_link() + |> resolve_existing_segments(remaining_parts) + else + Path.join([candidate | remaining_parts]) end end - defp deepest_existing_prefix(parts) do - Enum.reduce_while(length(parts)..1//-1, nil, fn count, _acc -> - existing_parts = Enum.take(parts, count) - - if File.exists?(Path.join(existing_parts)) do - {:halt, {existing_parts, Enum.drop(parts, count)}} - else - {:cont, nil} - end - end) + defp resolve_link(path) do + charlist_path = String.to_charlist(path) + + with {:ok, + {:file_info, _size, :symlink, _access, _atime, _mtime, _ctime, _mode, _links, + _major_device, _minor_device, _inode, _uid, _gid}} <- + :file.read_link_info(charlist_path), + {:ok, target} <- :file.read_link(charlist_path) do + target + |> List.to_string() + |> Path.expand(Path.dirname(path)) + else + _ -> path + end end end diff --git a/lib/ourocode/runtime/seed_artifact.ex b/lib/ourocode/runtime/seed_artifact.ex index 0b60f74..2173119 100644 --- a/lib/ourocode/runtime/seed_artifact.ex +++ b/lib/ourocode/runtime/seed_artifact.ex @@ -35,7 +35,11 @@ defmodule Ourocode.Runtime.SeedArtifact do def extract_yaml(text) when is_binary(text) do case String.split(text, @seed_marker, parts: 2) do [_before, yaml] -> - yaml = String.trim(yaml) + yaml = + yaml + |> String.replace("\r\n", "\n") + |> String.trim() + if yaml == "", do: :ignore, else: {:ok, yaml} _other -> @@ -53,7 +57,7 @@ defmodule Ourocode.Runtime.SeedArtifact do @spec write(String.t(), String.t(), String.t()) :: {:ok, String.t()} | {:error, term()} def write(cwd, seed_id, seed_yaml) when is_binary(cwd) and is_binary(seed_id) and is_binary(seed_yaml) do - path = Path.join(Path.expand(cwd), seed_id <> ".yaml") + path = Path.join(cwd, seed_id <> ".yaml") case File.write(path, seed_yaml <> "\n") do :ok -> {:ok, path} diff --git a/lib/ourocode/terminal/file_discovery.ex b/lib/ourocode/terminal/file_discovery.ex index 97a1b5a..7737189 100644 --- a/lib/ourocode/terminal/file_discovery.ex +++ b/lib/ourocode/terminal/file_discovery.ex @@ -29,6 +29,7 @@ defmodule Ourocode.Terminal.FileDiscovery do def parse_rg_files(out) when is_binary(out) do out |> String.split("\n", trim: true) + |> Enum.map(&String.trim_trailing(&1, "\r")) |> Enum.reject(&ignored_path?/1) |> Enum.take(@limit) end diff --git a/lib/ourocode/terminal/frame_sections.ex b/lib/ourocode/terminal/frame_sections.ex index b0c9341..008c42f 100644 --- a/lib/ourocode/terminal/frame_sections.ex +++ b/lib/ourocode/terminal/frame_sections.ex @@ -7,6 +7,7 @@ defmodule Ourocode.Terminal.FrameSections do def parse(frame) when is_binary(frame) do frame |> String.split("\n") + |> Enum.map(&String.trim_trailing(&1, "\r")) |> Enum.reduce({[], nil}, fn line, {sections, current} -> cond do String.starts_with?(line, "+-- ") -> diff --git a/lib/ourocode/terminal/interview_handoff.ex b/lib/ourocode/terminal/interview_handoff.ex index 26032b2..5bbdbe4 100644 --- a/lib/ourocode/terminal/interview_handoff.ex +++ b/lib/ourocode/terminal/interview_handoff.ex @@ -5,7 +5,7 @@ defmodule Ourocode.Terminal.InterviewHandoff do @spec prompt(String.t(), String.t()) :: String.t() def prompt(question, user_message) do - """ + prompt = """ You are the main ourocode session. An interview checkpoint is paused so the user can discuss it with you before answering. @@ -21,6 +21,8 @@ defmodule Ourocode.Terminal.InterviewHandoff do Do not include that line for clarifications, translations, explanations, or ordinary discussion. """ + + String.replace(prompt, "\r\n", "\n") end @spec extract_answer(term()) :: String.t() | nil diff --git a/lib/ourocode/terminal/interview_panel/text.ex b/lib/ourocode/terminal/interview_panel/text.ex index 8f3e758..c1f2d90 100644 --- a/lib/ourocode/terminal/interview_panel/text.ex +++ b/lib/ourocode/terminal/interview_panel/text.ex @@ -32,6 +32,7 @@ defmodule Ourocode.Terminal.InterviewPanel.Text do def md_text(text) do text |> to_string() + |> scrub_invalid_utf8() |> String.replace(~r/(\*\*|__)(.*?)\1/s, "\\2") |> String.replace(~r/(\*|_)(.*?)\1/s, "\\2") |> String.replace(~r/`([^`]+)`/, "\\1") @@ -55,6 +56,7 @@ defmodule Ourocode.Terminal.InterviewPanel.Text do def plain_line(text) do text |> to_string() + |> scrub_invalid_utf8() |> repair_hangul_syllable_spacing() |> String.replace(~r/\s+/, " ") |> String.trim() @@ -63,7 +65,24 @@ defmodule Ourocode.Terminal.InterviewPanel.Text do defp strip_unstable_glyphs(text) do text |> String.replace(~r/[\x{FFFD}\x{FE0E}\x{FE0F}\x{200D}]/u, "") - |> String.replace(~r/[\x{1F000}-\x{1FAFF}]/u, "") + |> strip_supplemental_symbols() + end + + defp scrub_invalid_utf8(text) do + String.replace_invalid(text, "") + end + + defp strip_supplemental_symbols(text) do + text + |> String.graphemes() + |> Enum.reject(&supplemental_symbol?/1) + |> Enum.join() + end + + defp supplemental_symbol?(grapheme) do + grapheme + |> String.to_charlist() + |> Enum.any?(&(&1 in 0x1F000..0x1FAFF)) end defp repair_hangul_syllable_spacing(text) do diff --git a/test/ourocode/cli_test.exs b/test/ourocode/cli_test.exs index 836dd4b..bcd8c74 100644 --- a/test/ourocode/cli_test.exs +++ b/test/ourocode/cli_test.exs @@ -73,6 +73,7 @@ defmodule Ourocode.CLITest do alias Ourocode.CLI.SmokeTest import ExUnit.CaptureIO + import Ourocode.Test.PathAssertions, only: [assert_same_path: 2] test "startup resolves the current working directory by default" do assert Ourocode.CLI.resolve_project_dir() == {:ok, File.cwd!()} @@ -181,11 +182,11 @@ defmodule Ourocode.CLITest do test "launch prints version without bootstrapping the terminal UI" do output = capture_io(fn -> - assert {:ok, %{mode: :version, version: "0.1.13"}} = + assert {:ok, %{mode: :version, version: "0.1.14"}} = Ourocode.CLI.launch(["--version"], Ourocode.CLITest.TerminalBootstrapSpy) end) - assert output == "ourocode 0.1.13\n" + assert output == "ourocode 0.1.14\n" refute_receive {:terminal_bootstrap, _context}, 50 end @@ -341,7 +342,7 @@ defmodule Ourocode.CLITest do ) assert_receive {:terminal_bootstrap, ^context} - assert context.project_dir == project_dir + assert_same_path(context.project_dir, project_dir) assert context.plugin_config.plugins |> Enum.map(& &1.id) == ["ouroboros-plugin"] after Application.delete_env(:ourocode, :cli_test_pid) diff --git a/test/ourocode/command/registry/skill_loader_test.exs b/test/ourocode/command/registry/skill_loader_test.exs index a45a531..a7642db 100644 --- a/test/ourocode/command/registry/skill_loader_test.exs +++ b/test/ourocode/command/registry/skill_loader_test.exs @@ -15,13 +15,13 @@ defmodule Ourocode.Command.Registry.SkillLoaderTest do assert SkillLoader.discover_files(root) == [ %{ root: Path.expand(root), - dir: Path.join(root, "alpha"), - file: Path.join([root, "alpha", "SKILL.md"]) + dir: Path.expand(Path.join(root, "alpha")), + file: Path.expand(Path.join([root, "alpha", "SKILL.md"])) }, %{ root: Path.expand(root), - dir: Path.join(root, "beta"), - file: Path.join([root, "beta", "SKILL.md"]) + dir: Path.expand(Path.join(root, "beta")), + file: Path.expand(Path.join([root, "beta", "SKILL.md"])) } ] after @@ -29,22 +29,29 @@ defmodule Ourocode.Command.Registry.SkillLoaderTest do end test "parses simple scalar frontmatter and ignores comments or malformed lines" do - assert SkillLoader.parse_frontmatter(""" - --- - name: "ship-it" - description: Prepare: release checklist - # ignored: true - - malformed - mcp_tool: release_planner - --- - - # Body - """) == %{ - "name" => "\"ship-it\"", - "description" => "Prepare: release checklist", - "mcp_tool" => "release_planner" - } + lf_frontmatter = """ + --- + name: "ship-it" + description: Prepare: release checklist + # ignored: true + + malformed + mcp_tool: release_planner + --- + + # Body + """ + + expected = %{ + "name" => "\"ship-it\"", + "description" => "Prepare: release checklist", + "mcp_tool" => "release_planner" + } + + assert SkillLoader.parse_frontmatter(lf_frontmatter) == expected + + crlf_frontmatter = String.replace(lf_frontmatter, "\n", "\r\n") + assert SkillLoader.parse_frontmatter(crlf_frontmatter) == expected assert SkillLoader.parse_frontmatter("# No frontmatter") == %{} assert SkillLoader.parse_frontmatter("---\nname: missing-close") == %{} diff --git a/test/ourocode/config/raw_loader_test.exs b/test/ourocode/config/raw_loader_test.exs index cb6ccd7..6bc9cd5 100644 --- a/test/ourocode/config/raw_loader_test.exs +++ b/test/ourocode/config/raw_loader_test.exs @@ -65,6 +65,30 @@ defmodule Ourocode.Config.RawLoaderTest do File.rm_rf!(Process.get(:raw_loader_tmp_dir)) end + test "preserves Windows path spelling while parsing CRLF config files with portable relative paths" do + dir = unique_tmp_dir("windows-paths") + File.mkdir_p!(Path.join(dir, ".ourocode")) + config_path = Path.join(dir, ".ourocode/config.json") + + File.write!( + config_path, + "{\r\n \"runtime\": {\r\n \"repeat-count\": 3\r\n }\r\n}\r\n" + ) + + assert {:ok, raw} = RawLoader.load(dir) + assert raw.root_dir == dir + + assert [ + %{ + path: ^config_path, + relative_path: ".ourocode/config.json", + data: %{"runtime" => %{"repeat_count" => 3}} + } + ] = raw.files + after + File.rm_rf!(Process.get(:raw_loader_tmp_dir)) + end + test "projects only supported runtime override keys from raw config" do raw = %{ "runtime" => %{ diff --git a/test/ourocode/mcp/transport/sse_test.exs b/test/ourocode/mcp/transport/sse_test.exs index 0661c7e..d36af73 100644 --- a/test/ourocode/mcp/transport/sse_test.exs +++ b/test/ourocode/mcp/transport/sse_test.exs @@ -796,7 +796,7 @@ defmodule Ourocode.MCP.Transport.SSETest do event_seq: event_seq, raw_event: %{"id" => sse_id} } = event}, - 500 + 5_000 assert token == "chunked-sse-token-#{seq}" assert payload == %{"childID" => child_id, "seq" => seq, "token" => token} @@ -821,7 +821,7 @@ defmodule Ourocode.MCP.Transport.SSETest do event_seq: result_event_seq, raw_event: %{"id" => "chunked-frame-result"} } = result_event}, - 500 + 5_000 assert result_seq == event_count + 1 assert result_event_seq == event_count + 2 diff --git a/test/ourocode/mcp/transport/stdio_cleanup_test.exs b/test/ourocode/mcp/transport/stdio_cleanup_test.exs index 4307016..c5c3324 100644 --- a/test/ourocode/mcp/transport/stdio_cleanup_test.exs +++ b/test/ourocode/mcp/transport/stdio_cleanup_test.exs @@ -8,7 +8,7 @@ defmodule Ourocode.MCP.Transport.StdioCleanupTest do original_cleanup_policy = Application.get_env(:ourocode, :cleanup_policy) Application.delete_env(:ourocode, :cleanup_policy) - Application.put_env(:ourocode, :stale_cleanup_timeout_ms, 100) + Application.put_env(:ourocode, :stale_cleanup_timeout_ms, 5_000) on_exit(fn -> restore_env(:stale_cleanup_timeout_ms, original_stale_cleanup_timeout_ms) @@ -17,9 +17,6 @@ defmodule Ourocode.MCP.Transport.StdioCleanupTest do end test "default-config stdio cleanup closes opened port within configured timeout" do - command = System.find_executable("sh") - assert is_binary(command) - script = """ while IFS= read -r line; do printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/progress","params":{"childID":"child-cleanup-1","seq":1,"token":"cleanup"}}' @@ -27,10 +24,12 @@ defmodule Ourocode.MCP.Transport.StdioCleanupTest do done """ + {command, args} = Ourocode.Test.PortPrograms.shell_script(script) + {:ok, transport} = Stdio.start_link( command: command, - args: ["-c", script], + args: args, parent_call_id: "parent-cleanup-1", runtime_source: "synthetic", external_ids: %{"session_id" => "session-cleanup-1"}, @@ -44,10 +43,10 @@ defmodule Ourocode.MCP.Transport.StdioCleanupTest do assert {:ok, %{"ok" => true, "childID" => "child-cleanup-1", "seq" => 2}} = Stdio.call_parent(transport, "tools/call", %{"name" => "synthetic.cleanup"}, - timeout: 1_000 + timeout: 5_000 ) - assert %{port: port, port_open?: true, cleanup_timeout_ms: 100} = Stdio.snapshot(transport) + assert %{port: port, port_open?: true, cleanup_timeout_ms: 5_000} = Stdio.snapshot(transport) assert is_port(port) assert port_open?(port) @@ -56,12 +55,12 @@ defmodule Ourocode.MCP.Transport.StdioCleanupTest do type: :transport_cleanup, parent_call_id: "parent-cleanup-1", cleanup_reason: :idle_timeout, - stale_cleanup_timeout_ms: 100, + stale_cleanup_timeout_ms: 5_000, released_resources: %{ports: 1} }}, - 500 + 6_000 - assert_receive {:DOWN, ^transport_ref, :process, ^transport, :normal}, 500 + assert_receive {:DOWN, ^transport_ref, :process, ^transport, :normal}, 6_000 refute port_open?(port) end diff --git a/test/ourocode/mcp/transport/stdio_test.exs b/test/ourocode/mcp/transport/stdio_test.exs index 8704217..1139d59 100644 --- a/test/ourocode/mcp/transport/stdio_test.exs +++ b/test/ourocode/mcp/transport/stdio_test.exs @@ -6,10 +6,9 @@ defmodule Ourocode.MCP.Transport.StdioTest do alias Ourocode.MCP.LifecycleEvent alias Ourocode.MCP.Transport.Stdio - test "executes a parent call and emits start/result events" do - command = System.find_executable("sh") - assert is_binary(command) + @parent_call_timeout_ms 5_000 + test "executes a parent call and emits start/result events" do script = """ while IFS= read -r line; do printf '%s\n' 'helper boot log' @@ -19,10 +18,12 @@ defmodule Ourocode.MCP.Transport.StdioTest do done """ + {command, args} = Ourocode.Test.PortPrograms.shell_script(script) + {:ok, transport} = Stdio.start_link( command: command, - args: ["-c", script], + args: args, parent_call_id: "parent-1", runtime_source: "synthetic", external_ids: %{"session_id" => "session-1"}, @@ -32,7 +33,9 @@ defmodule Ourocode.MCP.Transport.StdioTest do assert_receive {:ourocode_event, %{type: :transport_started, event_seq: 1}} assert {:ok, %{"ok" => true, "childID" => "child-1", "seq" => 1}} = - Stdio.call_parent(transport, "tools/call", %{"name" => "synthetic"}, timeout: 1_000) + Stdio.call_parent(transport, "tools/call", %{"name" => "synthetic"}, + timeout: @parent_call_timeout_ms + ) assert_receive {:ourocode_event, %{ @@ -99,9 +102,6 @@ defmodule Ourocode.MCP.Transport.StdioTest do end test "normalizes malformed stdio stdout JSON into transport decode failure events" do - command = System.find_executable("sh") - assert is_binary(command) - script = """ while IFS= read -r line; do printf '%s\n' 'helper startup log' @@ -110,10 +110,12 @@ defmodule Ourocode.MCP.Transport.StdioTest do done """ + {command, args} = Ourocode.Test.PortPrograms.shell_script(script) + {:ok, transport} = Stdio.start_link( command: command, - args: ["-c", script], + args: args, parent_call_id: "parent-malformed-stdio-1", runtime_source: "synthetic", external_ids: %{"session_id" => "session-malformed-stdio-1"}, @@ -125,7 +127,7 @@ defmodule Ourocode.MCP.Transport.StdioTest do assert {:ok, %{"ok" => true}} = Stdio.call_parent(transport, "tools/call", %{"name" => "synthetic.malformed"}, - timeout: 1_000 + timeout: @parent_call_timeout_ms ) assert_receive {:ourocode_event, @@ -162,9 +164,6 @@ defmodule Ourocode.MCP.Transport.StdioTest do end test "attaches stdio raw event debugging metadata to outbound and inbound records" do - command = System.find_executable("sh") - assert is_binary(command) - script = """ while IFS= read -r line; do printf '%s\n' '{malformed raw debug line' @@ -173,10 +172,12 @@ defmodule Ourocode.MCP.Transport.StdioTest do done """ + {command, args} = Ourocode.Test.PortPrograms.shell_script(script) + {:ok, transport} = Stdio.start_link( command: command, - args: ["-c", script], + args: args, parent_call_id: "parent-raw-debug-stdio-1", runtime_source: "synthetic", external_ids: %{"session_id" => "session-raw-debug-stdio-1"}, @@ -188,7 +189,7 @@ defmodule Ourocode.MCP.Transport.StdioTest do assert {:ok, %{"ok" => true, "childID" => "child-raw-debug-stdio-1", "seq" => 2}} = Stdio.call_parent(transport, "tools/call", %{"name" => "synthetic.raw_debug"}, - timeout: 1_000 + timeout: @parent_call_timeout_ms ) assert_receive {:ourocode_event, @@ -269,9 +270,6 @@ defmodule Ourocode.MCP.Transport.StdioTest do end test "continues processing valid stdout events surrounding malformed lines" do - command = System.find_executable("sh") - assert is_binary(command) - child_id = "child-malformed-recovery-stdio-1" journal_path = @@ -293,10 +291,12 @@ defmodule Ourocode.MCP.Transport.StdioTest do done """ + {command, args} = Ourocode.Test.PortPrograms.shell_script(script) + {:ok, transport} = Stdio.start_link( command: command, - args: ["-c", script], + args: args, parent_call_id: "parent-malformed-recovery-stdio-1", runtime_source: "synthetic", external_ids: %{"session_id" => "session-malformed-recovery-stdio-1"}, @@ -311,7 +311,7 @@ defmodule Ourocode.MCP.Transport.StdioTest do transport, "tools/call", %{"name" => "synthetic.malformed_recovery"}, - timeout: 1_000 + timeout: @parent_call_timeout_ms ) received_events = @@ -350,9 +350,6 @@ defmodule Ourocode.MCP.Transport.StdioTest do end test "normalizes stdio JSON-RPC server requests as lifecycle events instead of responses" do - command = System.find_executable("sh") - assert is_binary(command) - script = """ while IFS= read -r line; do printf '%s\n' '{"jsonrpc":"2.0","id":"server-request-1","method":"sampling/createMessage","params":{"childID":"child-server-request-1","seq":1,"token":"question"}}' @@ -360,10 +357,12 @@ defmodule Ourocode.MCP.Transport.StdioTest do done """ + {command, args} = Ourocode.Test.PortPrograms.shell_script(script) + {:ok, transport} = Stdio.start_link( command: command, - args: ["-c", script], + args: args, parent_call_id: "parent-server-request-stdio-1", runtime_source: "synthetic", external_ids: %{"session_id" => "session-server-request-stdio-1"}, @@ -375,7 +374,7 @@ defmodule Ourocode.MCP.Transport.StdioTest do assert {:ok, %{"ok" => true, "childID" => "child-server-request-1", "seq" => 2}} = Stdio.call_parent(transport, "tools/call", %{"name" => "synthetic.server_request"}, - timeout: 1_000 + timeout: @parent_call_timeout_ms ) assert_receive {:ourocode_event, @@ -423,9 +422,6 @@ defmodule Ourocode.MCP.Transport.StdioTest do end test "ingests synthetic stdio MCP events seq=1..N without dropping normalized sequences" do - command = System.find_executable("sh") - assert is_binary(command) - event_count = 12 child_id = "child-seq-stdio-1" result_seq = event_count + 1 @@ -452,10 +448,12 @@ defmodule Ourocode.MCP.Transport.StdioTest do done """ + {command, args} = Ourocode.Test.PortPrograms.shell_script(script) + {:ok, transport} = Stdio.start_link( command: command, - args: ["-c", script], + args: args, parent_call_id: "parent-seq-stdio-1", runtime_source: "synthetic", external_ids: %{"session_id" => "session-seq-stdio-1"}, @@ -471,7 +469,7 @@ defmodule Ourocode.MCP.Transport.StdioTest do assert {:ok, %{"ok" => true, "childID" => ^child_id, "seq" => ^result_seq}} = Stdio.call_parent(transport, "tools/call", %{"name" => "synthetic.seq"}, - timeout: 1_000 + timeout: @parent_call_timeout_ms ) assert_receive {:ourocode_event, @@ -585,9 +583,6 @@ defmodule Ourocode.MCP.Transport.StdioTest do end test "preserves valid stdout MCP event order while normalizing malformed stdout lines" do - command = System.find_executable("sh") - assert is_binary(command) - child_id = "child-valid-stdout-order-stdio-1" script = """ @@ -604,10 +599,12 @@ defmodule Ourocode.MCP.Transport.StdioTest do done """ + {command, args} = Ourocode.Test.PortPrograms.shell_script(script) + {:ok, transport} = Stdio.start_link( command: command, - args: ["-c", script], + args: args, parent_call_id: "parent-valid-stdout-order-stdio-1", runtime_source: "synthetic", external_ids: %{"session_id" => "session-valid-stdout-order-stdio-1"}, @@ -623,7 +620,7 @@ defmodule Ourocode.MCP.Transport.StdioTest do assert {:ok, %{"ok" => true, "childID" => ^child_id, "seq" => 4}} = Stdio.call_parent(transport, "tools/call", %{"name" => "synthetic.valid_order"}, - timeout: 1_000 + timeout: @parent_call_timeout_ms ) assert_receive {:ourocode_event, @@ -691,9 +688,6 @@ defmodule Ourocode.MCP.Transport.StdioTest do end test "persists normalized stdio MCP server events seq=1..N and reads them back without gaps" do - command = System.find_executable("sh") - assert is_binary(command) - event_count = 8 child_id = "child-journal-stdio-1" @@ -727,10 +721,12 @@ defmodule Ourocode.MCP.Transport.StdioTest do done """ + {command, args} = Ourocode.Test.PortPrograms.shell_script(script) + {:ok, transport} = Stdio.start_link( command: command, - args: ["-c", script], + args: args, parent_call_id: "parent-journal-stdio-1", runtime_source: "synthetic", external_ids: %{"session_id" => "session-journal-stdio-1"}, @@ -742,7 +738,7 @@ defmodule Ourocode.MCP.Transport.StdioTest do assert {:ok, %{"ok" => true, "childID" => ^child_id, "seq" => ^result_seq}} = Stdio.call_parent(transport, "tools/call", %{"name" => "synthetic.journal"}, - timeout: 1_000 + timeout: @parent_call_timeout_ms ) received_events = @@ -784,9 +780,6 @@ defmodule Ourocode.MCP.Transport.StdioTest do end test "journal replay reconstructs normalized stdio events with raw metadata" do - command = System.find_executable("sh") - assert is_binary(command) - child_id = "child-canonical-journal-stdio-1" journal_path = @@ -805,10 +798,12 @@ defmodule Ourocode.MCP.Transport.StdioTest do done """ + {command, args} = Ourocode.Test.PortPrograms.shell_script(script) + {:ok, transport} = Stdio.start_link( command: command, - args: ["-c", script], + args: args, parent_call_id: "parent-canonical-journal-stdio-1", runtime_source: "synthetic", external_ids: %{"session_id" => "session-canonical-journal-stdio-1"}, @@ -823,7 +818,7 @@ defmodule Ourocode.MCP.Transport.StdioTest do transport, "tools/call", %{"name" => "synthetic.canonical_journal"}, - timeout: 1_000 + timeout: @parent_call_timeout_ms ) live_events = @@ -902,9 +897,6 @@ defmodule Ourocode.MCP.Transport.StdioTest do end test "renders stdio parent MCP call as the root UI pane with identity and status metadata" do - command = System.find_executable("sh") - assert is_binary(command) - script = """ while IFS= read -r line; do printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/progress","params":{"childID":"child-root-1","seq":1,"token":"first"}}' @@ -912,10 +904,12 @@ defmodule Ourocode.MCP.Transport.StdioTest do done """ + {command, args} = Ourocode.Test.PortPrograms.shell_script(script) + {:ok, transport} = Stdio.start_link( command: command, - args: ["-c", script], + args: args, parent_call_id: "parent-root-stdio-1", runtime_source: "synthetic", external_ids: %{ @@ -929,7 +923,7 @@ defmodule Ourocode.MCP.Transport.StdioTest do assert {:ok, %{"ok" => true, "childID" => "child-root-1", "seq" => 1}} = Stdio.call_parent(transport, "tools/call", %{"name" => "synthetic.root"}, - timeout: 1_000 + timeout: @parent_call_timeout_ms ) assert_receive {:ourocode_event, @@ -1031,19 +1025,18 @@ defmodule Ourocode.MCP.Transport.StdioTest do end test "preserves OpenCode parent call input sessionID and callID in lifecycle identity" do - command = System.find_executable("sh") - assert is_binary(command) - script = """ while IFS= read -r line; do printf '%s\n' '{"jsonrpc":"2.0","id":"1","result":{"ok":true}}' done """ + {command, args} = Ourocode.Test.PortPrograms.shell_script(script) + {:ok, transport} = Stdio.start_link( command: command, - args: ["-c", script], + args: args, parent_call_id: "parent-opencode-input-1", runtime_source: "opencode", external_ids: %{}, @@ -1061,7 +1054,9 @@ defmodule Ourocode.MCP.Transport.StdioTest do } assert {:ok, %{"ok" => true}} = - Stdio.call_parent(transport, "agent/session/create", params, timeout: 1_000) + Stdio.call_parent(transport, "agent/session/create", params, + timeout: @parent_call_timeout_ms + ) assert_receive {:ourocode_event, %{ @@ -1097,9 +1092,6 @@ defmodule Ourocode.MCP.Transport.StdioTest do end test "appends stdio streaming token entries under the correct child panes in order" do - command = System.find_executable("sh") - assert is_binary(command) - script = """ while IFS= read -r line; do printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/progress","params":{"childID":"child-stream-a","seq":1,"token":"a-1"}}' @@ -1111,10 +1103,12 @@ defmodule Ourocode.MCP.Transport.StdioTest do done """ + {command, args} = Ourocode.Test.PortPrograms.shell_script(script) + {:ok, transport} = Stdio.start_link( command: command, - args: ["-c", script], + args: args, parent_call_id: "parent-stream-stdio-1", runtime_source: "synthetic", external_ids: %{"session_id" => "session-stream-stdio-1"}, @@ -1126,7 +1120,7 @@ defmodule Ourocode.MCP.Transport.StdioTest do assert {:ok, %{"ok" => true}} = Stdio.call_parent(transport, "tools/call", %{"name" => "synthetic.stream"}, - timeout: 1_000 + timeout: @parent_call_timeout_ms ) assert_receive {:ourocode_event, @@ -1218,9 +1212,6 @@ defmodule Ourocode.MCP.Transport.StdioTest do end test "delivers the first stdio token to the child pane within five seconds of childID creation" do - command = System.find_executable("sh") - assert is_binary(command) - script = """ while IFS= read -r line; do printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/progress","params":{"childID":"child-stdio-timing-1","phase":"created"}}' @@ -1230,10 +1221,12 @@ defmodule Ourocode.MCP.Transport.StdioTest do done """ + {command, args} = Ourocode.Test.PortPrograms.shell_script(script) + {:ok, transport} = Stdio.start_link( command: command, - args: ["-c", script], + args: args, parent_call_id: "parent-stdio-timing-1", runtime_source: "synthetic", external_ids: %{"session_id" => "session-stdio-timing-1"}, @@ -1268,7 +1261,8 @@ defmodule Ourocode.MCP.Transport.StdioTest do "phase" => "created" } } - } = child_created_event} + } = child_created_event}, + 1_000 child_created_received_at = System.monotonic_time(:millisecond) diff --git a/test/ourocode/plugin/action_mapping_loader_test.exs b/test/ourocode/plugin/action_mapping_loader_test.exs index d24f7b9..0f9e3b5 100644 --- a/test/ourocode/plugin/action_mapping_loader_test.exs +++ b/test/ourocode/plugin/action_mapping_loader_test.exs @@ -127,7 +127,7 @@ defmodule Ourocode.Plugin.ActionMappingLoaderTest do %LoadError{ reason: :untrusted_action_mapping_plugin, plugin_path: expanded_path, - manifest_path: ^manifest_path + manifest_path: actual_manifest_path }} = ActionMappingLoader.load_default(plugin_path, allowed_roots: [plugin_path], @@ -135,7 +135,8 @@ defmodule Ourocode.Plugin.ActionMappingLoaderTest do trusted_approvals: [approval] ) - assert expanded_path == Path.expand(plugin_path) + assert_same_path(expanded_path, Path.expand(plugin_path)) + assert_same_path(actual_manifest_path, manifest_path) end defp tmp_plugin_dir!(name) do @@ -170,4 +171,19 @@ defmodule Ourocode.Plugin.ActionMappingLoaderTest do "value" => signature }) end + + defp assert_same_path(left, right) do + if match?({:win32, _}, :os.type()) do + assert path_key(left) == path_key(right) + else + assert left == right + end + end + + defp path_key(path) do + path + |> Path.expand() + |> String.replace("\\", "/") + |> String.downcase() + end end diff --git a/test/ourocode/plugin/adapter_mapping_loader_test.exs b/test/ourocode/plugin/adapter_mapping_loader_test.exs index 68e9de3..f625945 100644 --- a/test/ourocode/plugin/adapter_mapping_loader_test.exs +++ b/test/ourocode/plugin/adapter_mapping_loader_test.exs @@ -1,6 +1,8 @@ defmodule Ourocode.Plugin.AdapterMappingLoaderTest do use ExUnit.Case, async: true + import Ourocode.Test.PathAssertions, only: [assert_same_path: 2] + alias Ourocode.Plugin.AdapterMappingLoader alias Ourocode.Plugin.LoadError alias Ourocode.Plugin.Loader @@ -121,7 +123,7 @@ defmodule Ourocode.Plugin.AdapterMappingLoaderTest do %LoadError{ reason: :untrusted_adapter_mapping_plugin, plugin_path: expanded_path, - manifest_path: ^manifest_path + manifest_path: actual_manifest_path }} = AdapterMappingLoader.load_default(plugin_path, allowed_roots: [plugin_path], @@ -129,7 +131,8 @@ defmodule Ourocode.Plugin.AdapterMappingLoaderTest do trusted_approvals: [approval] ) - assert expanded_path == Path.expand(plugin_path) + assert_same_path(expanded_path, Path.expand(plugin_path)) + assert_same_path(actual_manifest_path, manifest_path) end defp tmp_plugin_dir!(name) do diff --git a/test/ourocode/plugin/config_watcher/sources_test.exs b/test/ourocode/plugin/config_watcher/sources_test.exs index 1ad90f6..0a34d0a 100644 --- a/test/ourocode/plugin/config_watcher/sources_test.exs +++ b/test/ourocode/plugin/config_watcher/sources_test.exs @@ -2,15 +2,19 @@ defmodule Ourocode.Plugin.ConfigWatcher.SourcesTest do use ExUnit.Case, async: true alias Ourocode.Plugin.ConfigWatcher.Sources + import Ourocode.Test.PathAssertions, only: [path_key: 1] test "config_paths prefers explicit source paths and normalizes order" do project_dir = tmp_dir("sources-paths") - assert Sources.config_paths(project_dir, - source_paths: ["b.json", Path.join(project_dir, "a.json"), "b.json"] - ) == [ - Path.join(project_dir, "a.json"), - Path.join(project_dir, "b.json") + actual = + Sources.config_paths(project_dir, + source_paths: ["b.json", Path.join(project_dir, "a.json"), "b.json"] + ) + + assert Enum.map(actual, &path_key/1) == [ + path_key(Path.join(project_dir, "a.json")), + path_key(Path.join(project_dir, "b.json")) ] after cleanup_tmp_dir() @@ -19,28 +23,32 @@ defmodule Ourocode.Plugin.ConfigWatcher.SourcesTest do test "plugin_setting_paths accepts maps, string-keyed maps, and tuple lists" do project_dir = tmp_dir("settings-paths") - assert Sources.plugin_setting_paths(project_dir, - plugin_setting_paths: [ - %{plugin_id: "beta", path: "settings/beta.json"}, - %{"plugin_id" => "alpha", "path" => "settings/alpha.json"}, - {"alpha", ["settings/alpha.json", "settings/alpha-extra.json"]}, - {:bad, "ignored"} - ] - ) == [ + actual = + Sources.plugin_setting_paths(project_dir, + plugin_setting_paths: [ + %{plugin_id: "beta", path: "settings/beta.json"}, + %{"plugin_id" => "alpha", "path" => "settings/alpha.json"}, + {"alpha", ["settings/alpha.json", "settings/alpha-extra.json"]}, + {:bad, "ignored"} + ] + ) + |> normalize_source_paths() + + assert actual == [ %{ kind: :plugin_settings, plugin_id: "alpha", - path: Path.join(project_dir, "settings/alpha-extra.json") + path: path_key(Path.join(project_dir, "settings/alpha-extra.json")) }, %{ kind: :plugin_settings, plugin_id: "alpha", - path: Path.join(project_dir, "settings/alpha.json") + path: path_key(Path.join(project_dir, "settings/alpha.json")) }, %{ kind: :plugin_settings, plugin_id: "beta", - path: Path.join(project_dir, "settings/beta.json") + path: path_key(Path.join(project_dir, "settings/beta.json")) } ] after @@ -67,16 +75,20 @@ defmodule Ourocode.Plugin.ConfigWatcher.SourcesTest do assert is_binary(existing.checksum) assert existing.size > 0 - assert missing == %{ + assert Map.update!(missing, :path, &path_key/1) == %{ kind: :plugin_settings, plugin_id: "demo", - path: Path.join(project_dir, "missing.json"), + path: path_key(Path.join(project_dir, "missing.json")), exists?: false } after cleanup_tmp_dir() end + defp normalize_source_paths(sources) do + Enum.map(sources, &Map.update!(&1, :path, fn path -> path_key(path) end)) + end + defp tmp_dir(name) do dir = Path.join( diff --git a/test/ourocode/plugin/config_watcher_test.exs b/test/ourocode/plugin/config_watcher_test.exs index 7bf64a2..e369916 100644 --- a/test/ourocode/plugin/config_watcher_test.exs +++ b/test/ourocode/plugin/config_watcher_test.exs @@ -3,6 +3,7 @@ defmodule Ourocode.Plugin.ConfigWatcherTest do alias Ourocode.Journal alias Ourocode.Plugin.ConfigWatcher + import Ourocode.Test.PathAssertions, only: [path_key: 1] test "detects plugin config create modify and delete and emits reload requests without UI restart" do project_dir = tmp_dir!("plugin-config-watcher") @@ -58,10 +59,12 @@ defmodule Ourocode.Plugin.ConfigWatcherTest do project_dir = tmp_dir!("plugin-config-candidates") paths = ConfigWatcher.source_paths(project_dir) + path_keys = Enum.map(paths, &path_key/1) + project_dir_key = path_key(project_dir) - assert Path.join(project_dir, ".ourocode/config.json") in paths - assert Path.join(project_dir, "ourocode.json") in paths - assert Enum.all?(paths, &String.starts_with?(&1, project_dir)) + assert path_key(Path.join(project_dir, ".ourocode/config.json")) in path_keys + assert path_key(Path.join(project_dir, "ourocode.json")) in path_keys + assert Enum.all?(path_keys, &String.starts_with?(&1, project_dir_key)) end test "manual polling ignores unrelated file create modify and delete changes without reload events" do diff --git a/test/ourocode/plugin/hot_reload_boundary_test.exs b/test/ourocode/plugin/hot_reload_boundary_test.exs index 55afa0c..bc06353 100644 --- a/test/ourocode/plugin/hot_reload_boundary_test.exs +++ b/test/ourocode/plugin/hot_reload_boundary_test.exs @@ -6,6 +6,8 @@ defmodule Ourocode.Plugin.HotReloadBoundaryTest do alias Ourocode.Plugin.MappingSignatureVerifier alias Ourocode.Plugin.ConfigSchema + import Ourocode.Test.PathAssertions, only: [assert_same_path: 2] + test "compiles changed plugin code and swaps the active registry without losing prior state" do key_id = "hot-reload-key" secret = "hot-reload-secret" @@ -213,18 +215,17 @@ defmodule Ourocode.Plugin.HotReloadBoundaryTest do assert failed_state.plugin.load_error == failure - assert failure == %{ - plugin_id: "ouroboros-plugin", - state: :load_failed, - reason: :missing_capability_manifest, - message: - "plugin #{inspect(Path.expand(plugin_path))} is missing required capability manifest at #{inspect(missing_manifest_path)}", - plugin_path: Path.expand(plugin_path), - manifest_path: missing_manifest_path, - source: "official", - trust_policy: %{"requires_explicit_approval" => false, "tier" => "official"}, - attempted_at_ms: 600 - } + assert_load_failure(failure, %{ + plugin_id: "ouroboros-plugin", + state: :load_failed, + reason: :missing_capability_manifest, + message_body: "is missing required capability manifest at", + plugin_path: plugin_path, + manifest_path: missing_manifest_path, + source: "official", + trust_policy: %{"requires_explicit_approval" => false, "tier" => "official"}, + attempted_at_ms: 600 + }) assert failed_state.plugin_config.failed_plugins == ["ouroboros-plugin"] assert failed_state.plugin_config.plugins_by_id["ouroboros-plugin"].load_error == failure @@ -377,18 +378,17 @@ defmodule Ourocode.Plugin.HotReloadBoundaryTest do assert failed_state.plugin_config.failed_plugins == ["ouroboros-plugin"] assert failed_state.plugin_load_failures == [failure] - assert failure == %{ - plugin_id: "ouroboros-plugin", - state: :load_failed, - reason: :invalid_capability_manifest_schema, - message: - "plugin #{inspect(Path.expand(plugin_path))} has an invalid capability manifest schema at #{inspect(Path.join(plugin_path, "capabilities.json"))}", - plugin_path: Path.expand(plugin_path), - manifest_path: Path.join(plugin_path, "capabilities.json"), - source: "official", - trust_policy: %{"requires_explicit_approval" => false, "tier" => "official"}, - attempted_at_ms: 1_100 - } + assert_load_failure(failure, %{ + plugin_id: "ouroboros-plugin", + state: :load_failed, + reason: :invalid_capability_manifest_schema, + message_body: "has an invalid capability manifest schema at", + plugin_path: plugin_path, + manifest_path: Path.join(plugin_path, "capabilities.json"), + source: "official", + trust_policy: %{"requires_explicit_approval" => false, "tier" => "official"}, + attempted_at_ms: 1_100 + }) assert failed_state.plugin_transitions == [ %{ @@ -441,18 +441,17 @@ defmodule Ourocode.Plugin.HotReloadBoundaryTest do assert reloaded_state.plugin_config.plugins_by_id["vim-mode"].state == :load_failed - assert failure == %{ - plugin_id: "vim-mode", - state: :load_failed, - reason: :missing_capability_manifest, - message: - "plugin #{inspect(Path.expand(failed_plugin_path))} is missing required capability manifest at #{inspect(failed_manifest_path)}", - plugin_path: Path.expand(failed_plugin_path), - manifest_path: failed_manifest_path, - source: "third_party", - trust_policy: %{"requires_explicit_approval" => true, "tier" => "community_code"}, - attempted_at_ms: 700 - } + assert_load_failure(failure, %{ + plugin_id: "vim-mode", + state: :load_failed, + reason: :missing_capability_manifest, + message_body: "is missing required capability manifest at", + plugin_path: failed_plugin_path, + manifest_path: failed_manifest_path, + source: "third_party", + trust_policy: %{"requires_explicit_approval" => true, "tier" => "community_code"}, + attempted_at_ms: 700 + }) assert reloaded_state.plugin_load_failures == [failure] @@ -582,6 +581,18 @@ defmodule Ourocode.Plugin.HotReloadBoundaryTest do } end + defp assert_load_failure(failure, expected) do + expected_message = + "plugin #{inspect(failure.plugin_path)} #{expected.message_body} #{inspect(failure.manifest_path)}" + + assert Map.drop(failure, [:message, :plugin_path, :manifest_path]) == + Map.drop(expected, [:message_body, :plugin_path, :manifest_path]) + + assert failure.message == expected_message + assert_same_path(failure.plugin_path, expected.plugin_path) + assert_same_path(failure.manifest_path, expected.manifest_path) + end + defp official_plugin_identity do %{ plugin_id: "ouroboros-plugin", diff --git a/test/ourocode/plugin/loader_test.exs b/test/ourocode/plugin/loader_test.exs index cdd5f6d..aa5b2b6 100644 --- a/test/ourocode/plugin/loader_test.exs +++ b/test/ourocode/plugin/loader_test.exs @@ -511,7 +511,7 @@ defmodule Ourocode.Plugin.LoaderTest do File.rm_rf!(path) end) - path + Path.expand(path) end defp tmp_config_file!(name, contents) do diff --git a/test/ourocode/plugin/path_policy_test.exs b/test/ourocode/plugin/path_policy_test.exs index 1182b6b..3e60f6e 100644 --- a/test/ourocode/plugin/path_policy_test.exs +++ b/test/ourocode/plugin/path_policy_test.exs @@ -76,7 +76,7 @@ defmodule Ourocode.Plugin.PathPolicyTest do ) end - test "rejects symlink escape through an allowed root" do + test "rejects link escape through an allowed root" do base_dir = tmp_dir!("symlink-base") allowed_root = Path.join(base_dir, "plugins") outside_root = tmp_dir!("symlink-outside") @@ -84,15 +84,19 @@ defmodule Ourocode.Plugin.PathPolicyTest do link_path = Path.join(allowed_root, "linked-outside") - case File.ln_s(outside_root, link_path) do + case create_directory_link(outside_root, link_path) do :ok -> - assert {:error, :plugin_path_not_allowed} = - PathPolicy.validate(Path.join(link_path, "community-plugin"), - allowed_roots: [allowed_root] - ) + try do + assert {:error, :plugin_path_not_allowed} = + PathPolicy.validate(Path.join(link_path, "community-plugin"), + allowed_roots: [allowed_root] + ) + after + remove_directory_link(link_path) + end {:error, reason} -> - flunk("failed to create symlink for path policy test: #{inspect(reason)}") + flunk("failed to create link for path policy test: #{inspect(reason)}") end end @@ -114,4 +118,40 @@ defmodule Ourocode.Plugin.PathPolicyTest do defp restore_env(key, nil), do: Application.delete_env(:ourocode, key) defp restore_env(key, value), do: Application.put_env(:ourocode, key, value) + + defp create_directory_link(target_path, link_path) do + case File.ln_s(target_path, link_path) do + :ok -> :ok + {:error, :eperm} -> create_windows_junction(target_path, link_path) + {:error, reason} -> {:error, {:symlink, reason}} + end + end + + defp create_windows_junction(target_path, link_path) do + if windows?() do + case System.cmd( + "cmd.exe", + ["/d", "/c", "mklink", "/J", windows_path(link_path), windows_path(target_path)], + stderr_to_stdout: true + ) do + {_output, 0} -> :ok + {output, status} -> {:error, {:junction, status, String.trim(output)}} + end + else + {:error, :eperm} + end + end + + defp remove_directory_link(link_path) do + if windows?() do + System.cmd("cmd.exe", ["/d", "/c", "rmdir", windows_path(link_path)], + stderr_to_stdout: true + ) + else + File.rm(link_path) + end + end + + defp windows_path(path), do: path |> Path.expand() |> String.replace("/", "\\") + defp windows?, do: match?({:win32, _}, :os.type()) end diff --git a/test/ourocode/plugin/renderer_mapping_loader_test.exs b/test/ourocode/plugin/renderer_mapping_loader_test.exs index becb54f..911ccf0 100644 --- a/test/ourocode/plugin/renderer_mapping_loader_test.exs +++ b/test/ourocode/plugin/renderer_mapping_loader_test.exs @@ -5,6 +5,7 @@ defmodule Ourocode.Plugin.RendererMappingLoaderTest do alias Ourocode.Plugin.Loader alias Ourocode.Plugin.MappingSignatureVerifier alias Ourocode.Plugin.RendererMappingLoader + import Ourocode.Test.PathAssertions defmodule OfficialChildRenderer do def render(%{kind: :child_session, child_id: child_id, pane_state: pane_state}) do @@ -127,7 +128,7 @@ defmodule Ourocode.Plugin.RendererMappingLoaderTest do %LoadError{ reason: :untrusted_renderer_mapping_plugin, plugin_path: expanded_path, - manifest_path: ^manifest_path + manifest_path: expanded_manifest_path }} = RendererMappingLoader.load_default(plugin_path, allowed_roots: [plugin_path], @@ -135,7 +136,8 @@ defmodule Ourocode.Plugin.RendererMappingLoaderTest do trusted_approvals: [approval] ) - assert expanded_path == Path.expand(plugin_path) + assert_same_path(expanded_path, Path.expand(plugin_path)) + assert_same_path(expanded_manifest_path, manifest_path) end defp tmp_plugin_dir!(name) do diff --git a/test/ourocode/plugin/user_level/artifact_watcher_test.exs b/test/ourocode/plugin/user_level/artifact_watcher_test.exs index 9f1c3d3..0eec583 100644 --- a/test/ourocode/plugin/user_level/artifact_watcher_test.exs +++ b/test/ourocode/plugin/user_level/artifact_watcher_test.exs @@ -5,7 +5,12 @@ defmodule Ourocode.Plugin.UserLevel.ArtifactWatcherTest do alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability setup do - tmp = Path.join(System.tmp_dir!(), "ourocode_artifact_watcher_#{System.unique_integer([:positive])}") + tmp = + Path.join( + System.tmp_dir!(), + "ourocode_artifact_watcher_#{System.unique_integer([:positive])}" + ) + File.mkdir_p!(tmp) on_exit(fn -> File.rm_rf!(tmp) end) %{cwd: tmp} @@ -16,6 +21,20 @@ defmodule Ourocode.Plugin.UserLevel.ArtifactWatcherTest do File.write!(path, content) end + defp path_key(path) do + path + |> Path.expand() + |> String.replace("\\", "/") + |> maybe_downcase_windows_path() + end + + defp maybe_downcase_windows_path(path) do + case :os.type() do + {:win32, _name} -> String.downcase(path) + _other -> path + end + end + test "matches a seed.md under the declared glob", %{cwd: cwd} do {:ok, command} = CommandCapability.new(%{ @@ -30,7 +49,7 @@ defmodule Ourocode.Plugin.UserLevel.ArtifactWatcherTest do [artifact] = ArtifactWatcher.scan(command, cwd) assert artifact.kind == :seed - assert artifact.path == seed + assert path_key(artifact.path) == path_key(seed) assert artifact.glob == ".omx/superpowers/runs/*/seed.md" assert artifact.size > 0 assert "sha256:" <> _ = artifact.digest @@ -53,12 +72,12 @@ defmodule Ourocode.Plugin.UserLevel.ArtifactWatcherTest do Enum.each([handoff, report, log, other], &write!(&1, "data")) artifacts = ArtifactWatcher.scan(command, cwd) - by_path = Map.new(artifacts, &{&1.path, &1.kind}) + by_path = Map.new(artifacts, &{path_key(&1.path), &1.kind}) - assert by_path[handoff] == :handoff - assert by_path[report] == :report - assert by_path[log] == :log - assert by_path[other] == :other + assert by_path[path_key(handoff)] == :handoff + assert by_path[path_key(report)] == :report + assert by_path[path_key(log)] == :log + assert by_path[path_key(other)] == :other end test "deduplicates artifacts that match multiple globs", %{cwd: cwd} do diff --git a/test/ourocode/runtime/application_bootstrap_test.exs b/test/ourocode/runtime/application_bootstrap_test.exs index edc8a4a..141850c 100644 --- a/test/ourocode/runtime/application_bootstrap_test.exs +++ b/test/ourocode/runtime/application_bootstrap_test.exs @@ -2,6 +2,7 @@ defmodule Ourocode.Runtime.ApplicationBootstrapTest do use ExUnit.Case, async: true alias Ourocode.Runtime.ApplicationBootstrap + import Ourocode.Test.PathAssertions, only: [assert_same_path: 2] test "session_id preserves provided runtime session ids and generates terminal ids" do assert ApplicationBootstrap.session_id(%{runtime_session_id: "terminal-test"}) == @@ -20,8 +21,8 @@ defmodule Ourocode.Runtime.ApplicationBootstrapTest do project_dir ) - assert prepared.project_dir == Path.expand(project_dir) - assert prepared.journal_path == Path.expand(relative_journal) + assert_same_path(prepared.project_dir, Path.expand(project_dir)) + assert_same_path(prepared.journal_path, Path.expand(relative_journal)) assert File.dir?(Path.dirname(prepared.journal_path)) File.rm_rf!(project_dir) @@ -35,8 +36,10 @@ defmodule Ourocode.Runtime.ApplicationBootstrapTest do %{ status: :unhealthy, healthy?: false, - reason: {:missing_project_dir, ^missing_dir} + reason: {:missing_project_dir, actual_missing_dir} }} = ApplicationBootstrap.prepare(%{}, missing_dir) + + assert_same_path(actual_missing_dir, missing_dir) end test "default_journal_path uses the runtime session id" do diff --git a/test/ourocode/runtime/mcp_daemon_test.exs b/test/ourocode/runtime/mcp_daemon_test.exs index 8692432..62fa059 100644 --- a/test/ourocode/runtime/mcp_daemon_test.exs +++ b/test/ourocode/runtime/mcp_daemon_test.exs @@ -9,6 +9,9 @@ defmodule Ourocode.Runtime.McpDaemonTest do use ExUnit.Case, async: false alias Ourocode.Runtime.McpDaemon + alias Ourocode.Test.PortPrograms + + import Ourocode.Test.OsProcessAssertions setup do saved = {System.get_env("OUROCODE_MCP_AUTOSTART"), System.get_env("OUROCODE_MCP_URL")} @@ -127,27 +130,23 @@ defmodule Ourocode.Runtime.McpDaemonTest do end test "stop/1 reaps the per-instance OS process (no orphan on tab close)" do + {command, args} = PortPrograms.long_running_command() + erl_port = - Port.open({:spawn_executable, "/bin/sh"}, [ + Port.open({:spawn_executable, command}, [ :binary, :exit_status, :hide, - args: ["-c", "exec sleep 30"] + args: args ]) {:os_pid, os_pid} = Port.info(erl_port, :os_pid) - assert {_out, 0} = System.cmd("kill", ["-0", Integer.to_string(os_pid)]) + assert_os_process_alive(os_pid) assert :ok == McpDaemon.stop(%{mode: :spawned, port: erl_port, os_pid: os_pid, url: "u"}) - # The signalled server is gone — `kill -0` now fails (no such process). - Process.sleep(150) - - assert {_err, code} = - System.cmd("kill", ["-0", Integer.to_string(os_pid)], stderr_to_stdout: true) - - assert code != 0 + refute_os_process_alive(os_pid) end test "describe/1 renders every mode" do diff --git a/test/ourocode/runtime/stream/resources_test.exs b/test/ourocode/runtime/stream/resources_test.exs index 4006a92..98725e5 100644 --- a/test/ourocode/runtime/stream/resources_test.exs +++ b/test/ourocode/runtime/stream/resources_test.exs @@ -44,14 +44,14 @@ defmodule Ourocode.Runtime.Stream.ResourcesTest do end test "release_registered closes open ports" do - command = System.find_executable("sh") + {command, args} = stdin_draining_command() assert is_binary(command) port = Port.open({:spawn_executable, command}, [ :binary, :exit_status, - {:args, ["-c", "while IFS= read -r line; do :; done"]}, + {:args, args}, {:line, 65_536} ]) @@ -69,6 +69,33 @@ defmodule Ourocode.Runtime.Stream.ResourcesTest do assert {_released_state, %{process_handles: 1}} = Resources.release_registered(state) - refute Port.info(port) + assert_port_closed(port) + end + + defp assert_port_closed(port) do + deadline = System.monotonic_time(:millisecond) + 1_000 + + unless wait_until_port_closed(port, deadline) do + flunk("expected release_registered/1 to close the registered port") + end + end + + defp wait_until_port_closed(port, deadline) do + if port_closed?(port) do + true + else + if System.monotonic_time(:millisecond) >= deadline do + false + else + Process.sleep(10) + wait_until_port_closed(port, deadline) + end + end + end + + defp port_closed?(port), do: Port.info(port) == nil + + defp stdin_draining_command do + {System.find_executable("erl"), ["-noshell", "-eval", "io:get_line(''), halt(0)."]} end end diff --git a/test/ourocode/runtime/stream_supervisor_test.exs b/test/ourocode/runtime/stream_supervisor_test.exs index 19fa402..d8b3dd3 100644 --- a/test/ourocode/runtime/stream_supervisor_test.exs +++ b/test/ourocode/runtime/stream_supervisor_test.exs @@ -6,6 +6,7 @@ defmodule Ourocode.Runtime.StreamSupervisorTest do alias Ourocode.MCP.Transport.StreamableHTTP alias Ourocode.Runtime.Stream.{Child, Session, Transport} alias Ourocode.Runtime.Stream.Telemetry + alias Ourocode.Test.PortPrograms setup do original_stream_mailbox_capacity = Application.get_env(:ourocode, :stream_mailbox_capacity) @@ -836,14 +837,13 @@ defmodule Ourocode.Runtime.StreamSupervisorTest do Application.put_env(:ourocode, :stale_cleanup_timeout_ms, 100) configured_timeout_ms = Config.defaults().stale_cleanup_timeout_ms termination_budget_ms = configured_timeout_ms + 500 - command = System.find_executable("sh") - assert is_binary(command) + {command, args} = PortPrograms.long_running_command() port = Port.open({:spawn_executable, command}, [ :binary, :exit_status, - {:args, ["-c", "while IFS= read -r line; do :; done"]}, + {:args, args}, {:line, 65_536} ]) diff --git a/test/ourocode/runtime/user_level_plugin_invocation_post_execution_test.exs b/test/ourocode/runtime/user_level_plugin_invocation_post_execution_test.exs index fa2bf87..55b1047 100644 --- a/test/ourocode/runtime/user_level_plugin_invocation_post_execution_test.exs +++ b/test/ourocode/runtime/user_level_plugin_invocation_post_execution_test.exs @@ -6,7 +6,12 @@ defmodule Ourocode.Runtime.UserLevelPluginInvocationPostExecutionTest do alias Ourocode.TaskRequest setup do - tmp = Path.join(System.tmp_dir!(), "ourocode_invocation_post_#{System.unique_integer([:positive])}") + tmp = + Path.join( + System.tmp_dir!(), + "ourocode_invocation_post_#{System.unique_integer([:positive])}" + ) + File.mkdir_p!(tmp) on_exit(fn -> File.rm_rf!(tmp) end) %{cwd: tmp} @@ -74,12 +79,13 @@ defmodule Ourocode.Runtime.UserLevelPluginInvocationPostExecutionTest do assert envelope.status == :invoked - assert [%{kind: :seed, path: ^seed}] = envelope.artifacts + assert [%{kind: :seed, path: artifact_seed}] = envelope.artifacts + assert_same_path(artifact_seed, seed) assert envelope.continuation.action == :suggest - assert envelope.continuation.seed_path == seed + assert_same_path(envelope.continuation.seed_path, seed) assert envelope.continuation.command_template == - "ooo run seed_path=#{seed}" + "ooo run seed_path=#{envelope.continuation.seed_path}" end test "auto_run continuation when prompt opts in explicitly", %{cwd: cwd} do @@ -105,7 +111,11 @@ defmodule Ourocode.Runtime.UserLevelPluginInvocationPostExecutionTest do runner = fn _cmd, _argv, _opts -> {:ok, %{status: 0, stdout: "", stderr: ""}} end pid = self() - journal = fn event -> send(pid, {:journal, event["event_type"]}); :ok end + + journal = fn event -> + send(pid, {:journal, event["event_type"]}) + :ok + end {:ok, _envelope} = UserLevelPluginInvocation.execute(task("ooo superpowers tdd --goal x"), %{ @@ -134,7 +144,11 @@ defmodule Ourocode.Runtime.UserLevelPluginInvocationPostExecutionTest do }) pid = self() - journal = fn event -> send(pid, {:journal, event["event_type"]}); :ok end + + journal = fn event -> + send(pid, {:journal, event["event_type"]}) + :ok + end {:ok, envelope} = UserLevelPluginInvocation.execute(task("ooo superpowers tdd --goal x"), %{ @@ -152,4 +166,19 @@ defmodule Ourocode.Runtime.UserLevelPluginInvocationPostExecutionTest do refute_received {:journal, "user_level_artifact"} refute_received {:journal, "user_level_continuation"} end + + defp assert_same_path(left, right) do + if match?({:win32, _}, :os.type()) do + assert path_key(left) == path_key(right) + else + assert left == right + end + end + + defp path_key(path) do + path + |> Path.expand() + |> String.replace("\\", "/") + |> String.downcase() + end end diff --git a/test/ourocode/terminal/command_handler_test.exs b/test/ourocode/terminal/command_handler_test.exs index 4399911..e884502 100644 --- a/test/ourocode/terminal/command_handler_test.exs +++ b/test/ourocode/terminal/command_handler_test.exs @@ -6,6 +6,7 @@ defmodule Ourocode.Terminal.CommandHandlerTest do alias Ourocode.Terminal.CommandInput alias Ourocode.Runtime.FocusState alias Ourocode.Terminal.FocusNavigation + import Ourocode.Test.PathAssertions, only: [assert_same_path: 2] test "handle renders built-in help commands from the contextual registry" do {:ok, output} = StringIO.open("") @@ -115,9 +116,11 @@ defmodule Ourocode.Terminal.CommandHandlerTest do assert {:ok, %{sessions: [%{id: "session-alpha", event_count: 1}]}} = CommandHandler.handle(CommandInput.command_event("/resume"), state) - assert {:ok, %{path: ^session_path, events: [_event]}} = + assert {:ok, %{path: replayed_path, events: [_event]}} = CommandHandler.handle(CommandInput.command_event("/resume session-alpha"), state) + assert_same_path(replayed_path, session_path) + {_input, text} = StringIO.contents(output) assert text =~ "resume workspace" assert text =~ "latest Saved session" diff --git a/test/ourocode/terminal/conversation_store_test.exs b/test/ourocode/terminal/conversation_store_test.exs index 4c46f1c..792e181 100644 --- a/test/ourocode/terminal/conversation_store_test.exs +++ b/test/ourocode/terminal/conversation_store_test.exs @@ -27,7 +27,7 @@ defmodule Ourocode.Terminal.ConversationStoreTest do conversation = Conversation.add_turn(Conversation.new(), "a", "b") :ok = ConversationStore.save(project, conversation, state_dir: state_dir) - [file] = Path.wildcard(Path.join([state_dir, "chat", "*.json"])) + [file] = wildcard_chat_files(state_dir) File.write!(file, "{not json") assert Conversation.empty?(ConversationStore.load(project, state_dir: state_dir)) @@ -71,4 +71,11 @@ defmodule Ourocode.Terminal.ConversationStoreTest do on_exit(fn -> File.rm_rf(dir) end) dir end + + defp wildcard_chat_files(state_dir) do + [state_dir, "chat", "*.json"] + |> Path.join() + |> String.replace("\\", "/") + |> Path.wildcard() + end end diff --git a/test/ourocode/terminal/hud_model_test.exs b/test/ourocode/terminal/hud_model_test.exs index 0275c76..64557b5 100644 --- a/test/ourocode/terminal/hud_model_test.exs +++ b/test/ourocode/terminal/hud_model_test.exs @@ -20,11 +20,11 @@ defmodule Ourocode.Terminal.HudModelTest do %{"runtime" => "?", "status" => "healthy"}, [], :normal, - %{model_status: "codex (ChatGPT) · 1.2s"}, + %{model_status: "provider: codex (ChatGPT) model: gpt-5.3-codex · 1.2s"}, 100 ) - assert hud.left_status == "codex (ChatGPT) · 1.2s ready" + assert hud.left_status == "provider: codex (ChatGPT) model: gpt-5.3-codex · 1.2s ready" end test "model status is omitted when absent so the strip stays compact" do diff --git a/test/ourocode/terminal/interview_handoff_test.exs b/test/ourocode/terminal/interview_handoff_test.exs index b5047cd..f8aea82 100644 --- a/test/ourocode/terminal/interview_handoff_test.exs +++ b/test/ourocode/terminal/interview_handoff_test.exs @@ -6,12 +6,21 @@ defmodule Ourocode.Terminal.InterviewHandoffTest do test "prompt wraps a paused interview question and user message" do prompt = InterviewHandoff.prompt("Which provider?", "Compare Stripe and Toss") + refute prompt =~ "\r\n" assert prompt =~ "An interview checkpoint is paused" assert prompt =~ "Pending interview question:\nWhich provider?" assert prompt =~ "User message:\nCompare Stripe and Toss" assert prompt =~ "INTERVIEW_ANSWER: " end + test "prompt normalizes CRLF in handoff inputs" do + prompt = InterviewHandoff.prompt("Which\r\nprovider?", "Compare\r\nStripe and Toss") + + refute prompt =~ "\r\n" + assert prompt =~ "Pending interview question:\nWhich\nprovider?" + assert prompt =~ "User message:\nCompare\nStripe and Toss" + end + test "extract_answer reads the last explicit handoff line" do assert InterviewHandoff.extract_answer(""" Earlier thought. diff --git a/test/ourocode/terminal/interview_panel/text_test.exs b/test/ourocode/terminal/interview_panel/text_test.exs index 016159f..7e4cff0 100644 --- a/test/ourocode/terminal/interview_panel/text_test.exs +++ b/test/ourocode/terminal/interview_panel/text_test.exs @@ -12,6 +12,59 @@ defmodule Ourocode.Terminal.InterviewPanel.TextTest do assert Text.flatten_line("**Hello**\n\n`world`") == "Hello world" end + test "flatten_line keeps Korean PM option text while stripping unstable glyphs" do + option = + <<234, 176>> <> + " - " <> + hangul([0xC778]) <> + " " <> + hangul([0xD074, 0xB9AC, 0xC5D0, 0xC774, 0xD130]) <> + " | " <> + hangul([0xC778, 0xC2A4, 0xD0C0, 0xADF8, 0xB7A8]) <> + ", " <> + hangul([0xBE14, 0xB85C, 0xADF8]) <> + ", " <> + hangul([0xB274, 0xC2A4, 0xB808, 0xD130]) <> + hangul([0xC6A9]) <> + " " <> + hangul([0xCF58, 0xD150, 0xCE20]) <> + hangul([0xB97C]) <> + " " <> + hangul([0xD63C, 0xC790]) <> + " " <> + hangul([0xBE60, 0xB974, 0xAC8C]) <> + " " <> + hangul([0xB9CC, 0xB4DC, 0xB294]) <> + " " <> + hangul([0xC0AC, 0xB78C]) <> + " " <> + <<0x1F44B::utf8>> + + assert Text.flatten_line(option) == + "- " <> + hangul([0xC778]) <> + " " <> + hangul([0xD074, 0xB9AC, 0xC5D0, 0xC774, 0xD130]) <> + " | " <> + hangul([0xC778, 0xC2A4, 0xD0C0, 0xADF8, 0xB7A8]) <> + ", " <> + hangul([0xBE14, 0xB85C, 0xADF8]) <> + ", " <> + hangul([0xB274, 0xC2A4, 0xB808, 0xD130]) <> + hangul([0xC6A9]) <> + " " <> + hangul([0xCF58, 0xD150, 0xCE20]) <> + hangul([0xB97C]) <> + " " <> + hangul([0xD63C, 0xC790]) <> + " " <> + hangul([0xBE60, 0xB974, 0xAC8C]) <> + " " <> + hangul([0xB9CC, 0xB4DC, 0xB294]) <> + " " <> + hangul([0xC0AC, 0xB78C]) + end + test "plain_line only collapses whitespace" do assert Text.plain_line(" **Hello**\n world ") == "**Hello** world" end @@ -41,7 +94,9 @@ defmodule Ourocode.Terminal.InterviewPanel.TextTest do test "keeps deliberate Hangul word boundaries when the source has wider gaps" do first = hangul([0xC778, 0xD130, 0xBDF0]) second = hangul([0xD50C, 0xB85C, 0xC6B0]) - spaced = Enum.join(String.graphemes(first), " ") <> " " <> Enum.join(String.graphemes(second), " ") + + spaced = + Enum.join(String.graphemes(first), " ") <> " " <> Enum.join(String.graphemes(second), " ") assert Text.md_text(spaced) == first <> " " <> second end @@ -106,7 +161,8 @@ defmodule Ourocode.Terminal.InterviewPanel.TextTest do |> String.graphemes() |> Enum.join(" ") - assert Text.md_text(spaced) == Enum.join([first, second, third, fourth, fifth, sixth, seventh], " ") + assert Text.md_text(spaced) == + Enum.join([first, second, third, fourth, fifth, sixth, seventh], " ") end test "restores quality and execution phrase boundaries after compacting a model-spaced option" do diff --git a/test/ourocode/terminal/node_terminal_runtime_test.exs b/test/ourocode/terminal/node_terminal_runtime_test.exs index 18c448e..1d04958 100644 --- a/test/ourocode/terminal/node_terminal_runtime_test.exs +++ b/test/ourocode/terminal/node_terminal_runtime_test.exs @@ -32,6 +32,8 @@ defmodule Ourocode.Terminal.NodeTerminalRuntimeTest do defp node_terminal_script do """ const { spawnSync } = require("node:child_process"); + const fs = require("node:fs"); + const os = require("node:os"); const path = require("node:path"); const forbiddenGlobals = ["window", "document", "HTMLElement", "DOMParser"]; @@ -86,15 +88,31 @@ defmodule Ourocode.Terminal.NodeTerminalRuntimeTest do if "node_terminal_render_ok" not in result, do: System.halt(1) `; - const child = spawnSync("elixir", [...codePaths, "-e", elixirCode], { - cwd: process.env.OUROCODE_PROJECT_DIR, - env: process.env, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); + const scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), "ourocode-node-terminal-")); + const elixirScriptPath = path.join(scriptDir, "runtime.exs"); + fs.writeFileSync(elixirScriptPath, elixirCode, "utf8"); + + const elixirArgs = [...codePaths, elixirScriptPath]; + const child = + process.platform === "win32" + ? spawnSync(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "elixir.bat", ...elixirArgs], { + cwd: process.env.OUROCODE_PROJECT_DIR, + env: process.env, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }) + : spawnSync("elixir", elixirArgs, { + cwd: process.env.OUROCODE_PROJECT_DIR, + env: process.env, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + fs.rmSync(scriptDir, { recursive: true, force: true }); process.stdout.write(child.stdout || ""); process.stderr.write(child.stderr || ""); + if (child.error) process.stderr.write(child.error.message + "\\n"); process.exit(child.status ?? 1); """ end diff --git a/test/ourocode/terminal/resume_sessions_test.exs b/test/ourocode/terminal/resume_sessions_test.exs index 51bcd2c..9643cee 100644 --- a/test/ourocode/terminal/resume_sessions_test.exs +++ b/test/ourocode/terminal/resume_sessions_test.exs @@ -3,6 +3,7 @@ defmodule Ourocode.Terminal.ResumeSessionsTest do alias Ourocode.Journal alias Ourocode.Terminal.ResumeSessions + import Ourocode.Test.PathAssertions, only: [assert_same_path: 2] test "handles journal replay actions only" do assert ResumeSessions.handles?(:resume_session) @@ -20,9 +21,10 @@ defmodule Ourocode.Terminal.ResumeSessionsTest do Journal.append!(session_path, %{type: :event, event_seq: 2}) assert [ - %{id: "session-alpha", path: ^session_path, event_count: 2, updated_label: updated} + %{id: "session-alpha", path: actual_path, event_count: 2, updated_label: updated} ] = ResumeSessions.list(%{journal_path: active_path}) + assert_same_path(actual_path, session_path) assert is_binary(updated) end @@ -35,13 +37,17 @@ defmodule Ourocode.Terminal.ResumeSessionsTest do state = %{journal_path: active_path} - assert ResumeSessions.resolve_path(state, "session-bravo") == {:ok, session_path} - assert ResumeSessions.resolve_path(state, "1") == {:ok, session_path} + assert {:ok, actual_session_path} = ResumeSessions.resolve_path(state, "session-bravo") + assert_same_path(actual_session_path, session_path) + + assert {:ok, indexed_session_path} = ResumeSessions.resolve_path(state, "1") + assert_same_path(indexed_session_path, session_path) external_path = Path.join(dir, "external.jsonl") File.write!(external_path, "") - assert ResumeSessions.resolve_path(state, external_path) == {:ok, external_path} + assert {:ok, actual_external_path} = ResumeSessions.resolve_path(state, external_path) + assert_same_path(actual_external_path, external_path) assert ResumeSessions.resolve_path(state, "missing") == {:error, {:unknown_resume_session, "missing"}} @@ -60,9 +66,11 @@ defmodule Ourocode.Terminal.ResumeSessionsTest do assert {:ok, %{sessions: [%{id: "session-charlie", event_count: 1}]}} = ResumeSessions.run([], state, output) - assert {:ok, %{path: ^session_path, events: [_event]}} = + assert {:ok, %{path: actual_path, events: [_event]}} = ResumeSessions.run(["session-charlie"], state, output) + assert_same_path(actual_path, session_path) + {_input, text} = StringIO.contents(output) assert text =~ "resume workspace" assert text =~ "latest Saved session" @@ -106,9 +114,11 @@ defmodule Ourocode.Terminal.ResumeSessionsTest do assert {:ok, %{sessions: [%{id: "session-delta"}]}} = ResumeSessions.dispatch(:resume_session, %{args: []}, state) - assert {:ok, %{path: ^session_path, events: [_event]}} = + assert {:ok, %{path: actual_path, events: [_event]}} = ResumeSessions.dispatch(:replay_journal, %{args: ["session-delta"]}, state) + assert_same_path(actual_path, session_path) + {_input, text} = StringIO.contents(output) assert text =~ "resume workspace" assert text =~ "resumed session-delta: 1 events replayed" @@ -118,10 +128,12 @@ defmodule Ourocode.Terminal.ResumeSessionsTest do dir = tmp_dir!("resume-session-startup") journal_path = Path.join(dir, "runtime.jsonl") - assert ResumeSessions.journal_dir(%{ - startup_result: %{runtime: %{journal: %{path: journal_path}}} - }) == - dir + assert_same_path( + ResumeSessions.journal_dir(%{ + startup_result: %{runtime: %{journal: %{path: journal_path}}} + }), + dir + ) assert ResumeSessions.journal_dir(%{}) == Path.join([File.cwd!(), ".ourocode", "journals"]) end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..09b6167 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,187 @@ ExUnit.start() + +defmodule Ourocode.Test.PathAssertions do + @moduledoc false + + import ExUnit.Assertions + + def assert_same_path(left, right) do + assert path_key(left) == path_key(right) + end + + def path_key(path) do + path + |> Path.expand() + |> String.replace("\\", "/") + |> maybe_downcase_windows_path() + end + + defp maybe_downcase_windows_path(path) do + case :os.type() do + {:win32, _name} -> String.downcase(path) + _other -> path + end + end +end + +defmodule Ourocode.Test.PortPrograms do + @moduledoc false + + import ExUnit.Assertions + + def long_running_command do + erl = System.find_executable("erl") || System.find_executable("erl.exe") + assert is_binary(erl), "expected erl executable for portable long-running port test helper" + + {erl, ["-noshell", "-eval", "timer:sleep(infinity)."]} + end + + def echo_line_command(prefix) when is_binary(prefix) do + elixir_program(""" + IO.stream(:stdio, :line) + |> Enum.each(fn line -> + line = line |> String.trim_trailing("\\n") |> String.trim_trailing("\\r") + IO.puts(#{inspect(prefix)} <> line) + end) + """) + end + + def read_once_and_exit_command do + elixir_program(""" + IO.read(:stdio, :line) + System.halt(0) + """) + end + + def shell_script(script) when is_binary(script) do + operations = + script + |> normalize_printf_newlines() + |> String.split("\n") + |> Enum.flat_map(&parse_shell_line/1) + + elixir_program(""" + operations = #{inspect(operations, limit: :infinity, printable_limit: :infinity)} + + IO.stream(:stdio, :line) + |> Enum.each(fn line -> + line = line |> String.trim_trailing("\\n") |> String.trim_trailing("\\r") + + Enum.each(operations, fn + {:print, value} -> IO.puts(value) + {:echo_line, prefix} -> IO.puts(prefix <> line) + {:sleep, ms} -> Process.sleep(ms) + end) + end) + """) + end + + defp elixir_program(source) do + elixir = System.find_executable("elixir") || System.find_executable("elixir.bat") + assert is_binary(elixir), "expected elixir executable for portable port test helper" + + path = + Path.join( + System.tmp_dir!(), + "ourocode-port-program-#{System.unique_integer([:positive])}.exs" + ) + + File.write!(path, "File.rm(__ENV__.file)\n" <> source) + + {elixir, [path]} + end + + defp normalize_printf_newlines(script) do + String.replace(script, "%s\n'", "%s\\n'") + end + + defp parse_shell_line(raw_line) do + line = String.trim(raw_line) + + cond do + line == "" -> + [] + + line == "done" -> + [] + + String.starts_with?(line, "while ") -> + [] + + String.starts_with?(line, "IFS=") -> + [] + + line == "exit 0" -> + [] + + line == "sleep 1" -> + [{:sleep, 1_000}] + + line == ~s(printf '%s\\n' "echo:$line") -> + [{:echo_line, "echo:"}] + + match = Regex.run(~r/^printf '%s\\n' '(.*)'$/, line) -> + [_full, value] = match + [{:print, value}] + + true -> + flunk("unsupported portable shell test line: #{inspect(line)}") + end + end +end + +defmodule Ourocode.Test.OsProcessAssertions do + @moduledoc false + + import ExUnit.Assertions + + def assert_os_process_alive(os_pid) do + assert os_process_alive?(os_pid), "expected OS process #{os_pid} to be alive" + end + + def refute_os_process_alive(os_pid) do + deadline = System.monotonic_time(:millisecond) + 1_500 + + unless wait_until_dead(os_pid, deadline) do + flunk("expected OS process #{os_pid} to be reaped") + end + end + + defp wait_until_dead(os_pid, deadline) do + cond do + not os_process_alive?(os_pid) -> + true + + System.monotonic_time(:millisecond) >= deadline -> + false + + true -> + Process.sleep(25) + wait_until_dead(os_pid, deadline) + end + end + + defp os_process_alive?(os_pid) when is_integer(os_pid) and os_pid > 0 do + if windows?() do + tasklist_contains_pid?(os_pid) + else + {_output, code} = + System.cmd("kill", ["-0", Integer.to_string(os_pid)], stderr_to_stdout: true) + + code == 0 + end + end + + defp tasklist_contains_pid?(os_pid) do + command = System.find_executable("tasklist") || "tasklist" + + {output, _code} = + System.cmd(command, ["/FI", "PID eq #{os_pid}", "/NH"], stderr_to_stdout: true) + + output + |> String.split() + |> Enum.member?(Integer.to_string(os_pid)) + end + + defp windows?, do: match?({:win32, _name}, :os.type()) +end