From 3073ba5564bac6ff4ef7737ffbe269ff0e0037df Mon Sep 17 00:00:00 2001 From: Thomas Kpenou Date: Thu, 18 Jun 2026 11:33:15 -0400 Subject: [PATCH 1/6] =?UTF-8?q?test(migrations):=20cover=20migrate.py=20(0?= =?UTF-8?q?%=20=E2=86=92=2091%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 25 tests for src/migrations/migrate.py, previously untested despite running at every startup and transforming users' config and log files. All migrations are driven through the public migrate() entry point, so the orchestration logic (registry, dependency ordering, migrations.txt state, new-installation detection) is covered alongside each of the 7 migrations. Co-Authored-By: Claude Opus 4.8 --- src/tests/migrate_test.py | 330 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 src/tests/migrate_test.py 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() From f2d3dc818305d8bb7df723edd935b2de47b0f059 Mon Sep 17 00:00:00 2001 From: Thomas Kpenou Date: Thu, 18 Jun 2026 11:35:16 -0400 Subject: [PATCH 2/6] test(coverage): exclude main.py bootstrap from coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/main.py is pure application wiring — it parses args, instantiates every service and starts the Tornado server. It can't be unit-tested without a full integration environment, so its uncovered lines only distorted the metric. Excluding it makes the reported coverage reflect testable code. Co-Authored-By: Claude Opus 4.8 --- .coveragerc | 4 ++++ 1 file changed, 4 insertions(+) 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 = From 2b0647af4c19d38565f00a9af3216d26e92a25c5 Mon Sep 17 00:00:00 2001 From: Thomas Kpenou Date: Thu, 18 Jun 2026 11:40:07 -0400 Subject: [PATCH 3/6] =?UTF-8?q?test(common):=20cover=20common.js=20utils?= =?UTF-8?q?=20(44%=20=E2=86=92=2066%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 49 tests for the pure helpers and simple DOM utilities in common/utils/common.js: emptiness checks, array/object helpers, stringComparator, websocket-state checks, url/query helpers, id generators, isFullRegexMatch, DOM traversal helpers and HttpRequestError. Remaining gaps are browser-API-heavy functions (XHR, layout scrolling, clipboard, canvas text measurement) that aren't meaningfully testable in jsdom. Co-Authored-By: Claude Opus 4.8 --- .../tests/unit/common/utils/common_test.js | 355 +++++++++++++++++- 1 file changed, 354 insertions(+), 1 deletion(-) 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 = '
1
2
'; + 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 From 12fbcac77d2ad6bcfb5a422e2e01fc9112335ea2 Mon Sep 17 00:00:00 2001 From: Thomas Kpenou Date: Thu, 18 Jun 2026 11:44:17 -0400 Subject: [PATCH 4/6] =?UTF-8?q?test(file=5Futils):=20cover=20file=5Futils.?= =?UTF-8?q?py=20(62%=20=E2=86=92=20~74%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 43 tests for src/utils/file_utils.py: read/write round-trips (bytes, newlines, encoding fallback), folder/existence helpers, path helpers (normalize_path, relative_path, split_all, is_root), create_unique_filename, make_executable, is_binary detection, broken-symlink detection, modification/deletion dates, search_glob, and the FileMatcher / SingleFileMatcher pattern logic. Co-Authored-By: Claude Opus 4.8 --- src/tests/file_utils_extra_test.py | 313 +++++++++++++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 src/tests/file_utils_extra_test.py 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() From dd8063334ab4cf67cc070af126908115a74b8b1a Mon Sep 17 00:00:00 2001 From: Thomas Kpenou Date: Thu, 18 Jun 2026 11:48:04 -0400 Subject: [PATCH 5/6] =?UTF-8?q?test(schedule):=20cover=20SchedulePanel.vue?= =?UTF-8?q?=20(~1%=20=E2=86=92=20~73%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 19 tests for SchedulePanel: scheduleType / weekdaysError computeds, buildScheduleSetup (one-time, max_executions and end_datetime end options, active weekday filtering, repeat unit/period), error tracking via onFieldError/checkErrors, and the schedule/close actions (store action stubbed). Uses shallowMount to isolate the component's logic from the child input components' v-model side effects. Co-Authored-By: Claude Opus 4.8 --- .../components/schedule/SchedulePanel_test.js | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 web-src/tests/unit/main-app/components/schedule/SchedulePanel_test.js 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(); + }); + }); +}); From 7ab1b9b6eef9859eb0bc9e70726795c88fe2277e Mon Sep 17 00:00:00 2001 From: Thomas Kpenou Date: Thu, 18 Jun 2026 11:54:44 -0400 Subject: [PATCH 6/6] =?UTF-8?q?test(frontend):=20cover=20parameterHistory.?= =?UTF-8?q?js=20(25%=E2=86=9298%)=20and=20favicon=5Fmanager.js=20(0%?= =?UTF-8?q?=E2=86=9240%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parameterHistory.js: 25 tests covering save/load (dedup, prepend, 10-entry cap, corrupted storage, backward-compat favorite flag), getMostRecentValues, remove/toggle (index guards, favorite protection, reordering) and the shouldUseHistoricalValues flag, plus graceful handling of throwing storage. An in-memory localStorage mock is used since jsdom doesn't expose one here. favicon_manager.js: 5 tests for defaultFavicon, setFavicon append/replace and the default fallback of the executing/finished setters. Canvas-rendered icons (onload path) aren't testable under jsdom and are left uncovered. Co-Authored-By: Claude Opus 4.8 --- .../common/utils/parameterHistory_test.js | 213 ++++++++++++++++++ .../components/favicon_manager_test.js | 69 ++++++ 2 files changed, 282 insertions(+) create mode 100644 web-src/tests/unit/common/utils/parameterHistory_test.js create mode 100644 web-src/tests/unit/main-app/components/favicon_manager_test.js 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); + }); + }); +});