#!/usr/bin/env -S nvim -l -- Simple test runner using Neovim's built-in features -- Run with: nvim -l tests/run_tests.lua -- Add lua directory to package path package.path = package.path .. ";lua/?.lua;lua/?/init.lua" -- Load the parser module local parser = require("jj.core.parser") local utils = require("jj.utils") local tests_passed = 0 local tests_failed = 0 local failures = {} local function assert_equals(expected, actual, msg) if expected ~= actual then error( string.format("%s\nExpected: %s\nGot: %s", msg or "Assertion failed", tostring(expected), tostring(actual)) ) end end local function assert_is_nil(value, msg) if value ~= nil then error(string.format("%s\nExpected: nil\nGot: %s", msg or "Assertion failed", tostring(value))) end end local function assert_table_equals(expected, actual, msg) if type(expected) ~= "table" or type(actual) ~= "table" then error( string.format("%s\nExpected table, got: %s and %s", msg or "Assertion failed", type(expected), type(actual)) ) end if not vim.deep_equal(expected, actual) then error( string.format( "%s\nExpected: %s\nGot: %s", msg or "Assertion failed", vim.inspect(expected), vim.inspect(actual) ) ) end end local function run_test(name, test_fn) local status, err = pcall(test_fn) if status then tests_passed = tests_passed + 1 print(string.format("✓ %s", name)) else tests_failed = tests_failed + 1 print(string.format("✗ %s", name)) table.insert(failures, { name = name, error = err }) end end print("\n=== Running parser tests ===\n") -- Test cases run_test("parses simple diamond symbol", function() local line = "◆ abc123 my commit message" assert_equals("abc123", parser.get_revset(line)) end) run_test("parses simple circle symbol", function() local line = "○ def456 another commit" assert_equals("def456", parser.get_revset(line)) end) run_test("parses simple @ symbol", function() local line = "@ def456 another commit" assert_equals("def456", parser.get_revset(line)) end) run_test("parses conflict symbol", function() local line = "× ghi789 conflicted commit" assert_equals("ghi789", parser.get_revset(line)) end) run_test("parses with leading whitespace", function() local line = " ◆ jkl012 indented commit" assert_equals("jkl012", parser.get_revset(line)) end) run_test("parses single branch with box drawing", function() local line = "│ ○ mno345 commit on branch" assert_equals("mno345", parser.get_revset(line)) end) run_test("parses multiple branches", function() local line = "│ │ ◆ pqr678 commit with multiple branches" assert_equals("pqr678", parser.get_revset(line)) end) run_test("parses complex graph with connectors", function() local line = "├─○ stu901 commit after merge" assert_equals("stu901", parser.get_revset(line)) end) run_test("parses graph with multiple box chars", function() local line = "│ ├─◆ vwx234 complex branch" assert_equals("vwx234", parser.get_revset(line)) end) run_test("parses ASCII @ symbol", function() local line = "@ yza567 current working copy" assert_equals("yza567", parser.get_revset(line)) end) run_test("parses ASCII * symbol (git-style)", function() local line = "* bcd890 git style commit" assert_is_nil(parser.get_revset(line)) end) run_test("parses ASCII graph with pipe", function() local line = "| * efg123 ascii branch" assert_is_nil(parser.get_revset(line)) end) run_test("parses mixed ASCII graph", function() local line = "|\\ @ hij456 merge commit" assert_equals("hij456", parser.get_revset(line)) end) run_test("parses @ not at top", function() local line = "│ @ klm789 current change in middle" assert_equals("klm789", parser.get_revset(line)) end) run_test("parses @ with multiple branches", function() local line = "│ │ @ nop012 working copy on branch" assert_equals("nop012", parser.get_revset(line)) end) run_test("parses @ after merge connector", function() local line = "├─@ qrs345 working copy after merge" assert_equals("qrs345", parser.get_revset(line)) end) run_test("parses with various box drawing characters", function() local line = "╭─╮ ○ tuv678 fancy box" assert_equals("tuv678", parser.get_revset(line)) end) run_test("parses deeply nested branches", function() local line = "│ │ │ │ ◆ wxy901 deeply nested" assert_equals("wxy901", parser.get_revset(line)) end) run_test("parses revision with numbers and letters", function() local line = "◆ abc123def456 mixed alphanumeric" assert_equals("abc123def456", parser.get_revset(line)) end) run_test("stops at first non-alphanumeric after revision", function() local line = "○ xyz789 this is the message" assert_equals("xyz789", parser.get_revset(line)) end) run_test("parses with different line connector styles", function() local line = "┼─┤ ◆ zab234 cross connector" assert_equals("zab234", parser.get_revset(line)) end) run_test("parses with curve connectors", function() local line = "╰─○ cde567 curve connector" assert_equals("cde567", parser.get_revset(line)) end) run_test("parses with double line vertical ┃", function() local line = "┃ ◆ fgh890 double line vertical" assert_equals("fgh890", parser.get_revset(line)) end) run_test("parses with light triple dash vertical ┆", function() local line = "┆ ○ ijk123 light triple dash" assert_equals("ijk123", parser.get_revset(line)) end) run_test("parses with heavy triple dash vertical ┇", function() local line = "┇ ◆ lmn456 heavy triple dash" assert_equals("lmn456", parser.get_revset(line)) end) run_test("parses with light quadruple dash vertical ┊", function() local line = "┊ ○ opq789 light quadruple dash" assert_equals("opq789", parser.get_revset(line)) end) run_test("parses with heavy quadruple dash vertical ┋", function() local line = "┋ ◆ rst012 heavy quadruple dash" assert_equals("rst012", parser.get_revset(line)) end) run_test("parses with top-left corner ┌", function() local line = "┌─○ uvw345 top left corner" assert_equals("uvw345", parser.get_revset(line)) end) run_test("parses with top-right corner ┐", function() local line = "┐ ◆ xyz678 top right corner" assert_equals("xyz678", parser.get_revset(line)) end) run_test("parses with bottom-left corner └", function() local line = "└─○ abc901 bottom left corner" assert_equals("abc901", parser.get_revset(line)) end) run_test("parses with bottom-right corner ┘", function() local line = "┘ ◆ def234 bottom right corner" assert_equals("def234", parser.get_revset(line)) end) run_test("parses with left tee ├", function() local line = "├ ○ ghi567 left tee" assert_equals("ghi567", parser.get_revset(line)) end) run_test("parses with right tee ┤", function() local line = "┤ ◆ jkl890 right tee" assert_equals("jkl890", parser.get_revset(line)) end) run_test("parses with top tee ┬", function() local line = "┬─○ mno123 top tee" assert_equals("mno123", parser.get_revset(line)) end) run_test("parses with bottom tee ┴", function() local line = "┴─◆ pqr456 bottom tee" assert_equals("pqr456", parser.get_revset(line)) end) run_test("parses with cross ┼", function() local line = "┼ ○ stu789 cross" assert_equals("stu789", parser.get_revset(line)) end) run_test("parses with mixed special box chars", function() local line = "┃ ┆ ┇ ○ vwx012 mixed special" assert_equals("vwx012", parser.get_revset(line)) end) run_test("parses with rounded corners", function() local line = "╭─╮ ╰─╯ ◆ yza345 rounded corners" assert_equals("yza345", parser.get_revset(line)) end) run_test("returns nil for lines without revision", function() local line = "This is just a description line" assert_is_nil(parser.get_revset(line)) end) run_test("returns nil for empty line", function() local line = "" assert_is_nil(parser.get_revset(line)) end) run_test("returns nil for only graph characters", function() local line = "│ │ ├─" assert_is_nil(parser.get_revset(line)) end) -- Regression tests for false positives with description lines run_test("returns nil for description line 'go' (false positive)", function() local line = "│ │ go mod tidy" assert_is_nil(parser.get_revset(line)) end) run_test("returns nil for description line 'Improve' (false positive)", function() local line = "│ │ │ Improve input validation and UX for repository" assert_is_nil(parser.get_revset(line)) end) run_test("returns nil for description line 'Add' (false positive)", function() local line = "│ │ │ Add Makefile and performance docs" assert_is_nil(parser.get_revset(line)) end) run_test("returns nil for description line starting with word (graph only)", function() local line = "├─── description text here" assert_is_nil(parser.get_revset(line)) end) run_test("returns nil for line with only graph chars and text", function() local line = "│ │ │ commit message without symbol" assert_is_nil(parser.get_revset(line)) end) run_test("still parses correctly when symbol is present", function() local line = "│ │ ◆ s some description" assert_equals("s", parser.get_revset(line)) end) run_test("still parses correctly with box chars and symbol", function() local line = "├─○ go some description" assert_equals("go", parser.get_revset(line)) end) run_test("still parses correctly with divergent change suffix /0", function() local line = "├─○ go/0 some description" assert_equals("go/0", parser.get_revset(line)) end) -- Divergent changes tests run_test("parses divergent change with /0", function() local line = "◆ abc123/0 first divergent copy" assert_equals("abc123/0", parser.get_revset(line)) end) run_test("parses divergent change with /1", function() local line = "○ rs/1 lam@lamtrung.com 9 hours ago pr-83-1 6a7 (divergent)" assert_equals("rs/1", parser.get_revset(line)) end) run_test("parses divergent change with /2", function() local line = "◆ ghi789/2 third divergent copy" assert_equals("ghi789/2", parser.get_revset(line)) end) run_test("parses divergent change with higher number /10", function() local line = "○ jkl012/10 tenth divergent copy" assert_equals("jkl012/10", parser.get_revset(line)) end) run_test("parses divergent change with graph chars", function() local line = "│ │ ◆ mno345/0 divergent on branch" assert_equals("mno345/0", parser.get_revset(line)) end) run_test("parses divergent change with @ symbol", function() local line = "@ pqr678/1 working copy divergent" assert_equals("pqr678/1", parser.get_revset(line)) end) run_test("parses divergent change with conflict symbol", function() local line = "× stu901/0 conflicted divergent" assert_equals("stu901/0", parser.get_revset(line)) end) run_test("parses divergent change with merge connector", function() local line = "├─○ vwx234/2 divergent after merge" assert_equals("vwx234/2", parser.get_revset(line)) end) run_test("parses divergent change deeply nested", function() local line = "│ │ │ │ ◆ yza567/0 deeply nested divergent" assert_equals("yza567/0", parser.get_revset(line)) end) run_test("parses short revset with divergent suffix", function() local line = "○ a/0 single char divergent" assert_equals("a/0", parser.get_revset(line)) end) run_test("parses divergent change at end of line", function() local line = "◆ bcd890/1" assert_equals("bcd890/1", parser.get_revset(line)) end) print("\n=== Running parse_default_cmd tests ===\n") run_test("parse_default_cmd: parses config list array output", function() local output = 'ui.default-command = ["log", "--no-pager", "--limit", "18"]' assert_table_equals({ "log", "--no-pager", "--limit", "18" }, parser.parse_default_cmd(output)) end) run_test("parse_default_cmd: parses config list single string output", function() local output = 'ui.default-command = "log"' assert_table_equals({ "log" }, parser.parse_default_cmd(output)) end) run_test("parse_default_cmd: parses bare array (config get format)", function() local output = '["log", "--limit", "10"]' assert_table_equals({ "log", "--limit", "10" }, parser.parse_default_cmd(output)) end) run_test("parse_default_cmd: parses bare string (config get format)", function() local output = '"log"' assert_table_equals({ "log" }, parser.parse_default_cmd(output)) end) run_test("parse_default_cmd: parses unquoted string", function() local output = "log" assert_table_equals({ "log" }, parser.parse_default_cmd(output)) end) run_test("parse_default_cmd: returns nil for empty string", function() assert_is_nil(parser.parse_default_cmd("")) end) run_test("parse_default_cmd: returns nil for nil", function() assert_is_nil(parser.parse_default_cmd(nil)) end) print("\n=== Running parse_push_args tests ===\n") local cmd = require("jj.cmd") run_test("parse_push_args: parses bookmark and remote", function() local opts, err = cmd.parse_push_args({ "my-bookmark", "--remote", "origin" }) assert_is_nil(err) assert_table_equals({ bookmark = "my-bookmark", remote = "origin" }, opts) end) run_test("parse_push_args: parses deleted flag", function() local opts, err = cmd.parse_push_args({ "--deleted" }) assert_is_nil(err) assert_table_equals({ deleted = true }, opts) end) run_test("parse_push_args: allows remote before bookmark", function() local opts, err = cmd.parse_push_args({ "--remote", "origin", "my-bookmark" }) assert_is_nil(err) assert_table_equals({ bookmark = "my-bookmark", remote = "origin" }, opts) end) run_test("parse_push_args: errors on missing remote value", function() local opts, err = cmd.parse_push_args({ "--remote" }) assert_is_nil(opts) assert_equals("Missing remote name after --remote", err) end) run_test("parse_push_args: errors when remote value is another option", function() local opts, err = cmd.parse_push_args({ "--remote", "--deleted" }) assert_is_nil(opts) assert_equals("Missing remote name after --remote", err) end) run_test("parse_push_args: errors on duplicate remotes", function() local opts, err = cmd.parse_push_args({ "--remote", "origin", "--remote", "upstream" }) assert_is_nil(opts) assert_equals("Remote already set. Cannot specify multiple remotes.", err) end) run_test("parse_push_args: errors on multiple bookmarks", function() local opts, err = cmd.parse_push_args({ "bookmark-1", "bookmark-2" }) assert_is_nil(opts) assert_equals("Only one bookmark can be provided", err) end) run_test("parse_push_args: errors on unknown option", function() local opts, err = cmd.parse_push_args({ "--foo" }) assert_is_nil(opts) assert_equals("Unknown option: --foo", err) end) run_test("parse_push_args: errors on deleted with bookmark", function() local opts, err = cmd.parse_push_args({ "--deleted", "my-bookmark" }) assert_is_nil(opts) assert_equals("Cannot specify both --deleted and a bookmark", err) end) print("\n=== Running build_log_cmd tests ===\n") local log = require("jj.cmd.log") run_test("build_log_cmd: raw_flags with --no-pager does not duplicate it", function() local cmd = log.build_log_cmd({ raw_flags = "--no-pager --limit 18" }) -- Should contain exactly one --no-pager local _, count = cmd:gsub("%-%-no%-pager", "") assert_equals(1, count, "Expected exactly one --no-pager") assert_equals("jj log --no-pager --limit 18", cmd) end) run_test("build_log_cmd: raw_flags without --no-pager works normally", function() local cmd = log.build_log_cmd({ raw_flags = "--limit 18" }) assert_equals("jj log --no-pager --limit 18", cmd) end) run_test("build_log_cmd: raw_flags that is only --no-pager", function() local cmd = log.build_log_cmd({ raw_flags = "--no-pager" }) assert_equals("jj log --no-pager", cmd) end) run_test("build_log_cmd: structured opts with limit", function() local cmd = log.build_log_cmd({ limit = 10 }) assert_equals(true, cmd:find("--limit 10") ~= nil, "Expected --limit 10 in command") assert_equals(true, cmd:find("--no%-pager") ~= nil, "Expected --no-pager in command") end) run_test("build_log_cmd: structured opts with revisions", function() local cmd = log.build_log_cmd({ revisions = "main" }) assert_equals(true, cmd:find("--revisions main") ~= nil, "Expected --revisions main in command") end) run_test("build_log_cmd: default opts produces valid command", function() local cmd = log.build_log_cmd({}) assert_equals(true, cmd:find("^jj log %-%-no%-pager") ~= nil, "Expected command to start with jj log --no-pager") assert_equals(true, cmd:find("--limit 20") ~= nil, "Expected default --limit 20") end) print("\n=== Running utils.parse_bookmark_names tests ===\n") run_test("parse_bookmark_names: parses simple bookmark", function() local input = "main::true" assert_table_equals({ { name = "main", is_deleted = false } }, utils.parse_bookmark_names(input)) end) run_test("parse_bookmark_names: parses multiple bookmarks", function() local input = "main::true feature-1::true feature-2::false" assert_table_equals( { { name = "main", is_deleted = false }, { name = "feature-1", is_deleted = false }, { name = "feature-2", is_deleted = true }, }, utils.parse_bookmark_names(input) ) end) run_test("parse_bookmark_names: strips asterisks", function() local input = "main*::true feature-1*::true" assert_table_equals( { { name = "main", is_deleted = false }, { name = "feature-1", is_deleted = false } }, utils.parse_bookmark_names(input) ) end) run_test("parse_bookmark_names: strips remote suffixes", function() local input = "main@origin::true feature-1@remote::false" assert_table_equals( { { name = "main", is_deleted = false }, { name = "feature-1", is_deleted = true } }, utils.parse_bookmark_names(input) ) end) run_test("parse_bookmark_names: deduplicates bookmarks", function() local input = "main::true main@origin::true main*::true" assert_table_equals({ { name = "main", is_deleted = false } }, utils.parse_bookmark_names(input)) end) run_test("parse_bookmark_names: handles mixed input", function() local input = "main*::true feature-1::true feature-1@origin::true feature-2*::false" assert_table_equals( { { name = "main", is_deleted = false }, { name = "feature-1", is_deleted = false }, { name = "feature-2", is_deleted = true }, }, utils.parse_bookmark_names(input) ) end) run_test("parse_bookmark_names: handles empty input", function() assert_table_equals({}, utils.parse_bookmark_names("")) end) run_test("parse_bookmark_names: handles whitespace only", function() assert_table_equals({}, utils.parse_bookmark_names(" ")) end) print("\n=== Running file encoding tests ===\n") local jj_file = require("jj.file") --- Hex-dump a string so assertion failures on binary data are readable. local function hex(s) return (s:gsub(".", function(c) return string.format("%02x ", string.byte(c)) end)) end run_test("decode: utf-16le with BOM and dos endings", function() -- "a\r\nü\r\n" in utf-16le with BOM local raw = "\255\254a\0\r\0\n\0\252\0\r\0\n\0" local lines, had_eol, enc = jj_file._decode(raw) assert_table_equals({ "a", "\195\188" }, lines) -- "ü" in utf-8 assert_equals(true, had_eol) assert_equals("utf-16le", enc.fenc) assert_equals(true, enc.bomb) assert_equals("dos", enc.ff) for _, line in ipairs(lines) do assert_is_nil(line:find("%z"), "line contains a NUL byte") assert_is_nil(line:find("\1"), "line contains a SOH byte") end end) run_test("decode: honors explicit enc for BOM-less utf-16le", function() local lines = jj_file._decode("h\0i\0", { fenc = "utf-16le", bomb = false, ff = "unix" }) assert_table_equals({ "hi" }, lines) end) run_test("decode: roundtrip reproduces the bytes exactly", function() local fixtures = { ["utf-16le bom"] = "\255\254h\0i\0\n\0\252\0", ["utf-16be bom"] = "\254\255\0h\0i\0\n\0\252", ["utf-16le bom + dos"] = "\255\254a\0\r\0\n\0b\0\r\0\n\0", ["utf-8 bom"] = "\239\187\191hello\nworld\n", ["plain dos"] = "a\r\nb\r\n", ["mac"] = "a\rb\r", ["no trailing newline"] = "a\nb", ["empty file"] = "", } for name, raw in pairs(fixtures) do local lines, had_eol, enc = jj_file._decode(raw) if lines == nil then error(string.format("%s: decode failed: %s", name, tostring(had_eol))) end local encoded, err = jj_file._encode(lines, had_eol, enc) if encoded == nil then error(string.format("%s: encode failed: %s", name, tostring(err))) end assert_equals(hex(raw), hex(encoded), name) end end) -- Print summary print(string.format("\n=== Test Summary ===")) print(string.format("Passed: %d", tests_passed)) print(string.format("Failed: %d", tests_failed)) if tests_failed > 0 then print("\n=== Failures ===") for _, failure in ipairs(failures) do print(string.format("\n%s:", failure.name)) print(string.format(" %s", failure.error)) end os.exit(1) else print("\n✓ All tests passed!") os.exit(0) end