diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84391c1..24168af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,11 +82,18 @@ jobs: run: | sudo apt-get update sudo apt-get install -y build-essential pkg-config nasm libass-dev fonts-dejavu-core + - name: Install Lua wrapper test tools + if: runner.os != 'macOS' || matrix.arch != 'x86_64' + run: python tools/ci/setup-lua.py - name: Configure run: meson setup build ${{ matrix.args }} - name: Build run: meson compile -C build + - name: Check Lua wrapper tests are enabled + if: runner.os != 'macOS' || matrix.arch != 'x86_64' + shell: bash + run: meson test -C build --list | grep -q aegisub-wrapper - name: Test run: meson test -C build --print-errorlogs - name: Check binary architecture and runtime dependencies diff --git a/examples/Aegisub/Inspector.moon b/examples/Aegisub/Inspector.moon index a362fef..acdd116 100644 --- a/examples/Aegisub/Inspector.moon +++ b/examples/Aegisub/Inspector.moon @@ -1,5 +1,5 @@ -- This library is unlicensed under CC0 -local requireffi, ffi, looseVersionCompare +local ffi, looseVersionCompare versionRecord = '0.7.2' haveDepCtrl, DependencyControl = pcall require, 'l0.DependencyControl' @@ -15,7 +15,6 @@ if haveDepCtrl feed: "https://raw.githubusercontent.com/TypesettingTools/SubInspector/master/DependencyControl.json", { { "ffi" } - { "requireffi.requireffi", version: "0.1.1" } } } ) @@ -34,11 +33,10 @@ if haveDepCtrl return true - ffi, requireffi = versionRecord\requireModules! + ffi = versionRecord\requireModules! else ffi = require 'ffi' - requireffi = require 'requireffi.requireffi' SIVersionCompat = 0x000501 versionComponents = ( version ) -> @@ -73,7 +71,29 @@ int si_calculateBounds( void*, SI_Rect*, const int32_t*, const uint32_t void si_cleanup( void* ); ]] ) -SubInspector, libraryPath = requireffi( 'SubInspector.Inspector.SubInspector' ) +loadLibrary = -> + filename = switch ffi.os + when 'Windows' then 'SubInspector.dll' + when 'OSX' then 'libSubInspector.dylib' + else 'libSubInspector.so' + + paths = { } + for template in package.path\gmatch '[^;]+' + template = template\gsub '\\', '/' + root = template\match('^(.-)%?%.lua$') or template\match('^(.-)%?%.moon$') + if root + table.insert paths, root .. 'SubInspector/Inspector/' .. filename + -- Package-manager installs can use the system's native library search path. + table.insert paths, 'SubInspector' + + errors = { 'Could not load the SubInspector library:' } + for path in *paths + success, library = pcall ffi.load, path + return library if success + table.insert errors, ' ' .. path .. ': ' .. tostring library + error table.concat errors, '\n' + +SubInspector = loadLibrary! log = ( message, ... ) -> aegisub.log 2, message .. '\n', ... @@ -198,7 +218,7 @@ addStyles = ( line, scriptText, seenStyles ) => class Inspector @version = versionRecord - new: ( subtitles = error( "You must provide the subtitles object." ), fcConfig = libraryPath .. "fonts.conf", fontDir = aegisub.decode_path( '?script/fonts' ), logFunc = log ) => + new: ( subtitles = error( "You must provide the subtitles object." ), fcConfig, fontDir = aegisub.decode_path( '?script/fonts' ), logFunc = log ) => success, message = looseVersionCompare SubInspector.si_getVersion! assert success, message diff --git a/tests/meson.build b/tests/meson.build index 961a04d..d9e6db3 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -2,3 +2,19 @@ render_test = executable('render-test', 'render.c', include_directories: include_directories('../src'), link_with: subinspector) test('render', render_test) + +if not meson.is_cross_build() + luajit = find_program('luajit', required: false) + moonc = find_program('moonc', required: false) + if luajit.found() and moonc.found() + wrapper = custom_target('Inspector.lua', + input: '../examples/Aegisub/Inspector.moon', + output: 'Inspector.lua', + command: [moonc, '-o', '@OUTPUT@', '@INPUT@']) + python = import('python').find_installation() + test('aegisub-wrapper', python, + args: [files('wrapper.py'), luajit.full_path(), wrapper, + subinspector.full_path(), files('wrapper.lua')], + depends: subinspector) + endif +endif diff --git a/tests/wrapper.lua b/tests/wrapper.lua new file mode 100644 index 0000000..2664a6d --- /dev/null +++ b/tests/wrapper.lua @@ -0,0 +1,123 @@ +local wrapper, include = arg[1], arg[2] +local ffi = require('ffi') + +local function load_wrapper(test_ffi, search_path, depctrl) + local registered + local env = setmetatable({ + package = {path = search_path}, + aegisub = { + decode_path = function() return include .. '/fonts' end, + video_size = function() return nil end, + log = function() end, + }, + require = function(name) + if name == 'ffi' then return test_ffi end + if name == 'l0.DependencyControl' and depctrl then + return function(record) + record.requireModules = function(self) + assert(#self[1] == 1 and self[1][1][1] == 'ffi') + return test_ffi + end + record.checkVersion = function() return true end + record.register = function(_, module) + registered = module + return module + end + return record + end + end + error('Unexpected dependency: ' .. name) + end, + }, {__index = _G}) + local module = setfenv(assert(loadfile(wrapper)), env)() + if depctrl then assert(registered == module) end + return module +end + +-- Exercise all platform names and path conventions without loading foreign +-- binaries. These cases also run without DependencyControl or requireffi. +local cases = { + {'Windows', 'C:\\Users\\Test Person\\include\\?.lua', + 'C:/Users/Test Person/include/SubInspector/Inspector/SubInspector.dll'}, + {'OSX', '/missing/?.lua;/Application Support/include/?.moon', + '/Application Support/include/SubInspector/Inspector/libSubInspector.dylib'}, + {'Linux', '/usr/share/aegisub/automation/include/?.lua', + '/usr/share/aegisub/automation/include/SubInspector/Inspector/libSubInspector.so'}, + {'Linux', '/missing/?.lua;/missing/?/init.lua', 'SubInspector'}, +} +for _, case in ipairs(cases) do + for _, depctrl in ipairs({false, true}) do + local calls = {} + load_wrapper({ + os = case[1], + cdef = function() end, + load = function(path) + calls[#calls + 1] = path + if path == case[3] then return {} end + error('library missing or incompatible') + end, + }, case[2], depctrl) + assert(calls[#calls] == case[3]) + for _, path in ipairs(calls) do + assert(not path:find('init.lua', 1, true)) + end + end +end + +local ok, message = pcall(load_wrapper, { + os = 'OSX', + cdef = function() end, + load = function() error('wrong architecture') end, +}, '/missing/?.lua', false) +assert(not ok) +assert(message:find('/missing/SubInspector/Inspector/libSubInspector.dylib', 1, true)) +assert(message:find('SubInspector: ', 1, true)) +assert(message:find('wrong architecture', 1, true)) + +-- Load the real staged library and exercise the wrapper-to-C interface, both +-- with and without DependencyControl registration. +local declared = false +for _, depctrl in ipairs({false, true}) do + local loaded_path, config + local test_ffi = setmetatable({ + cdef = function(definitions) + if not declared then ffi.cdef(definitions); declared = true end + end, + load = function(path) + local library = ffi.load(path) + loaded_path = path + return setmetatable({ + si_init = function(width, height, font_config, fonts) + config = font_config + return library.si_init(width, height, font_config, fonts) + end, + }, {__index = function(_, key) return library[key] end}) + end, + }, {__index = ffi}) + local Inspector = load_wrapper(test_ffi, include .. '/?.lua', depctrl) + local expected_root = include:gsub('\\', '/') .. '/SubInspector/Inspector/' + assert(loaded_path:find(expected_root, 1, true) == 1) + local subtitles = { + {class = 'info', key = 'ScriptType', value = 'v4.00+', raw = 'ScriptType: v4.00+'}, + {class = 'info', key = 'PlayResX', value = '640', raw = 'PlayResX: 640'}, + {class = 'info', key = 'PlayResY', value = '480', raw = 'PlayResY: 480'}, + {class = 'style', name = 'Default', raw = + 'Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,' .. + '0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1'}, + } + local inspector = Inspector(subtitles) + assert(config == nil and inspector.fcConfig == nil) + local shape = '{\\an7\\pos(100,100)\\p1}m 0 0 l 20 0 20 20 0 20' + local rects = assert(inspector:getBounds({{ + style = 'Default', text = shape, + raw = 'Dialogue: 0,0:00:00.00,0:00:02.00,Default,,0,0,0,,' .. shape, + }}, {0, 500})) + assert(#rects == 2) + assert(rects[1].x == 100 and rects[1].y == 100) + assert(rects[1].w == 20 and rects[1].h == 20 and rects[1].solid) + assert(rects[1].hash == rects[2].hash) + local configured = Inspector(subtitles, 'custom-fonts.conf') + assert(config == 'custom-fonts.conf' and configured.fcConfig == config) +end + +print('Library discovery, errors, DependencyControl registration, and wrapper rendering passed.') diff --git a/tests/wrapper.py b/tests/wrapper.py new file mode 100644 index 0000000..a48a388 --- /dev/null +++ b/tests/wrapper.py @@ -0,0 +1,16 @@ +"""Stage a normal Aegisub include directory and run the LuaJIT wrapper tests.""" + +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +luajit, wrapper, binary, tests = sys.argv[1:] +with tempfile.TemporaryDirectory(prefix='SubInspector wrapper ') as directory: + include = Path(directory) / 'include' + libraries = include / 'SubInspector' / 'Inspector' + libraries.mkdir(parents=True) + (include / 'fonts').mkdir() + shutil.copy2(binary, libraries / Path(binary).name) + subprocess.run([luajit, tests, wrapper, str(include)], check=True) diff --git a/tools/ci/setup-lua.py b/tools/ci/setup-lua.py new file mode 100644 index 0000000..d17d457 --- /dev/null +++ b/tools/ci/setup-lua.py @@ -0,0 +1,51 @@ +"""Install LuaJIT and MoonScript for the native Aegisub wrapper tests in CI.""" + +import hashlib +import os +import subprocess +import sys +import urllib.request +import zipfile +from pathlib import Path + + +def run(*args, **kwargs): + subprocess.run(args, check=True, **kwargs) + + +temporary = Path(os.environ['RUNNER_TEMP']) +paths = [] + +if sys.platform == 'linux': + run('sudo', 'apt-get', 'install', '-y', 'luajit', 'luarocks', 'liblua5.1-0-dev') + run('sudo', 'luarocks', '--lua-version=5.1', 'install', 'moonscript', '0.7.0') +elif sys.platform == 'darwin': + run('brew', 'install', 'luajit', 'luarocks') + luajit = subprocess.check_output(['brew', '--prefix', 'luajit'], text=True).strip() + tree = temporary / 'luarocks' + run('luarocks', '--lua-version=5.1', f'--lua-dir={luajit}', f'--tree={tree}', + 'install', 'moonscript', '0.7.0') + paths.append(tree / 'bin') +elif sys.platform == 'win32': + luajit = temporary / 'luajit' + run('git', 'clone', '--depth', '1', '--branch', 'v2.1', + 'https://github.com/LuaJIT/LuaJIT.git', luajit) + run('cmd', '/c', 'msvcbuild.bat', cwd=luajit / 'src') + paths.append(luajit / 'src') + + archive = temporary / 'moonscript.zip' + urllib.request.urlretrieve( + 'https://github.com/leafo/moonscript/releases/download/v0.7.0/' + 'moonscript-v0.7.0-windows-x86_64.zip', archive) + if hashlib.sha256(archive.read_bytes()).hexdigest() != ( + '36c41ae1faaff1423e100df667a498df61607470b0588663adc14e9db536eb17'): + sys.exit('MoonScript archive checksum mismatch') + with zipfile.ZipFile(archive) as compiler: + compiler.extractall(temporary / 'moonscript') + paths.append(temporary / 'moonscript') +else: + sys.exit(f'Unsupported CI platform: {sys.platform}') + +with open(os.environ['GITHUB_PATH'], 'a', encoding='utf-8') as github_path: + for path in paths: + github_path.write(f'{path}\n')