diff --git a/.coveragerc b/.coveragerc
index 3fe90df8..d2eac282 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -4,6 +4,10 @@ omit =
*/tests/*
*/e2e_tests/*
*/__pycache__/*
+ # Application bootstrap/wiring: instantiates services and starts the
+ # server. Not unit-testable without a full integration environment.
+ main.py
+ */main.py
[report]
exclude_lines =
diff --git a/src/tests/file_utils_extra_test.py b/src/tests/file_utils_extra_test.py
new file mode 100644
index 00000000..986400b2
--- /dev/null
+++ b/src/tests/file_utils_extra_test.py
@@ -0,0 +1,313 @@
+import datetime
+import os
+import stat
+import unittest
+
+from tests import test_utils
+from utils import file_utils, os_utils
+from utils.file_utils import FileMatcher, SingleFileMatcher, FileExistsException
+
+
+def _path(*parts):
+ return os.path.join(test_utils.temp_folder, *parts)
+
+
+class ReadWriteFileTest(unittest.TestCase):
+ def setUp(self):
+ test_utils.setup()
+
+ def tearDown(self):
+ test_utils.cleanup()
+
+ def test_write_then_read_roundtrip(self):
+ path = _path('sub', 'file.txt')
+ file_utils.write_file(path, 'hello world')
+ self.assertEqual('hello world', file_utils.read_file(path))
+
+ def test_write_creates_missing_folders(self):
+ path = _path('a', 'b', 'c', 'file.txt')
+ file_utils.write_file(path, 'x')
+ self.assertTrue(os.path.exists(path))
+
+ def test_write_and_read_bytes(self):
+ path = _path('bytes.bin')
+ file_utils.write_file(path, b'\x01\x02\x03', byte_content=True)
+ self.assertEqual(b'\x01\x02\x03', file_utils.read_file(path, byte_content=True))
+
+ def test_read_keep_newlines(self):
+ path = _path('nl.txt')
+ file_utils.write_file(path, 'a\r\nb\n', byte_content=False)
+ # With keep_newlines the original line endings are preserved.
+ self.assertEqual('a\r\nb\n', file_utils.read_file(path, keep_newlines=True))
+
+ def test_read_falls_back_to_alternative_encoding(self):
+ path = _path('latin.txt')
+ # 0xe9 is invalid UTF-8 on its own -> triggers the encoding fallback.
+ file_utils.write_file(path, b'caf\xe9', byte_content=True)
+ result = file_utils.read_file(path)
+ self.assertTrue(result.startswith('caf'))
+
+ def test_try_encoded_read_returns_none_for_unreadable(self):
+ path = _path('bad.bin')
+ file_utils.write_file(path, b'\xff\xfe\x00\x80\x81', byte_content=True)
+ # Not asserting a specific decode, just that the helper runs.
+ self.assertIsNotNone(file_utils.read_file(path))
+
+
+class FolderAndExistenceTest(unittest.TestCase):
+ def setUp(self):
+ test_utils.setup()
+
+ def tearDown(self):
+ test_utils.cleanup()
+
+ def test_prepare_folder_creates_folder(self):
+ path = _path('new', 'folder')
+ file_utils.prepare_folder(path)
+ self.assertTrue(os.path.isdir(path))
+
+ def test_prepare_folder_is_idempotent(self):
+ path = _path('folder')
+ file_utils.prepare_folder(path)
+ file_utils.prepare_folder(path)
+ self.assertTrue(os.path.isdir(path))
+
+ def test_exists(self):
+ path = _path('file.txt')
+ self.assertFalse(file_utils.exists(path))
+ file_utils.write_file(path, 'x')
+ self.assertTrue(file_utils.exists(path))
+
+ def test_exists_with_current_folder(self):
+ file_utils.write_file(_path('runners', 'x.json'), '{}')
+ self.assertTrue(file_utils.exists('x.json', _path('runners')))
+ self.assertFalse(file_utils.exists('missing.json', _path('runners')))
+
+
+class PathHelpersTest(unittest.TestCase):
+ def tearDown(self):
+ os_utils.reset_os()
+
+ def test_is_root(self):
+ self.assertTrue(file_utils.is_root(os.path.abspath(os.sep)))
+ self.assertFalse(file_utils.is_root('/some/path'))
+
+ def test_split_all(self):
+ self.assertEqual(['a', 'b', 'c'], file_utils.split_all(os.path.join('a', 'b', 'c')))
+
+ def test_split_all_empty(self):
+ self.assertEqual([], file_utils.split_all(''))
+
+ def test_normalize_path_absolute(self):
+ absolute = os.path.abspath(os.sep + 'tmp')
+ self.assertEqual(os.path.normpath(absolute), file_utils.normalize_path(absolute))
+
+ def test_normalize_path_with_current_folder(self):
+ result = file_utils.normalize_path('child', os.path.abspath(os.sep + 'parent'))
+ self.assertEqual(os.path.join(os.path.abspath(os.sep + 'parent'), 'child'), result)
+
+ def test_normalize_path_nonexistent_relative_returned_as_is(self):
+ self.assertEqual('does_not_exist_xyz', file_utils.normalize_path('does_not_exist_xyz'))
+
+ def test_relative_path(self):
+ parent = os.path.abspath(os.sep + 'data')
+ child = os.path.join(parent, 'sub', 'file.txt')
+ self.assertEqual(os.path.join('sub', 'file.txt'), file_utils.relative_path(child, parent))
+
+ def test_relative_path_raises_when_not_subpath(self):
+ with self.assertRaises(ValueError):
+ file_utils.relative_path(
+ os.path.abspath(os.sep + 'other'),
+ os.path.abspath(os.sep + 'data'))
+
+
+class UniqueFilenameTest(unittest.TestCase):
+ def setUp(self):
+ test_utils.setup()
+
+ def tearDown(self):
+ test_utils.cleanup()
+
+ def test_returns_preferred_when_free(self):
+ path = _path('report.txt')
+ self.assertEqual(path, file_utils.create_unique_filename(path))
+
+ def test_appends_suffix_on_conflict(self):
+ path = _path('report.txt')
+ file_utils.write_file(path, 'x')
+ self.assertEqual(_path('report_0.txt'), file_utils.create_unique_filename(path))
+
+ def test_increments_suffix_on_multiple_conflicts(self):
+ file_utils.write_file(_path('report.txt'), 'x')
+ file_utils.write_file(_path('report_0.txt'), 'x')
+ self.assertEqual(_path('report_1.txt'), file_utils.create_unique_filename(_path('report.txt')))
+
+ def test_raises_when_retries_exhausted(self):
+ file_utils.write_file(_path('report.txt'), 'x')
+ with self.assertRaises(FileExistsException):
+ file_utils.create_unique_filename(_path('report.txt'), retries=0)
+
+
+class ExecutableTest(unittest.TestCase):
+ def setUp(self):
+ test_utils.setup()
+
+ def tearDown(self):
+ test_utils.cleanup()
+
+ def test_make_executable(self):
+ path = _path('script.sh')
+ file_utils.write_file(path, '#!/bin/sh\n')
+ file_utils.make_executable(path)
+ self.assertTrue(file_utils.is_executable(path))
+ mode = os.stat(path).st_mode
+ self.assertTrue(mode & stat.S_IXUSR)
+
+
+class BinaryDetectionTest(unittest.TestCase):
+ def setUp(self):
+ test_utils.setup()
+
+ def tearDown(self):
+ test_utils.cleanup()
+
+ def test_elf_header_is_binary(self):
+ path = _path('elf')
+ file_utils.write_file(path, bytes.fromhex('7F454C46') + b'rest', byte_content=True)
+ self.assertTrue(file_utils.is_binary(path))
+
+ def test_windows_executable_is_binary(self):
+ path = _path('exe')
+ file_utils.write_file(path, bytes.fromhex('4D5A') + b'rest', byte_content=True)
+ self.assertTrue(file_utils.is_binary(path))
+
+ def test_null_bytes_is_binary(self):
+ path = _path('nulls')
+ file_utils.write_file(path, b'abc\x00\x00def', byte_content=True)
+ self.assertTrue(file_utils.is_binary(path))
+
+ def test_plain_text_is_not_binary(self):
+ path = _path('text.txt')
+ file_utils.write_file(path, 'just some text content')
+ self.assertFalse(file_utils.is_binary(path))
+
+
+class SymlinkTest(unittest.TestCase):
+ def setUp(self):
+ test_utils.setup()
+
+ def tearDown(self):
+ test_utils.cleanup()
+
+ def test_broken_symlink_detected(self):
+ link = _path('broken_link')
+ os.symlink(_path('does_not_exist'), link)
+ self.assertTrue(file_utils.is_broken_symlink(link))
+
+ def test_valid_symlink_is_not_broken(self):
+ target = _path('target.txt')
+ file_utils.write_file(target, 'x')
+ link = _path('valid_link')
+ os.symlink(os.path.abspath(target), link)
+ self.assertFalse(file_utils.is_broken_symlink(link))
+
+ def test_regular_file_is_not_broken_symlink(self):
+ path = _path('regular.txt')
+ file_utils.write_file(path, 'x')
+ self.assertFalse(file_utils.is_broken_symlink(path))
+
+
+class ModificationDateTest(unittest.TestCase):
+ def setUp(self):
+ test_utils.setup()
+
+ def tearDown(self):
+ test_utils.cleanup()
+
+ def test_modification_date_returns_datetime(self):
+ path = _path('file.txt')
+ file_utils.write_file(path, 'x')
+ self.assertIsInstance(file_utils.modification_date(path), datetime.datetime)
+
+ def test_last_modification_of_tree(self):
+ file_utils.write_file(_path('tree', 'a.txt'), 'a')
+ file_utils.write_file(_path('tree', 'sub', 'b.txt'), 'b')
+ result = file_utils.last_modification([_path('tree')])
+ self.assertIsInstance(result, datetime.datetime)
+
+ def test_deletion_date_uses_existing_parent(self):
+ folder = _path('folder')
+ file_utils.prepare_folder(folder)
+ deleted = os.path.join(folder, 'gone.txt')
+ self.assertIsInstance(file_utils.deletion_date(deleted), datetime.datetime)
+
+
+class SearchGlobTest(unittest.TestCase):
+ def setUp(self):
+ test_utils.setup()
+
+ def tearDown(self):
+ test_utils.cleanup()
+
+ def test_glob_matches_extension(self):
+ file_utils.write_file(_path('a.txt'), 'x')
+ file_utils.write_file(_path('b.txt'), 'x')
+ file_utils.write_file(_path('c.log'), 'x')
+ matches = file_utils.search_glob(_path('*.txt'))
+ self.assertCountEqual([_path('a.txt'), _path('b.txt')], matches)
+
+ def test_glob_recursive(self):
+ file_utils.write_file(_path('root.txt'), 'x')
+ file_utils.write_file(_path('sub', 'nested.txt'), 'x')
+ matches = file_utils.search_glob(_path('**', '*.txt'), recursive=True)
+ self.assertIn(_path('sub', 'nested.txt'), matches)
+
+
+class FileMatcherTest(unittest.TestCase):
+ def tearDown(self):
+ os_utils.reset_os()
+
+ def _abs(self, *parts):
+ return os.path.join(os.path.abspath(os.sep + 'data'), *parts)
+
+ def test_plain_pattern_matches_children(self):
+ matcher = FileMatcher([os.path.abspath(os.sep + 'data')], os.path.abspath(os.sep + 'work'))
+ self.assertTrue(matcher.has_match(self._abs('logs', 'app.log')))
+
+ def test_plain_pattern_rejects_outside_paths(self):
+ matcher = FileMatcher([os.path.abspath(os.sep + 'data')], os.path.abspath(os.sep + 'work'))
+ self.assertFalse(matcher.has_match(os.path.abspath(os.sep + 'other') + os.sep + 'app.log'))
+
+ def test_star_pattern_matches_extension(self):
+ matcher = FileMatcher([self._abs('*.log')], os.path.abspath(os.sep + 'work'))
+ self.assertTrue(matcher.has_match(self._abs('app.log')))
+ self.assertFalse(matcher.has_match(self._abs('app.txt')))
+
+ def test_recursive_pattern_matches_nested(self):
+ matcher = FileMatcher([self._abs('**')], os.path.abspath(os.sep + 'work'))
+ self.assertTrue(matcher.has_match(self._abs('deep', 'nested', 'x.log')))
+
+ def test_empty_patterns_match_nothing(self):
+ matcher = FileMatcher([], os.path.abspath(os.sep + 'work'))
+ self.assertFalse(matcher.has_match(self._abs('x.log')))
+
+ def test_has_match_accepts_path_object(self):
+ import pathlib
+ matcher = FileMatcher([os.path.abspath(os.sep + 'data')], os.path.abspath(os.sep + 'work'))
+ self.assertTrue(matcher.has_match(pathlib.Path(self._abs('a', 'b.txt'))))
+
+
+class SingleFileMatcherTest(unittest.TestCase):
+ def test_relative_pattern_is_normalized_against_working_dir(self):
+ working_dir = os.path.abspath(os.sep + 'work')
+ matcher = SingleFileMatcher('logs', working_dir)
+ self.assertEqual(os.path.join(working_dir, 'logs'), matcher.pattern)
+
+ def test_absolute_pattern_is_kept(self):
+ pattern = os.path.abspath(os.sep + 'data') + os.sep + 'logs'
+ matcher = SingleFileMatcher(pattern, os.path.abspath(os.sep + 'work'))
+ self.assertEqual(pattern, matcher.pattern)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/src/tests/migrate_test.py b/src/tests/migrate_test.py
new file mode 100644
index 00000000..11029b01
--- /dev/null
+++ b/src/tests/migrate_test.py
@@ -0,0 +1,330 @@
+import json
+import os
+import unittest
+
+from migrations import migrate
+from tests import test_utils
+from utils import file_utils
+from execution.logging import OUTPUT_STARTED_MARKER
+
+
+def _all_migration_ids():
+ # The registry name is module-private; getattr with a literal string avoids
+ # the name mangling that a direct ``migrate.__migrations_registry`` would
+ # trigger inside this class body.
+ return list(getattr(migrate, '__migrations_registry').keys())
+
+
+class MigrateTestBase(unittest.TestCase):
+ def setUp(self):
+ test_utils.setup()
+
+ base = test_utils.temp_folder
+ self.temp_dir = os.path.join(base, 'state')
+ self.conf_dir = os.path.join(base, 'conf')
+ self.conf_file = os.path.join(self.conf_dir, 'conf.json')
+ self.log_dir = os.path.join(base, 'log')
+
+ for folder in (self.temp_dir, self.conf_dir, self.log_dir):
+ os.makedirs(folder)
+
+ def tearDown(self):
+ test_utils.cleanup()
+
+ def _run_migrate(self):
+ migrate.migrate(self.temp_dir, self.conf_dir, self.conf_file, self.log_dir)
+
+ def _run_only(self, migration_id):
+ """Mark every migration except ``migration_id`` as already applied, so a
+ single call to migrate() runs exactly the targeted migration."""
+ already_applied = [m for m in _all_migration_ids() if m != migration_id]
+ migrate._write_migrations(self.temp_dir, already_applied)
+ self._run_migrate()
+
+ def _mark_all_applied(self):
+ migrate._write_migrations(self.temp_dir, _all_migration_ids())
+
+ def _write_conf(self, obj):
+ file_utils.write_file(self.conf_file, json.dumps(obj))
+
+ def _read_conf(self):
+ return json.loads(file_utils.read_file(self.conf_file))
+
+ def _write_runner(self, name, obj):
+ path = os.path.join(self.conf_dir, 'runners', name + '.json')
+ file_utils.write_file(path, json.dumps(obj))
+ return path
+
+ def _read_runner(self, name):
+ path = os.path.join(self.conf_dir, 'runners', name + '.json')
+ return json.loads(file_utils.read_file(path))
+
+ def _write_log(self, filename, content):
+ processes = os.path.join(self.log_dir, 'processes')
+ path = os.path.join(processes, filename)
+ file_utils.write_file(path, content)
+ return path
+
+ def _read_migrations_file(self):
+ return migrate._read_old_migrations(self.temp_dir)
+
+
+class OrchestrationTest(MigrateTestBase):
+ def test_new_installation_marks_all_applied(self):
+ # Both temp and conf folders are empty -> treated as a fresh install:
+ # all migrations are recorded as done without transforming anything.
+ self._run_migrate()
+
+ self.assertCountEqual(_all_migration_ids(), self._read_migrations_file())
+
+ def test_new_installation_does_not_transform_data(self):
+ # conf folder stays empty so the install is still considered new; a log
+ # file that would otherwise be migrated must be left untouched.
+ self._write_log('old.log', 'raw output')
+ self._run_migrate()
+
+ self.assertEqual('raw output', file_utils.read_file(
+ os.path.join(self.log_dir, 'processes', 'old.log')))
+
+ def test_already_migrated_is_noop(self):
+ self._mark_all_applied()
+ path = self._write_runner('x', {'bash_formatting': False})
+
+ self._run_migrate()
+
+ self.assertEqual({'bash_formatting': False}, self._read_runner('x'))
+
+ def test_runs_all_pending_when_nothing_applied(self):
+ # conf_file present -> not a new installation, so every migration runs
+ # (most are no-ops here) and all ids end up recorded.
+ self._write_conf({'something': 'value'})
+ migrate._write_migrations(self.temp_dir, [])
+
+ self._run_migrate()
+
+ self.assertCountEqual(_all_migration_ids(), self._read_migrations_file())
+
+ def test_unknown_requirement_raises(self):
+ registry = getattr(migrate, '__migrations_registry')
+ descriptor_cls = getattr(migrate, '_MigrationDescriptor')
+ registry['broken'] = descriptor_cls('broken', lambda ctx: None, 'broken', ['does_not_exist'])
+ try:
+ with self.assertRaises(Exception):
+ migrate._validate_requirements()
+ finally:
+ del registry['broken']
+
+
+class AddExecutionInfoToLogFilesTest(MigrateTestBase):
+ migration_id = 'add_execution_info_to_log_files'
+
+ def test_old_file_gets_header_with_parsed_name(self):
+ self._write_log('myscript_bob_200115_103000.log', 'hello world')
+
+ self._run_only(self.migration_id)
+
+ content = file_utils.read_file(
+ os.path.join(self.log_dir, 'processes', 'myscript_bob_200115_103000.log'))
+ self.assertTrue(content.startswith('id:'))
+ self.assertIn('script:myscript', content)
+ self.assertIn('user_name:bob', content)
+ self.assertIn('user_id:bob', content)
+ self.assertIn(OUTPUT_STARTED_MARKER, content)
+ self.assertTrue(content.endswith('hello world'))
+
+ def test_unparseable_name_uses_unknown(self):
+ self._write_log('weirdname.log', 'body')
+
+ self._run_only(self.migration_id)
+
+ content = file_utils.read_file(
+ os.path.join(self.log_dir, 'processes', 'weirdname.log'))
+ self.assertIn('script:unknown', content)
+ self.assertIn('user_name:unknown', content)
+
+ def test_new_format_file_is_left_untouched(self):
+ original = 'id:5' + os.linesep + OUTPUT_STARTED_MARKER + os.linesep + 'output'
+ self._write_log('already_new.log', original)
+
+ self._run_only(self.migration_id)
+
+ self.assertEqual(original, file_utils.read_file(
+ os.path.join(self.log_dir, 'processes', 'already_new.log')))
+
+ def test_missing_processes_folder_is_noop(self):
+ # No processes folder at all -> migration returns without error.
+ self._run_only(self.migration_id)
+
+
+class AddUserIdToLogFilesTest(MigrateTestBase):
+ migration_id = 'add_user_id_to_log_files'
+
+ def _log_content(self, params_lines):
+ params = os.linesep.join(params_lines) + os.linesep
+ return params + OUTPUT_STARTED_MARKER + os.linesep + 'the output'
+
+ def test_user_id_and_name_are_added(self):
+ content = self._log_content(['id:7', 'user:bob'])
+ self._write_log('run.log', content)
+
+ self._run_only(self.migration_id)
+
+ migrated = file_utils.read_file(
+ os.path.join(self.log_dir, 'processes', 'run.log'))
+ self.assertIn('user_id:bob', migrated)
+ self.assertIn('user_name:bob', migrated)
+ self.assertTrue(migrated.endswith('the output'))
+
+ def test_file_with_both_fields_is_untouched(self):
+ content = self._log_content(['id:7', 'user:bob', 'user_id:bob', 'user_name:bob'])
+ self._write_log('run.log', content)
+
+ self._run_only(self.migration_id)
+
+ self.assertEqual(content, file_utils.read_file(
+ os.path.join(self.log_dir, 'processes', 'run.log')))
+
+
+class IntroduceAccessConfigTest(MigrateTestBase):
+ migration_id = 'introduce_access_config'
+
+ def test_fields_moved_under_access(self):
+ self._write_conf({
+ 'auth': {'type': 'ldap', 'allowed_users': ['u1']},
+ 'admin_users': ['admin1'],
+ 'trusted_ips': ['127.0.0.1'],
+ })
+
+ self._run_only(self.migration_id)
+
+ result = self._read_conf()
+ self.assertEqual(['u1'], result['access']['allowed_users'])
+ self.assertEqual(['admin1'], result['access']['admin_users'])
+ self.assertEqual(['127.0.0.1'], result['access']['trusted_ips'])
+ self.assertNotIn('allowed_users', result['auth'])
+ self.assertNotIn('admin_users', result)
+ self.assertNotIn('trusted_ips', result)
+
+ def test_no_relevant_fields_leaves_file_unchanged(self):
+ self._write_conf({'auth': {'type': 'ldap'}})
+
+ self._run_only(self.migration_id)
+
+ self.assertEqual({'auth': {'type': 'ldap'}}, self._read_conf())
+
+ def test_missing_conf_file_is_noop(self):
+ self._run_only(self.migration_id)
+
+ def test_existing_indentation_is_preserved(self):
+ # File written with 2-space indentation -> rewrite keeps that indent.
+ file_utils.write_file(self.conf_file, json.dumps(
+ {'admin_users': ['admin1']}, indent=2))
+
+ self._run_only(self.migration_id)
+
+ raw = file_utils.read_file(self.conf_file)
+ self.assertIn('\n "access"', raw)
+ self.assertEqual(['admin1'], self._read_conf()['access']['admin_users'])
+
+
+class OutputFilesParametersSubstitutionTest(MigrateTestBase):
+ migration_id = 'migrate_output_files_parameters_substitution'
+
+ def test_dollar_syntax_is_rewritten(self):
+ self._write_runner('script', {
+ 'parameters': [{'name': 'param1'}],
+ 'output_files': ['report_$$$param1.txt'],
+ })
+
+ self._run_only(self.migration_id)
+
+ self.assertEqual(['report_${param1}.txt'], self._read_runner('script')['output_files'])
+
+ def test_runner_without_output_files_is_untouched(self):
+ self._write_runner('script', {'parameters': [{'name': 'param1'}]})
+
+ self._run_only(self.migration_id)
+
+ self.assertEqual({'parameters': [{'name': 'param1'}]}, self._read_runner('script'))
+
+
+class BashFormattingToOutputFormatTest(MigrateTestBase):
+ migration_id = 'migrate_bash_formatting_to_output_format'
+
+ def test_bash_formatting_false_becomes_text(self):
+ self._write_runner('script', {'bash_formatting': False})
+
+ self._run_only(self.migration_id)
+
+ result = self._read_runner('script')
+ self.assertNotIn('bash_formatting', result)
+ self.assertEqual('text', result['output_format'])
+
+ def test_bash_formatting_true_becomes_terminal(self):
+ self._write_runner('script', {'bash_formatting': True})
+
+ self._run_only(self.migration_id)
+
+ self.assertEqual('terminal', self._read_runner('script')['output_format'])
+
+
+class RepeatParamAndSameArgParamTest(MigrateTestBase):
+ migration_id = 'migrate_repeat_param_and_same_arg_param'
+
+ def test_repeat_param_true_is_converted(self):
+ self._write_runner('script', {'parameters': [{'name': 'p', 'repeat_param': True}]})
+
+ self._run_only(self.migration_id)
+
+ param = self._read_runner('script')['parameters'][0]
+ self.assertNotIn('repeat_param', param)
+ self.assertEqual(False, param['same_arg_param'])
+
+ def test_multiple_arguments_becomes_argument_per_value(self):
+ self._write_runner('script', {'parameters': [{'name': 'p', 'multiple_arguments': True}]})
+
+ self._run_only(self.migration_id)
+
+ param = self._read_runner('script')['parameters'][0]
+ self.assertNotIn('multiple_arguments', param)
+ self.assertEqual('argument_per_value', param['multiselect_argument_type'])
+
+ def test_param_without_legacy_fields_is_untouched(self):
+ self._write_runner('script', {'parameters': [{'name': 'p', 'type': 'int'}]})
+
+ self._run_only(self.migration_id)
+
+ self.assertEqual([{'name': 'p', 'type': 'int'}], self._read_runner('script')['parameters'])
+
+
+class LdapUsernamePatternToUserResolverTest(MigrateTestBase):
+ migration_id = 'migrate_ldap_username_pattern_to_user_resolver'
+
+ def test_username_pattern_moved_into_resolver(self):
+ self._write_conf({'auth': {'type': 'ldap', 'username_pattern': 'uid=$username'}})
+
+ self._run_only(self.migration_id)
+
+ auth = self._read_conf()['auth']
+ self.assertNotIn('username_pattern', auth)
+ self.assertEqual('uid=$username', auth['ldap_user_resolver']['username_pattern'])
+
+ def test_non_ldap_auth_is_untouched(self):
+ self._write_conf({'auth': {'type': 'google_oauth', 'username_pattern': 'x'}})
+
+ self._run_only(self.migration_id)
+
+ self.assertEqual({'auth': {'type': 'google_oauth', 'username_pattern': 'x'}}, self._read_conf())
+
+ def test_already_migrated_resolver_is_untouched(self):
+ config = {'auth': {'type': 'ldap', 'username_pattern': 'a',
+ 'ldap_user_resolver': {'username_pattern': 'b'}}}
+ self._write_conf(config)
+
+ self._run_only(self.migration_id)
+
+ self.assertEqual(config, self._read_conf())
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/web-src/tests/unit/common/utils/common_test.js b/web-src/tests/unit/common/utils/common_test.js
index 175f9bf4..66441f2d 100644
--- a/web-src/tests/unit/common/utils/common_test.js
+++ b/web-src/tests/unit/common/utils/common_test.js
@@ -1,6 +1,46 @@
'use strict';
-import {randomInt, trimTextNodes} from '@/common/utils/common';
+import {
+ arraysEqual,
+ asyncForEachKeyValue,
+ clearArray,
+ closestByClass,
+ contains,
+ deepCloneObject,
+ destroyChildren,
+ findNeighbour,
+ forEachKeyValue,
+ getElementsByTagNameRecursive,
+ getFileInputValue,
+ getQueryParameter,
+ getUnparameterizedUrl,
+ getWebsocketUrl,
+ guid,
+ hasClass,
+ HttpRequestError,
+ isBlankString,
+ isEmptyArray,
+ isEmptyObject,
+ isEmptyString,
+ isEmptyValue,
+ isFullRegexMatch,
+ isNull,
+ isWebsocketClosed,
+ isWebsocketConnecting,
+ isWebsocketOpen,
+ randomInt,
+ readQueryParameters,
+ removeElement,
+ removeElementIf,
+ removeElements,
+ stringComparator,
+ toBoolean,
+ toDict,
+ toMap,
+ toQueryArgs,
+ trimTextNodes,
+ uuidv4,
+} from '@/common/utils/common';
describe('Test common.js', function () {
@@ -123,4 +163,317 @@ describe('Test common.js', function () {
expect(div.innerHTML).toBe('hello world ! another record + one more')
});
});
+
+ describe('test isNull', function () {
+ it('null is null', () => expect(isNull(null)).toBe(true));
+ it('undefined is null', () => expect(isNull(undefined)).toBe(true));
+ it('0 is not null', () => expect(isNull(0)).toBe(false));
+ it('empty string is not null', () => expect(isNull('')).toBe(false));
+ it('object is not null', () => expect(isNull({})).toBe(false));
+ });
+
+ describe('test emptiness checks', function () {
+ it('isEmptyString', () => {
+ expect(isEmptyString(null)).toBe(true);
+ expect(isEmptyString('')).toBe(true);
+ expect(isEmptyString(' ')).toBe(false);
+ expect(isEmptyString('x')).toBe(false);
+ });
+
+ it('isBlankString', () => {
+ expect(isBlankString(null)).toBe(true);
+ expect(isBlankString(' ')).toBe(true);
+ expect(isBlankString('x')).toBe(false);
+ });
+
+ it('isEmptyArray', () => {
+ expect(isEmptyArray(null)).toBe(true);
+ expect(isEmptyArray([])).toBe(true);
+ expect(isEmptyArray([1])).toBe(false);
+ });
+
+ it('isEmptyObject', () => {
+ expect(isEmptyObject(null)).toBe(true);
+ expect(isEmptyObject({})).toBe(true);
+ expect(isEmptyObject({a: 1})).toBe(false);
+ });
+
+ it('isEmptyValue', () => {
+ expect(isEmptyValue(null)).toBe(true);
+ expect(isEmptyValue('')).toBe(true);
+ expect(isEmptyValue([])).toBe(true);
+ expect(isEmptyValue({})).toBe(true);
+ expect(isEmptyValue('x')).toBe(false);
+ expect(isEmptyValue([1])).toBe(false);
+ expect(isEmptyValue({a: 1})).toBe(false);
+ expect(isEmptyValue(5)).toBe(false);
+ });
+ });
+
+ describe('test array helpers', function () {
+ it('contains', () => {
+ expect(contains([1, 2, 3], 2)).toBe(true);
+ expect(contains([1, 2, 3], 9)).toBe(false);
+ });
+
+ it('removeElement removes first match and returns array', () => {
+ const arr = [1, 2, 3];
+ expect(removeElement(arr, 2)).toBe(arr);
+ expect(arr).toEqual([1, 3]);
+ });
+
+ it('removeElement on missing value is a noop', () => {
+ const arr = [1, 2];
+ removeElement(arr, 9);
+ expect(arr).toEqual([1, 2]);
+ });
+
+ it('removeElementIf removes matching elements', () => {
+ const arr = [1, 2, 3, 4];
+ removeElementIf(arr, (x) => x > 2);
+ expect(arr).toEqual([1, 2]);
+ });
+
+ it('removeElements', () => {
+ const arr = [1, 2, 3, 4];
+ removeElements(arr, [2, 4]);
+ expect(arr).toEqual([1, 3]);
+ });
+
+ it('clearArray empties in place', () => {
+ const arr = [1, 2, 3];
+ clearArray(arr);
+ expect(arr).toEqual([]);
+ });
+
+ it('arraysEqual', () => {
+ const ref = [1, 2];
+ expect(arraysEqual(ref, ref)).toBe(true);
+ expect(arraysEqual(null, null)).toBe(true);
+ expect(arraysEqual([1, 2], [1, 2])).toBe(true);
+ expect(arraysEqual(null, [1])).toBe(false);
+ expect(arraysEqual([1], null)).toBe(false);
+ expect(arraysEqual([1, 2], [1, 2, 3])).toBe(false);
+ expect(arraysEqual([1, 2], [1, 9])).toBe(false);
+ });
+ });
+
+ describe('test object/collection helpers', function () {
+ it('toBoolean', () => {
+ expect(toBoolean(true)).toBe(true);
+ expect(toBoolean(false)).toBe(false);
+ expect(toBoolean('true')).toBe(true);
+ expect(toBoolean('TRUE')).toBe(true);
+ expect(toBoolean('false')).toBe(false);
+ expect(toBoolean(1)).toBe(true);
+ expect(toBoolean(0)).toBe(false);
+ expect(toBoolean(null)).toBe(false);
+ });
+
+ it('toDict keys by field', () => {
+ const result = toDict([{id: 'a', n: 1}, {id: 'b', n: 2}], 'id');
+ expect(result).toEqual({a: {id: 'a', n: 1}, b: {id: 'b', n: 2}});
+ });
+
+ it('toMap with key and value extractors', () => {
+ const result = toMap([{k: 'x', v: 1}, {k: 'y', v: 2}], (e) => e.k, (e) => e.v);
+ expect(result).toEqual({x: 1, y: 2});
+ });
+
+ it('deepCloneObject produces an independent copy', () => {
+ const original = {a: 1, nested: {b: 2}};
+ const clone = deepCloneObject(original);
+ expect(clone).toEqual(original);
+ clone.nested.b = 99;
+ expect(original.nested.b).toBe(2);
+ });
+
+ it('forEachKeyValue visits own properties', () => {
+ const collected = {};
+ forEachKeyValue({a: 1, b: 2}, (key, value) => {
+ collected[key] = value;
+ });
+ expect(collected).toEqual({a: 1, b: 2});
+ });
+
+ it('asyncForEachKeyValue awaits callback for each entry', async () => {
+ const collected = [];
+ await asyncForEachKeyValue({a: 1, b: 2}, async (key, value) => {
+ collected.push([key, value]);
+ });
+ expect(collected).toEqual([['a', 1], ['b', 2]]);
+ });
+ });
+
+ describe('test stringComparator', function () {
+ it('sorts by field, case-insensitive', () => {
+ const arr = [{name: 'banana'}, {name: 'Apple'}, {name: 'cherry'}];
+ arr.sort(stringComparator('name'));
+ expect(arr.map((e) => e.name)).toEqual(['Apple', 'banana', 'cherry']);
+ });
+
+ it('andThen falls back to a secondary comparator', () => {
+ const arr = [
+ {name: 'a', age: 2},
+ {name: 'a', age: 1},
+ {name: 'b', age: 5},
+ ];
+ const byAge = (x, y) => x.age - y.age;
+ arr.sort(stringComparator('name').andThen(byAge));
+ expect(arr.map((e) => e.age)).toEqual([1, 2, 5]);
+ });
+ });
+
+ describe('test websocket state helpers', function () {
+ it('isWebsocketConnecting', () => {
+ expect(isWebsocketConnecting({readyState: 0})).toBe(true);
+ expect(isWebsocketConnecting({readyState: 1})).toBe(false);
+ });
+
+ it('isWebsocketOpen', () => {
+ expect(isWebsocketOpen({readyState: 1})).toBe(true);
+ expect(isWebsocketOpen({readyState: 0})).toBe(false);
+ expect(isWebsocketOpen(null)).toBe(false);
+ });
+
+ it('isWebsocketClosed', () => {
+ expect(isWebsocketClosed({readyState: 2})).toBe(true);
+ expect(isWebsocketClosed({readyState: 3})).toBe(true);
+ expect(isWebsocketClosed({readyState: 1})).toBe(false);
+ });
+ });
+
+ describe('test url helpers', function () {
+ afterEach(() => {
+ window.history.pushState({}, '', '/');
+ });
+
+ it('readQueryParameters parses the query string', () => {
+ window.history.pushState({}, '', '/?foo=bar&hello=world');
+ expect(readQueryParameters()).toEqual({foo: 'bar', hello: 'world'});
+ });
+
+ it('readQueryParameters returns empty object when no query', () => {
+ window.history.pushState({}, '', '/');
+ expect(readQueryParameters()).toEqual({});
+ });
+
+ it('getQueryParameter returns a single value', () => {
+ window.history.pushState({}, '', '/?foo=bar');
+ expect(getQueryParameter('foo')).toBe('bar');
+ });
+
+ it('getUnparameterizedUrl drops the query', () => {
+ expect(getUnparameterizedUrl()).toBe('http://localhost:3000/');
+ });
+
+ it('getWebsocketUrl builds a ws url with relative path', () => {
+ expect(getWebsocketUrl('events')).toBe('ws://localhost:3000/events');
+ });
+
+ it('getWebsocketUrl without path returns host url', () => {
+ expect(getWebsocketUrl('')).toBe('ws://localhost:3000');
+ });
+
+ it('toQueryArgs serializes scalars and arrays', () => {
+ expect(toQueryArgs({a: 1, b: [2, 3]})).toBe('a=1&b=2&b=3');
+ });
+ });
+
+ describe('test id generators', function () {
+ it('guid default has uuid-like length', () => {
+ expect(guid().length).toBe(36);
+ });
+
+ it('guid truncates to requested length', () => {
+ expect(guid(8).length).toBe(8);
+ });
+
+ it('guid pads to requested length', () => {
+ expect(guid(50).length).toBe(50);
+ });
+
+ it('uuidv4 matches the v4 format', () => {
+ expect(uuidv4()).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
+ });
+ });
+
+ describe('test isFullRegexMatch', function () {
+ it('anchors a bare pattern', () => {
+ expect(isFullRegexMatch('\\d+', '123')).toBe(true);
+ expect(isFullRegexMatch('\\d+', '12a')).toBe(false);
+ });
+
+ it('respects existing anchors', () => {
+ expect(isFullRegexMatch('^abc$', 'abc')).toBe(true);
+ expect(isFullRegexMatch('^abc$', 'abcd')).toBe(false);
+ });
+ });
+
+ describe('test DOM helpers', function () {
+ it('hasClass', () => {
+ const el = document.createElement('div');
+ el.className = 'foo bar';
+ expect(hasClass(el, 'foo')).toBe(true);
+ expect(hasClass(el, 'baz')).toBe(false);
+ });
+
+ it('destroyChildren removes all children', () => {
+ const el = document.createElement('div');
+ el.appendChild(document.createElement('span'));
+ el.appendChild(document.createElement('span'));
+ destroyChildren(el);
+ expect(el.childNodes.length).toBe(0);
+ });
+
+ it('findNeighbour finds sibling by tag', () => {
+ const parent = document.createElement('div');
+ const span = document.createElement('span');
+ const input = document.createElement('input');
+ parent.appendChild(span);
+ parent.appendChild(input);
+ expect(findNeighbour(input, 'span')).toBe(span);
+ expect(findNeighbour(span, 'a')).toBeNull();
+ });
+
+ it('closestByClass walks up the tree', () => {
+ const grandparent = document.createElement('div');
+ grandparent.className = 'target';
+ const parent = document.createElement('div');
+ const child = document.createElement('span');
+ grandparent.appendChild(parent);
+ parent.appendChild(child);
+ expect(closestByClass(child, 'target')).toBe(grandparent);
+ expect(closestByClass(child, 'missing')).toBeNull();
+ });
+
+ it('getElementsByTagNameRecursive finds nested elements', () => {
+ const root = document.createElement('div');
+ root.innerHTML = '';
+ const spans = getElementsByTagNameRecursive(root, 'span');
+ expect(spans.length).toBe(2);
+ });
+
+ it('getFileInputValue returns first file or null', () => {
+ expect(getFileInputValue({files: null})).toBeNull();
+ expect(getFileInputValue({files: []})).toBeNull();
+ const file = {name: 'f.txt'};
+ expect(getFileInputValue({files: [file]})).toBe(file);
+ });
+ });
+
+ describe('test HttpRequestError', function () {
+ it('carries code and message', () => {
+ const error = new HttpRequestError(404, 'Not found');
+ expect(error.code).toBe(404);
+ expect(error.message).toBe('Not found');
+ expect(error instanceof Error).toBe(true);
+ });
+
+ it('defaults code to -1', () => {
+ const error = new HttpRequestError();
+ expect(error.code).toBe(-1);
+ });
+ });
});
\ No newline at end of file
diff --git a/web-src/tests/unit/common/utils/parameterHistory_test.js b/web-src/tests/unit/common/utils/parameterHistory_test.js
new file mode 100644
index 00000000..069176fa
--- /dev/null
+++ b/web-src/tests/unit/common/utils/parameterHistory_test.js
@@ -0,0 +1,213 @@
+'use strict';
+
+import {vi} from 'vitest';
+import {
+ getMostRecentValues,
+ loadParameterHistory,
+ removeParameterHistoryEntry,
+ saveParameterHistory,
+ shouldUseHistoricalValues,
+ toggleFavoriteEntry,
+} from '@/common/utils/parameterHistory';
+
+const SCRIPT = 'my-script';
+const KEY = 'script_server_param_history_' + SCRIPT;
+
+// jsdom doesn't expose a working localStorage here, so use an in-memory mock.
+function createLocalStorageMock() {
+ let store = {};
+ return {
+ getItem: (key) => (key in store ? store[key] : null),
+ setItem: (key, value) => { store[key] = String(value); },
+ removeItem: (key) => { delete store[key]; },
+ clear: () => { store = {}; },
+ };
+}
+
+describe('Test parameterHistory', function () {
+ beforeEach(function () {
+ vi.stubGlobal('localStorage', createLocalStorageMock());
+ });
+
+ afterEach(function () {
+ vi.unstubAllGlobals();
+ });
+
+ describe('saveParameterHistory / loadParameterHistory', function () {
+ it('saves a new entry at the front', function () {
+ saveParameterHistory(SCRIPT, {a: 1});
+ const history = loadParameterHistory(SCRIPT);
+ expect(history.length).toBe(1);
+ expect(history[0].values).toEqual({a: 1});
+ expect(history[0].favorite).toBe(false);
+ });
+
+ it('ignores empty parameter values', function () {
+ saveParameterHistory(SCRIPT, {});
+ expect(loadParameterHistory(SCRIPT)).toEqual([]);
+ });
+
+ it('prepends newer distinct entries', function () {
+ saveParameterHistory(SCRIPT, {a: 1});
+ saveParameterHistory(SCRIPT, {a: 2});
+ const history = loadParameterHistory(SCRIPT);
+ expect(history.map(e => e.values)).toEqual([{a: 2}, {a: 1}]);
+ });
+
+ it('updates timestamp instead of duplicating identical values', function () {
+ saveParameterHistory(SCRIPT, {a: 1});
+ saveParameterHistory(SCRIPT, {a: 1});
+ expect(loadParameterHistory(SCRIPT).length).toBe(1);
+ });
+
+ it('caps non-favorite entries at 10', function () {
+ for (let i = 0; i < 12; i++) {
+ saveParameterHistory(SCRIPT, {n: i});
+ }
+ const history = loadParameterHistory(SCRIPT);
+ expect(history.length).toBe(10);
+ // newest first
+ expect(history[0].values).toEqual({n: 11});
+ });
+
+ it('returns empty array when nothing stored', function () {
+ expect(loadParameterHistory(SCRIPT)).toEqual([]);
+ });
+
+ it('returns empty array on corrupted storage', function () {
+ localStorage.setItem(KEY, 'not-json');
+ expect(loadParameterHistory(SCRIPT)).toEqual([]);
+ });
+
+ it('defaults missing favorite flag to false', function () {
+ localStorage.setItem(KEY, JSON.stringify([{timestamp: 1, values: {a: 1}}]));
+ expect(loadParameterHistory(SCRIPT)[0].favorite).toBe(false);
+ });
+ });
+
+ describe('getMostRecentValues', function () {
+ it('returns null when no history', function () {
+ expect(getMostRecentValues(SCRIPT)).toBeNull();
+ });
+
+ it('returns the most recent values', function () {
+ saveParameterHistory(SCRIPT, {a: 1});
+ saveParameterHistory(SCRIPT, {a: 2});
+ expect(getMostRecentValues(SCRIPT)).toEqual({a: 2});
+ });
+ });
+
+ describe('removeParameterHistoryEntry', function () {
+ it('removes a valid entry', function () {
+ saveParameterHistory(SCRIPT, {a: 1});
+ saveParameterHistory(SCRIPT, {a: 2});
+ removeParameterHistoryEntry(SCRIPT, 0);
+ expect(loadParameterHistory(SCRIPT).map(e => e.values)).toEqual([{a: 1}]);
+ });
+
+ it('ignores an out-of-range index', function () {
+ saveParameterHistory(SCRIPT, {a: 1});
+ removeParameterHistoryEntry(SCRIPT, 5);
+ expect(loadParameterHistory(SCRIPT).length).toBe(1);
+ });
+
+ it('does not remove a favorite entry', function () {
+ saveParameterHistory(SCRIPT, {a: 1});
+ toggleFavoriteEntry(SCRIPT, 0);
+ removeParameterHistoryEntry(SCRIPT, 0);
+ expect(loadParameterHistory(SCRIPT).length).toBe(1);
+ });
+ });
+
+ describe('toggleFavoriteEntry', function () {
+ it('marks an entry as favorite', function () {
+ saveParameterHistory(SCRIPT, {a: 1});
+ toggleFavoriteEntry(SCRIPT, 0);
+ expect(loadParameterHistory(SCRIPT)[0].favorite).toBe(true);
+ });
+
+ it('toggles back to non-favorite', function () {
+ saveParameterHistory(SCRIPT, {a: 1});
+ toggleFavoriteEntry(SCRIPT, 0);
+ toggleFavoriteEntry(SCRIPT, 0);
+ expect(loadParameterHistory(SCRIPT)[0].favorite).toBe(false);
+ });
+
+ it('ignores an out-of-range index', function () {
+ saveParameterHistory(SCRIPT, {a: 1});
+ toggleFavoriteEntry(SCRIPT, 9);
+ expect(loadParameterHistory(SCRIPT)[0].favorite).toBe(false);
+ });
+
+ it('moves favorites ahead of non-favorites', function () {
+ saveParameterHistory(SCRIPT, {a: 1});
+ saveParameterHistory(SCRIPT, {a: 2}); // index 0 now
+ // make the older entry ({a:1}, index 1) a favorite
+ toggleFavoriteEntry(SCRIPT, 1);
+ const history = loadParameterHistory(SCRIPT);
+ expect(history[0].values).toEqual({a: 1});
+ expect(history[0].favorite).toBe(true);
+ });
+
+ it('keeps favorites even beyond the non-favorite cap', function () {
+ saveParameterHistory(SCRIPT, {fav: true});
+ toggleFavoriteEntry(SCRIPT, 0);
+ for (let i = 0; i < 12; i++) {
+ saveParameterHistory(SCRIPT, {n: i});
+ }
+ const history = loadParameterHistory(SCRIPT);
+ const favorites = history.filter(e => e.favorite);
+ expect(favorites.length).toBe(1);
+ expect(favorites[0].values).toEqual({fav: true});
+ });
+ });
+
+ describe('graceful error handling', function () {
+ function stubThrowingStorage() {
+ vi.stubGlobal('localStorage', {
+ getItem: () => JSON.stringify([{timestamp: 1, values: {a: 1}, favorite: false}]),
+ setItem: () => { throw new Error('quota exceeded'); },
+ removeItem: () => {},
+ clear: () => {},
+ });
+ }
+
+ it('saveParameterHistory swallows storage errors', function () {
+ stubThrowingStorage();
+ expect(() => saveParameterHistory(SCRIPT, {b: 2})).not.toThrow();
+ });
+
+ it('removeParameterHistoryEntry swallows storage errors', function () {
+ stubThrowingStorage();
+ expect(() => removeParameterHistoryEntry(SCRIPT, 0)).not.toThrow();
+ });
+
+ it('toggleFavoriteEntry swallows storage errors', function () {
+ stubThrowingStorage();
+ expect(() => toggleFavoriteEntry(SCRIPT, 0)).not.toThrow();
+ });
+
+ it('shouldUseHistoricalValues returns false on storage error', function () {
+ vi.stubGlobal('localStorage', {
+ getItem: () => { throw new Error('blocked'); },
+ });
+ expect(shouldUseHistoricalValues(SCRIPT)).toBe(false);
+ });
+ });
+
+ describe('shouldUseHistoricalValues', function () {
+ it('is true when the flag is set to "true"', function () {
+ localStorage.setItem('useHistoricalValues_' + SCRIPT, 'true');
+ expect(shouldUseHistoricalValues(SCRIPT)).toBe(true);
+ });
+
+ it('is false when the flag is anything else', function () {
+ localStorage.setItem('useHistoricalValues_' + SCRIPT, 'false');
+ expect(shouldUseHistoricalValues(SCRIPT)).toBe(false);
+ });
+
+ it('is false when the flag is absent', function () {
+ expect(shouldUseHistoricalValues(SCRIPT)).toBe(false);
+ });
+ });
+});
diff --git a/web-src/tests/unit/main-app/components/favicon_manager_test.js b/web-src/tests/unit/main-app/components/favicon_manager_test.js
new file mode 100644
index 00000000..d4e9ae68
--- /dev/null
+++ b/web-src/tests/unit/main-app/components/favicon_manager_test.js
@@ -0,0 +1,69 @@
+'use strict';
+
+import {
+ defaultFavicon,
+ setDefaultFavicon,
+ setExecutingFavicon,
+ setFinishedFavicon,
+} from '@/main-app/components/favicon_manager';
+
+function iconLinks() {
+ const head = document.getElementsByTagName('head')[0];
+ return Array.from(head.childNodes).filter(
+ node => node.tagName === 'LINK' && node.type === 'image/x-icon');
+}
+
+describe('Test favicon_manager', function () {
+ beforeEach(function () {
+ iconLinks().forEach(link => link.parentNode.removeChild(link));
+ });
+
+ afterEach(function () {
+ iconLinks().forEach(link => link.parentNode.removeChild(link));
+ });
+
+ describe('defaultFavicon', function () {
+ it('is a shortcut icon link', function () {
+ expect(defaultFavicon.tagName).toBe('LINK');
+ expect(defaultFavicon.type).toBe('image/x-icon');
+ expect(defaultFavicon.rel).toBe('shortcut icon');
+ expect(defaultFavicon.href).toContain('favicon.ico');
+ });
+ });
+
+ describe('setFavicon behaviour', function () {
+ it('appends a favicon link when none is present', function () {
+ expect(iconLinks().length).toBe(0);
+ setDefaultFavicon();
+ expect(iconLinks()).toContain(defaultFavicon);
+ });
+
+ it('replaces an existing favicon link instead of adding another', function () {
+ const stale = document.createElement('link');
+ stale.type = 'image/x-icon';
+ stale.rel = 'shortcut icon';
+ stale.href = 'old.ico';
+ document.getElementsByTagName('head')[0].appendChild(stale);
+
+ setDefaultFavicon();
+
+ const links = iconLinks();
+ expect(links.length).toBe(1);
+ expect(links[0]).toBe(defaultFavicon);
+ });
+ });
+
+ describe('fallback to default when generated icons are unavailable', function () {
+ // In jsdom the base image never fires onload, so executingFavicon /
+ // finishedFavicon stay undefined and the setters fall back to default.
+ it('setExecutingFavicon falls back to the default favicon', function () {
+ setExecutingFavicon();
+ expect(iconLinks()).toContain(defaultFavicon);
+ });
+
+ it('setFinishedFavicon falls back to the default favicon', function () {
+ setFinishedFavicon();
+ expect(iconLinks()).toContain(defaultFavicon);
+ });
+ });
+});
diff --git a/web-src/tests/unit/main-app/components/schedule/SchedulePanel_test.js b/web-src/tests/unit/main-app/components/schedule/SchedulePanel_test.js
new file mode 100644
index 00000000..4784a214
--- /dev/null
+++ b/web-src/tests/unit/main-app/components/schedule/SchedulePanel_test.js
@@ -0,0 +1,191 @@
+'use strict';
+
+import SchedulePanel from '@/main-app/components/schedule/SchedulePanel';
+import {shallowMount} from '@vue/test-utils';
+import {createPinia, setActivePinia} from 'pinia';
+import {useScriptScheduleStore} from '@/main-app/stores/scriptSchedule';
+import {vi} from 'vitest';
+import {vueTicks} from '../../../test_utils';
+
+describe('Test SchedulePanel', function () {
+ let pinia;
+ let panel;
+
+ beforeEach(function () {
+ pinia = createPinia();
+ setActivePinia(pinia);
+
+ panel = shallowMount(SchedulePanel, {
+ global: {plugins: [pinia]}
+ });
+ });
+
+ afterEach(function () {
+ panel.unmount();
+ });
+
+ describe('scheduleType computed', function () {
+ it('defaults to one-time', function () {
+ expect(panel.vm.scheduleType).toBe('one-time');
+ });
+
+ it('switching to repeat clears oneTimeSchedule', async function () {
+ panel.vm.scheduleType = 'repeat';
+ expect(panel.vm.oneTimeSchedule).toBe(false);
+ expect(panel.vm.scheduleType).toBe('repeat');
+ });
+
+ it('switching back to one-time sets oneTimeSchedule', function () {
+ panel.vm.scheduleType = 'repeat';
+ panel.vm.scheduleType = 'one-time';
+ expect(panel.vm.oneTimeSchedule).toBe(true);
+ });
+ });
+
+ describe('weekdaysError computed', function () {
+ it('is null for one-time schedule', function () {
+ expect(panel.vm.weekdaysError).toBeNull();
+ });
+
+ it('is null when repeating by days', async function () {
+ await panel.setData({oneTimeSchedule: false, repeatTimeUnit: 'days'});
+ expect(panel.vm.weekdaysError).toBeNull();
+ });
+
+ it('is required when repeating by weeks with no active day', async function () {
+ const weekDays = panel.vm.weekDays.map(d => ({...d, active: false}));
+ await panel.setData({oneTimeSchedule: false, repeatTimeUnit: 'weeks', weekDays});
+ expect(panel.vm.weekdaysError).toBe('required');
+ });
+
+ it('is null when repeating by weeks with an active day', async function () {
+ const weekDays = panel.vm.weekDays.map((d, i) => ({...d, active: i === 0}));
+ await panel.setData({oneTimeSchedule: false, repeatTimeUnit: 'weeks', weekDays});
+ expect(panel.vm.weekdaysError).toBeNull();
+ });
+ });
+
+ describe('buildScheduleSetup', function () {
+ it('builds a one-time setup', async function () {
+ await panel.setData({
+ oneTimeSchedule: true,
+ startDate: new Date(2026, 5, 18),
+ startTime: '14:30',
+ });
+
+ const setup = panel.vm.buildScheduleSetup();
+
+ expect(setup.repeatable).toBe(false);
+ expect(setup.endOption).toBe('never');
+ expect(setup.endArg).toBeNull();
+ expect(setup.startDatetime.getHours()).toBe(14);
+ expect(setup.startDatetime.getMinutes()).toBe(30);
+ });
+
+ it('maps maxExecuteCount to max_executions', async function () {
+ await panel.setData({
+ oneTimeSchedule: false,
+ endOption: 'maxExecuteCount',
+ maxExecuteCount: 7,
+ });
+
+ const setup = panel.vm.buildScheduleSetup();
+
+ expect(setup.repeatable).toBe(true);
+ expect(setup.endOption).toBe('max_executions');
+ expect(setup.endArg).toBe(7);
+ });
+
+ it('maps endDatetime to end_datetime with a Date arg', async function () {
+ await panel.setData({
+ oneTimeSchedule: false,
+ endOption: 'endDatetime',
+ endDate: new Date(2026, 5, 20),
+ endTime: '09:15',
+ });
+
+ const setup = panel.vm.buildScheduleSetup();
+
+ expect(setup.endOption).toBe('end_datetime');
+ expect(setup.endArg).toBeInstanceOf(Date);
+ expect(setup.endArg.getHours()).toBe(9);
+ expect(setup.endArg.getMinutes()).toBe(15);
+ });
+
+ it('includes only active weekdays as names', async function () {
+ const weekDays = panel.vm.weekDays.map(d => ({...d, active: false}));
+ weekDays[0].active = true; // Monday
+ weekDays[2].active = true; // Wednesday
+ await panel.setData({oneTimeSchedule: false, repeatTimeUnit: 'weeks', weekDays});
+
+ const setup = panel.vm.buildScheduleSetup();
+
+ expect(setup.weekDays).toEqual(['Monday', 'Wednesday']);
+ });
+
+ it('carries repeat unit and period', async function () {
+ await panel.setData({
+ oneTimeSchedule: false,
+ repeatTimeUnit: 'hours',
+ repeatPeriod: 3,
+ });
+
+ const setup = panel.vm.buildScheduleSetup();
+
+ expect(setup.repeatUnit).toBe('hours');
+ expect(setup.repeatPeriod).toBe(3);
+ });
+ });
+
+ describe('error tracking', function () {
+ it('records a field error and exposes it in errors', function () {
+ panel.vm.onFieldError('startTime', 'invalid time');
+ expect(panel.vm.errors).toContain('invalid time');
+ });
+
+ it('clears errors when the field becomes valid', function () {
+ panel.vm.onFieldError('startTime', 'invalid time');
+ panel.vm.onFieldError('startTime', '');
+ expect(panel.vm.errors).toEqual([]);
+ });
+
+ it('ignores repeat-only field errors in one-time mode', function () {
+ panel.vm.onFieldError('repeatPeriod', 'bad');
+ expect(panel.vm.errors).toEqual([]);
+ });
+
+ it('tracks repeat-only field errors in repeat mode', async function () {
+ await panel.setData({oneTimeSchedule: false});
+ panel.vm.onFieldError('repeatPeriod', 'bad');
+ expect(panel.vm.errors).toContain('bad');
+ });
+
+ it('tracks maxExecuteCount error only with maxExecuteCount end option', async function () {
+ await panel.setData({oneTimeSchedule: false, endOption: 'maxExecuteCount'});
+ panel.vm.onFieldError('maxExecuteCount', 'bad count');
+ expect(panel.vm.errors).toContain('bad count');
+ });
+ });
+
+ describe('actions', function () {
+ it('close emits close', function () {
+ panel.vm.close();
+ expect(panel.emitted('close')).toBeTruthy();
+ });
+
+ it('runScheduleAction schedules, emits scheduled with id and closes', async function () {
+ const store = useScriptScheduleStore();
+ const scheduleSpy = vi.spyOn(store, 'schedule')
+ .mockResolvedValue({data: {id: 42}});
+
+ await panel.vm.runScheduleAction();
+ await vueTicks();
+
+ expect(scheduleSpy).toHaveBeenCalledOnce();
+ const passedSetup = scheduleSpy.mock.calls[0][0].scheduleSetup;
+ expect(passedSetup).toBeDefined();
+ expect(panel.emitted('scheduled')[0]).toEqual([42]);
+ expect(panel.emitted('close')).toBeTruthy();
+ });
+ });
+});