Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4ddab90
Refactor initialization to be able to run without a wxApp
arch1t3cht Oct 19, 2023
9fd937a
Allow initializing a Context without a GUI
arch1t3cht Oct 20, 2023
5f47fa3
Don't do a bunch of things when in cli mode
arch1t3cht Oct 26, 2023
f3c75de
Don't show progress dialogs when in cli mode
arch1t3cht Oct 31, 2023
8a107bc
Disable a bunch more dialogs and messageboxes in cli mode
arch1t3cht Nov 1, 2023
a75cca8
Disable more video provider things on CLI mode
arch1t3cht Nov 2, 2023
b468966
Implement aegisub-cli's API when run with --cli
arch1t3cht Nov 4, 2023
2b4570c
Allow computing text extents in cli mode
arch1t3cht Nov 4, 2023
8175c62
Clean up some of the locale initialization spaghetti
arch1t3cht Feb 15, 2024
360910d
Fix up the ported CLI commits for current master
CoffeeFlux Aug 12, 2026
5487467
Fix CLI mode to actually work without a display server
CoffeeFlux Aug 12, 2026
79ba48d
Add an end-to-end test for --cli mode
CoffeeFlux Aug 12, 2026
67adf79
Fix the Windows build by keeping wx's entry point there
CoffeeFlux Aug 12, 2026
e6f884e
Don't render video frames asynchronously in CLI mode
CoffeeFlux Aug 12, 2026
3d0d673
Make macro failures fail the process in CLI mode
CoffeeFlux Aug 12, 2026
d4f9ca2
Register the --cli end-to-end test in meson
CoffeeFlux Aug 13, 2026
a8c27a8
Handle command line parsing errors instead of aborting
CoffeeFlux Aug 13, 2026
e828bb8
Fail cleanly in CLI mode when the input file can't be loaded
CoffeeFlux Aug 13, 2026
f00c4d8
Cover the CLI error paths in the end-to-end test
CoffeeFlux Aug 13, 2026
71c53bd
Use CLI11 instead of boost::program_options for the CLI
CoffeeFlux Aug 13, 2026
1ba57f8
Allow overriding ?data with AEGISUB_DATA_DIR
CoffeeFlux Oct 8, 2026
3c555b5
Report startup errors through the error callback
CoffeeFlux Oct 8, 2026
a80b16c
Don't write the user's config or hotkeys in CLI mode
CoffeeFlux Oct 8, 2026
692e56e
Don't prompt or touch playback in CLI mode
CoffeeFlux Oct 8, 2026
6bc8d13
Make tool/resampleres work in CLI mode
CoffeeFlux Oct 8, 2026
8b0a6cc
Don't initialize the desktop portal in CLI mode
CoffeeFlux Oct 8, 2026
a370357
Store the provider lists in vectors to work around a GCC bug
CoffeeFlux Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions automation/tests/cli/cli-test-macro.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
-- Automation 4 test file
-- Deterministic macro used by the meson test suite to exercise --cli mode

script_name = "CLI test macros"
script_description = "Macros used by the automated tests for Aegisub's CLI mode"
script_author = "Aegisub Project"
script_version = "1"

local clipboard = require 'aegisub.clipboard'

local function append_marker(subs, sel, act)
aegisub.progress.title("CLI test")
aegisub.progress.task("Appending markers")

-- In CLI mode the clipboard is a process-local stand-in that never
-- touches the system clipboard or the main thread
assert(clipboard.set("cli-test-clipboard"), "clipboard.set failed")
assert(clipboard.get() == "cli-test-clipboard", "clipboard.get returned the wrong value")

for n, i in ipairs(sel) do
local line = subs[i]
if not line.comment then
line.text = line.text .. "\\N{cli-test}"
subs[i] = line
end
aegisub.progress.set(100 * n / #sel)
end
aegisub.set_undo_point("append cli-test marker")
end

local function count_lines(subs, sel, act)
local dialogue = 0
for i = 1, #subs do
if subs[i].class == "dialogue" then
dialogue = dialogue + 1
end
end
aegisub.log(0, "dialogue lines: " .. dialogue .. "\n")
end

aegisub.register_macro("CLI Test/Append marker", "Appends a marker to all selected lines", append_marker)
aegisub.register_macro("CLI Test/Count lines", "Logs the number of dialogue lines", count_lines)
16 changes: 16 additions & 0 deletions automation/tests/cli/empty.ass
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[Script Info]
; Script generated by Aegisub
Title: CLI test empty file
ScriptType: v4.00+
WrapStyle: 0
ScaledBorderAndShadow: yes
YCbCr Matrix: None
PlayResX: 1280
PlayResY: 720

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
20 changes: 20 additions & 0 deletions automation/tests/cli/input.ass
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[Script Info]
; Script generated by Aegisub
Title: CLI test file
ScriptType: v4.00+
WrapStyle: 0
ScaledBorderAndShadow: yes
YCbCr Matrix: None
PlayResX: 1280
PlayResY: 720

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,First line
Dialogue: 0,0:00:05.00,0:00:10.00,Default,,0,0,0,,Second line
Comment: 0,0:00:10.00,0:00:15.00,Default,,0,0,0,,A comment line
Dialogue: 0,0:00:15.00,0:00:20.00,Default,,0,0,0,,Third line
193 changes: 193 additions & 0 deletions automation/tests/cli/run_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""End-to-end tests for Aegisub's --cli mode.

Runs a deterministic automation macro on fixture files in a throwaway HOME
(so the user's real config is untouched) and checks the results, plus the
error paths for bad command lines and unloadable input files. Must work
without a display server; DISPLAY/WAYLAND_DISPLAY are stripped from the
environment to make sure of that.
"""

import os
import subprocess
import sys
import tempfile

def run_cli(aegisub, args, env):
proc = subprocess.run([aegisub, "--cli"] + args, env=env, timeout=240,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
sys.stdout.buffer.write(proc.stdout)
return proc

def fail(message, output=None):
print(message)
if output is not None:
print("---- output file ----")
print(output)
return 1

def test_macro_runs(aegisub, srcdir, env, tmp):
out_file = os.path.join(tmp, "out.ass")
proc = run_cli(aegisub, [
os.path.join(srcdir, "input.ass"),
out_file,
"CLI Test/Append marker",
"--automation", os.path.join(srcdir, "cli-test-macro.lua"),
], env)
if proc.returncode != 0:
return fail(f"aegisub --cli exited with {proc.returncode}")

if not os.path.exists(out_file):
return fail("output file was not written")

with open(out_file, encoding="utf-8-sig") as f:
out = f.read()

marked = out.count("\\N{cli-test}")
if marked != 3:
return fail(f"expected 3 marked dialogue lines, found {marked}", out)

if "A comment line" not in out:
return fail("comment line went missing from the output", out)
return 0

def test_empty_file(aegisub, srcdir, env, tmp):
# A valid file with no dialogue lines gets a blank line inserted on
# load, the same as when the GUI opens it, and the macro runs on that
out_file = os.path.join(tmp, "out-empty.ass")
proc = run_cli(aegisub, [
os.path.join(srcdir, "empty.ass"),
out_file,
"CLI Test/Append marker",
"--automation", os.path.join(srcdir, "cli-test-macro.lua"),
], env)
if proc.returncode != 0:
return fail(f"empty input: aegisub --cli exited with {proc.returncode}")

if not os.path.exists(out_file):
return fail("empty input: output file was not written")

with open(out_file, encoding="utf-8-sig") as f:
out = f.read()

marked = out.count("\\N{cli-test}")
if marked != 1:
return fail(f"empty input: expected 1 marked line, found {marked}", out)
return 0

def test_missing_input(aegisub, srcdir, env, tmp):
out_file = os.path.join(tmp, "out-missing.ass")
proc = run_cli(aegisub, [
os.path.join(tmp, "does-not-exist.ass"),
out_file,
"CLI Test/Append marker",
"--automation", os.path.join(srcdir, "cli-test-macro.lua"),
], env)
if proc.returncode != 1:
return fail(f"missing input: expected exit code 1, got {proc.returncode}")
if b"Failed to load" not in proc.stdout:
return fail("missing input: no diagnostic was printed")
if os.path.exists(out_file):
return fail("missing input: an output file was written anyway")
return 0

def test_resample(aegisub, srcdir, env, tmp):
# The built-in resampler has no UI in CLI mode and instead resamples to
# the video's resolution, which is what muxtools relies on
out_file = os.path.join(tmp, "out-resample.ass")
proc = run_cli(aegisub, [
os.path.join(srcdir, "input.ass"),
out_file,
"tool/resampleres",
"--video", "?dummy:24:48:1920:1080:0:0:0:",
], env)
if proc.returncode != 0:
return fail(f"resample: aegisub --cli exited with {proc.returncode}")

with open(out_file, encoding="utf-8-sig") as f:
out = f.read()

if "PlayResX: 1920" not in out or "PlayResY: 1080" not in out:
return fail("resample: PlayRes was not changed to the video's resolution", out)
# 1280x720 -> 1920x1080 scales sizes by 1.5
if "Style: Default,Arial,72," not in out:
return fail("resample: the style was not resampled", out)
return 0

def test_resample_without_video(aegisub, srcdir, env, tmp):
out_file = os.path.join(tmp, "out-resample-novideo.ass")
proc = run_cli(aegisub, [
os.path.join(srcdir, "input.ass"),
out_file,
"tool/resampleres",
], env)
if proc.returncode != 1:
return fail(f"resample without video: expected exit code 1, got {proc.returncode}")
if b"requires --video" not in proc.stdout:
return fail("resample without video: no diagnostic was printed")
if os.path.exists(out_file):
return fail("resample without video: an output file was written anyway")
return 0

def test_no_user_state(aegisub, srcdir, env, tmp):
# None of the runs above may have written config, hotkeys, MRU lists,
# logs etc. into the user's config directory
written = []
for user_dir in (os.path.join(tmp, ".aegisub"),
os.path.join(tmp, "Library", "Application Support", "Aegisub")):
for root, _, files in os.walk(user_dir):
written += [os.path.join(root, f) for f in files]
if written:
return fail("files were written to the user's config directory:\n" + "\n".join(written))
return 0

def test_bad_option_value(aegisub, srcdir, env, tmp):
# A malformed value must produce a clean error, not an uncaught
# exception (which would show up here as death by SIGABRT)
proc = run_cli(aegisub, ["--active-line=notanumber"], env)
if proc.returncode != 1:
return fail(f"bad option value: expected exit code 1, got {proc.returncode}")
return 0

def test_unknown_option(aegisub, srcdir, env, tmp):
proc = run_cli(aegisub, ["--definitely-not-an-option"], env)
if proc.returncode != 1:
return fail(f"unknown option: expected exit code 1, got {proc.returncode}")
if b"unrecognised option" not in proc.stdout:
return fail("unknown option: no diagnostic was printed")
return 0

def main():
aegisub, srcdir = sys.argv[1], sys.argv[2]

env = dict(os.environ)
env.pop("DISPLAY", None)
env.pop("WAYLAND_DISPLAY", None)

with tempfile.TemporaryDirectory() as tmp:
env["HOME"] = tmp
env["XDG_CONFIG_HOME"] = os.path.join(tmp, ".config")
env["XDG_CACHE_HOME"] = os.path.join(tmp, ".cache")
env["XDG_DATA_HOME"] = os.path.join(tmp, ".local", "share")
# macOS looks up ?user through Foundation, which ignores HOME
env["CFFIXED_USER_HOME"] = tmp

# ?data points at the install prefix, which doesn't exist for an
# uninstalled build, so point it at the source tree instead to give
# scripts access to the bundled include files (moonscript etc.)
env["AEGISUB_DATA_DIR"] = os.path.dirname(os.path.dirname(os.path.dirname(srcdir)))

tests = [test_macro_runs, test_empty_file, test_missing_input,
test_resample, test_resample_without_video,
test_bad_option_value, test_unknown_option,
test_no_user_state]
for test in tests:
print(f"== {test.__name__}")
if test(aegisub, srcdir, env, tmp) != 0:
return 1

print("ok")
return 0

if __name__ == "__main__":
sys.exit(main())
3 changes: 3 additions & 0 deletions libaegisub/common/hotkey.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ std::string_view Hotkey::GetHotkey(std::string_view context, std::string_view co
}

void Hotkey::Flush() {
if (config_file.empty())
return;

json::Object root;

auto get = [](json::Object& obj, std::string_view key) -> json::UnknownElement& {
Expand Down
6 changes: 6 additions & 0 deletions libaegisub/common/mru.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ MRUManager::MRUManager(agi::fs::path const& config, std::string_view default_con
{
LOG_D("agi/mru") << "Loading MRU List";

// An empty config path means the MRU list is in-memory only (CLI mode)
if (config_name.empty()) return;

auto root = json_util::file(config, default_config);
for (auto const& it : static_cast<json::Object const&>(root))
Load(it.first.c_str(), it.second);
Expand Down Expand Up @@ -114,6 +117,9 @@ agi::fs::path const& MRUManager::GetEntry(std::string_view key, const size_t ent
}

void MRUManager::Flush() {
if (config_name.string().empty())
return;

json::Object out;

for (size_t i = 0; i < mru.size(); ++i) {
Expand Down
11 changes: 11 additions & 0 deletions libaegisub/common/path.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@

#include <boost/range/distance.hpp>

#include <cstdlib>

namespace {
constexpr std::string_view tokens[] = {
"?audio",
Expand Down Expand Up @@ -61,6 +63,15 @@ Path::Path() {
static_assert(sizeof(paths) / sizeof(paths[0]) == sizeof(tokens) / sizeof(tokens[0]),
"Token and path arrays need to be the same size");
FillPlatformSpecificPaths();

// Lets an uninstalled build (e.g. in the test suite) use the bundled
// files from the source tree
#ifdef _WIN32
if (const wchar_t *data = _wgetenv(L"AEGISUB_DATA_DIR"); data && *data)
#else
if (const char *data = getenv("AEGISUB_DATA_DIR"); data && *data)
#endif
SetToken("?data", fs::path(data));
}

fs::path Path::Decode(std::string_view path) const {
Expand Down
18 changes: 18 additions & 0 deletions libaegisub/include/libaegisub/background_runner.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
#pragma once

#include <cstdint>
#include <iostream>
#include <functional>
#include <string>

Expand Down Expand Up @@ -54,6 +55,23 @@ namespace agi {
virtual bool IsCancelled()=0;
};

/// @class CLIProgressSink
/// @brief A progress sink that doesn't do anything with its progress updates except print logs
class CLIProgressSink : public ProgressSink {
std::ostream &stream;

public:
CLIProgressSink(std::ostream &stream = std::cout) : stream(stream) {};

void SetIndeterminate() {}
void SetTitle(std::string const&) {}
void SetMessage(std::string const&) {}
void SetProgress(int64_t, int64_t) {}
void Log(std::string const& str) { stream << str; }
void SetStayOpen(bool) {}
bool IsCancelled() { return false; }
};

/// @class BackgroundRunner
/// @brief A class which runs a function, providing it with a progress sink
///
Expand Down
20 changes: 20 additions & 0 deletions meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ endif
zlib_dep = dependency('zlib')
deps += zlib_dep

# Header-only; the wrap builds CLI11's own first-party meson support
deps += dependency('CLI11', version: '>=2.0')

use_bundled_wx = force_all_fallbacks or force_fallback_for.contains('wxWidgets')
if not use_bundled_wx
wx_dep = dependency('wxWidgets', version: '>=' + get_option('wx_version'),
Expand Down Expand Up @@ -582,3 +585,20 @@ aegisub = executable('aegisub', aegisub_src, version_h, libresrc_headers, aegisu
install_dir: bindir,
dependencies: deps,
win_subsystem: 'windows')

if get_option('tests') and host_machine.system() == 'linux'
# End-to-end test of --cli mode: runs an automation macro on a fixture
# file without a display server and checks the output. Linux-only for
# now: Windows keeps wx's entry point so there is no --cli there, and
# the macOS CI builds have not been vetted for running outside a bundle.
test('automation-cli',
find_program('python3', 'python'),
args: [
meson.current_source_dir() / 'automation' / 'tests' / 'cli' / 'run_test.py',
aegisub.full_path(),
meson.current_source_dir() / 'automation' / 'tests' / 'cli',
],
suite: 'automation',
depends: aegisub,
timeout: 300)
endif
Loading
Loading