2026-10-23 19:19:01 +08:00
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
2026-10-27 23:02:43 +08:00
|
|
|
from io import BytesIO, StringIO
|
2026-10-23 19:19:01 +08:00
|
|
|
from pathlib import Path
|
2026-10-27 23:02:43 +08:00
|
|
|
import re
|
2026-10-27 09:47:48 +08:00
|
|
|
import subprocess
|
|
|
|
|
import sys
|
2026-10-23 19:19:01 +08:00
|
|
|
import tempfile
|
2026-10-27 23:02:43 +08:00
|
|
|
from types import SimpleNamespace
|
2026-10-23 19:19:01 +08:00
|
|
|
import unittest
|
2026-10-27 23:02:43 +08:00
|
|
|
from unittest.mock import Mock, patch
|
2026-10-23 19:19:01 +08:00
|
|
|
|
|
|
|
|
from farm_render import bundle
|
|
|
|
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
ZIP = ROOT / 'src/farm_render/data/assets.zip'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BundleTests(unittest.TestCase):
|
2026-10-27 09:47:48 +08:00
|
|
|
def test_manifest_check_without_third_party_packages(self):
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
[sys.executable, '-S',
|
|
|
|
|
str(ROOT / 'scripts/check_asset_manifest.py')],
|
|
|
|
|
cwd=ROOT, capture_output=True, text=True, timeout=30,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
|
|
|
self.assertIn('Verified external asset manifest', result.stdout)
|
|
|
|
|
|
2026-10-23 19:19:01 +08:00
|
|
|
def setUp(self):
|
|
|
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
|
|
|
self.addCleanup(self.temp.cleanup)
|
|
|
|
|
self.root = Path(self.temp.name)
|
|
|
|
|
self.expected = bundle.expected_manifest()
|
|
|
|
|
|
|
|
|
|
def download(self, url, destination, limit):
|
|
|
|
|
content = ZIP.read_bytes()
|
|
|
|
|
self.assertEqual(limit, len(content))
|
|
|
|
|
destination.write(content)
|
|
|
|
|
|
|
|
|
|
def test_download_deduplicates_and_cache_hash_checked_each_start(self):
|
|
|
|
|
with patch.object(bundle, '_copy_remote',
|
|
|
|
|
side_effect=self.download) as fetch:
|
|
|
|
|
with ThreadPoolExecutor(4) as pool:
|
|
|
|
|
results = list(pool.map(
|
|
|
|
|
lambda _: bundle.ensure_assets(self.root), range(4),
|
|
|
|
|
))
|
|
|
|
|
self.assertEqual(len(set(results)), 1)
|
|
|
|
|
self.assertEqual(fetch.call_count, 1)
|
|
|
|
|
path = results[0]
|
|
|
|
|
content = bytearray(path.read_bytes())
|
|
|
|
|
content[-1] ^= 1
|
|
|
|
|
path.write_bytes(content)
|
|
|
|
|
bundle.ensure_assets(self.root)
|
|
|
|
|
self.assertEqual(fetch.call_count, 2)
|
|
|
|
|
self.assertEqual(path.read_bytes(), ZIP.read_bytes())
|
|
|
|
|
|
|
|
|
|
def test_gitea_and_service_use_service_repository_and_configured_base(self):
|
|
|
|
|
with patch.object(bundle, '_copy_remote',
|
|
|
|
|
side_effect=self.download) as fetch:
|
|
|
|
|
bundle.ensure_assets(self.root / 'gitea', 'Gitea')
|
|
|
|
|
self.assertEqual(fetch.call_args.args[0],
|
|
|
|
|
'https://gitea.diuse.work/Diuse/farm_service/'
|
2026-10-26 18:53:41 +08:00
|
|
|
'raw/branch/main/catalogue/releases/'
|
|
|
|
|
+ self.expected['catalogue_release'] + '/assets.zip')
|
2026-10-23 19:19:01 +08:00
|
|
|
bundle.ensure_assets(self.root / 'service', 'farm_service',
|
|
|
|
|
'https://farm.example/prefix/')
|
|
|
|
|
self.assertEqual(fetch.call_args.args[0],
|
2026-10-26 18:53:41 +08:00
|
|
|
'https://farm.example/prefix/v1/catalogue/releases/'
|
|
|
|
|
+ self.expected['catalogue_release'] + '/assets.zip')
|
2026-10-23 19:19:01 +08:00
|
|
|
|
|
|
|
|
def test_failed_download_leaves_no_partial_and_wrong_hash_is_rejected(self):
|
|
|
|
|
def corrupt(url, destination, limit):
|
|
|
|
|
data = bytearray(ZIP.read_bytes())
|
|
|
|
|
data[-1] ^= 1
|
|
|
|
|
destination.write(data)
|
|
|
|
|
with patch.object(bundle, '_copy_remote', side_effect=corrupt):
|
|
|
|
|
with self.assertRaisesRegex(ValueError, 'SHA256'):
|
|
|
|
|
bundle.ensure_assets(self.root)
|
|
|
|
|
self.assertFalse(list(self.root.rglob('assets.zip')))
|
|
|
|
|
self.assertFalse(list(self.root.rglob('*.download')))
|
|
|
|
|
with patch.object(bundle, '_copy_remote', side_effect=OSError()):
|
|
|
|
|
with self.assertRaises(OSError):
|
|
|
|
|
bundle.ensure_assets(self.root)
|
|
|
|
|
self.assertFalse(list(self.root.rglob('*.download')))
|
|
|
|
|
|
|
|
|
|
def test_invalid_source_and_service_configuration(self):
|
|
|
|
|
for source, url in [('unknown', ''), ('farm_service', ''),
|
|
|
|
|
('farm_service', 'http://farm.example'),
|
|
|
|
|
('Github', 'https://user:pass@farm.example'),
|
|
|
|
|
('Gitea', 'https://farm.example/?token=secret')]:
|
|
|
|
|
with self.subTest(source=source, url=url):
|
|
|
|
|
with self.assertRaises(ValueError):
|
|
|
|
|
bundle.ensure_assets(self.root, source, url)
|
|
|
|
|
|
|
|
|
|
def test_download_enforces_response_limit_and_https_redirect(self):
|
|
|
|
|
class Response(BytesIO):
|
|
|
|
|
headers = {'Content-Length': '20'}
|
|
|
|
|
def geturl(self):
|
|
|
|
|
return 'https://farm.example/assets.zip'
|
|
|
|
|
with patch.object(bundle, 'urlopen', return_value=Response(b'x'*20)):
|
|
|
|
|
with self.assertRaisesRegex(ValueError, 'limit'):
|
|
|
|
|
bundle._copy_remote('https://farm.example/a', BytesIO(), 10)
|
|
|
|
|
response = Response(b'')
|
|
|
|
|
response.geturl = lambda: 'http://farm.example/a'
|
|
|
|
|
with patch.object(bundle, 'urlopen', return_value=response):
|
|
|
|
|
with self.assertRaises(ValueError):
|
|
|
|
|
bundle._copy_remote('https://farm.example/a', BytesIO(), 10)
|
2026-10-27 11:40:59 +08:00
|
|
|
|
2026-10-27 23:02:43 +08:00
|
|
|
def test_download_refreshes_terminal_progress_on_same_line(self):
|
|
|
|
|
from rich.console import Console
|
|
|
|
|
|
2026-10-27 11:40:59 +08:00
|
|
|
class Response(BytesIO):
|
|
|
|
|
headers = {'Content-Length': '262144'}
|
|
|
|
|
def geturl(self):
|
|
|
|
|
return 'https://farm.example/private/assets.zip'
|
2026-10-27 23:02:43 +08:00
|
|
|
terminal = StringIO()
|
|
|
|
|
terminal.isatty = lambda: True
|
|
|
|
|
with patch.dict('os.environ', {'TERM': 'xterm-256color'}), \
|
|
|
|
|
patch.object(bundle.sys, 'stderr', terminal), \
|
|
|
|
|
patch('rich.console.Console', side_effect=lambda **kwargs:
|
|
|
|
|
Console(**kwargs, force_terminal=True,
|
|
|
|
|
force_interactive=True, legacy_windows=False,
|
|
|
|
|
color_system='standard', width=120)), \
|
|
|
|
|
patch.object(bundle, 'urlopen', return_value=Response(
|
2026-10-27 11:40:59 +08:00
|
|
|
b'x' * 262144)), patch.object(
|
2026-10-27 23:02:43 +08:00
|
|
|
bundle, 'time', SimpleNamespace(monotonic=Mock(
|
|
|
|
|
side_effect=[0, 1, 2, 10, 11]))), \
|
2026-10-27 11:40:59 +08:00
|
|
|
self.assertLogs(bundle.logger, level='INFO') as captured:
|
|
|
|
|
bundle._copy_remote('https://farm.example/private/assets.zip',
|
|
|
|
|
BytesIO(), 262144)
|
|
|
|
|
messages = '\n'.join(captured.output)
|
|
|
|
|
self.assertIn('下载开始', messages)
|
|
|
|
|
self.assertIn('下载完成', messages)
|
2026-10-27 23:02:43 +08:00
|
|
|
self.assertNotIn('下载进度', messages)
|
|
|
|
|
raw = terminal.getvalue()
|
|
|
|
|
progress = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]', '', raw)
|
|
|
|
|
self.assertIn('0.0%', progress)
|
|
|
|
|
self.assertIn('50.0%', progress)
|
|
|
|
|
self.assertIn('100.0%', progress)
|
|
|
|
|
self.assertIn('eta', progress)
|
|
|
|
|
self.assertIn('kB', progress)
|
|
|
|
|
self.assertIn('/s', progress)
|
|
|
|
|
self.assertGreater(raw.count('\r'), 2)
|
|
|
|
|
self.assertIn('\x1b[2K', raw)
|
|
|
|
|
self.assertNotIn('farm.example', progress)
|
2026-10-27 11:40:59 +08:00
|
|
|
self.assertNotIn('farm.example', messages)
|
|
|
|
|
self.assertNotIn('private', messages)
|
|
|
|
|
|
|
|
|
|
def test_unknown_length_and_failed_download_report_truthfully(self):
|
|
|
|
|
class Response(BytesIO):
|
|
|
|
|
headers = {}
|
|
|
|
|
def geturl(self):
|
|
|
|
|
return 'https://farm.example/a'
|
2026-10-27 23:02:43 +08:00
|
|
|
output = StringIO()
|
|
|
|
|
with patch.object(bundle.sys, 'stderr', output), \
|
|
|
|
|
patch.object(bundle, 'urlopen', return_value=Response(b'abc')), \
|
|
|
|
|
patch.object(bundle, 'time', SimpleNamespace(
|
|
|
|
|
monotonic=Mock(side_effect=[0, 10]))), \
|
2026-10-27 11:40:59 +08:00
|
|
|
self.assertLogs(bundle.logger, level='INFO') as captured:
|
|
|
|
|
bundle._copy_remote('https://farm.example/a', BytesIO(), 10)
|
|
|
|
|
self.assertIn('下载完成', '\n'.join(captured.output))
|
2026-10-27 23:02:43 +08:00
|
|
|
self.assertEqual(output.getvalue(), '')
|
2026-10-27 11:40:59 +08:00
|
|
|
self.assertNotIn('%', '\n'.join(captured.output))
|
|
|
|
|
with patch.object(bundle, 'urlopen', side_effect=OSError('secret')), \
|
|
|
|
|
self.assertLogs(bundle.logger, level='INFO') as captured:
|
|
|
|
|
with self.assertRaises(OSError):
|
|
|
|
|
bundle._copy_remote('https://farm.example/a', BytesIO(), 10)
|
|
|
|
|
messages = '\n'.join(captured.output)
|
|
|
|
|
self.assertIn('下载失败', messages)
|
|
|
|
|
self.assertNotIn('下载完成', messages)
|
|
|
|
|
self.assertNotIn('secret', messages)
|
2026-10-27 23:02:43 +08:00
|
|
|
|
|
|
|
|
def test_terminal_progress_is_throttled_and_unknown_size_has_no_percent(self):
|
|
|
|
|
terminal = StringIO()
|
|
|
|
|
terminal.isatty = lambda: True
|
|
|
|
|
with patch.object(bundle.sys, 'stderr', terminal), \
|
|
|
|
|
patch('rich.progress.Progress') as rich_progress:
|
|
|
|
|
progress = bundle._DownloadProgress('字体包')
|
|
|
|
|
progress.update(0, 0, 0)
|
|
|
|
|
progress.update(65536, 0, 0.1)
|
|
|
|
|
progress.update(131072, 0, 0.2)
|
|
|
|
|
progress.close()
|
|
|
|
|
display = rich_progress.return_value
|
|
|
|
|
self.assertEqual(display.update.call_count, 2)
|
|
|
|
|
self.assertEqual(display.update.call_args.kwargs['completed'], 131072)
|
|
|
|
|
self.assertEqual(display.update.call_args.kwargs['percentage'], '')
|
|
|
|
|
self.assertIsNone(display.add_task.call_args.kwargs['total'])
|
|
|
|
|
self.assertFalse(rich_progress.call_args.kwargs['auto_refresh'])
|
|
|
|
|
self.assertFalse(rich_progress.call_args.kwargs['redirect_stdout'])
|
|
|
|
|
self.assertFalse(rich_progress.call_args.kwargs['redirect_stderr'])
|
|
|
|
|
display.stop.assert_called_once()
|
|
|
|
|
|
|
|
|
|
def test_download_failure_ends_progress_line(self):
|
|
|
|
|
class Response(BytesIO):
|
|
|
|
|
headers = {'Content-Length': '10'}
|
|
|
|
|
|
|
|
|
|
def geturl(self):
|
|
|
|
|
return 'https://farm.example/a'
|
|
|
|
|
|
|
|
|
|
def read(self, size):
|
|
|
|
|
raise OSError('private error')
|
|
|
|
|
|
|
|
|
|
terminal = StringIO()
|
|
|
|
|
terminal.isatty = lambda: True
|
|
|
|
|
with patch.object(bundle.sys, 'stderr', terminal), \
|
|
|
|
|
patch.object(bundle, 'urlopen', return_value=Response()), \
|
|
|
|
|
self.assertLogs(bundle.logger, level='INFO') as captured:
|
|
|
|
|
with self.assertRaises(OSError):
|
|
|
|
|
bundle._copy_remote('https://farm.example/a', BytesIO(), 10)
|
|
|
|
|
self.assertTrue(terminal.getvalue().endswith('\n'))
|
|
|
|
|
self.assertNotIn('100.0%', terminal.getvalue())
|
|
|
|
|
self.assertNotIn('下载完成', '\n'.join(captured.output))
|
|
|
|
|
|
|
|
|
|
def test_progress_display_failure_does_not_abort_download(self):
|
|
|
|
|
class Response(BytesIO):
|
|
|
|
|
headers = {'Content-Length': '3'}
|
|
|
|
|
|
|
|
|
|
def geturl(self):
|
|
|
|
|
return 'https://farm.example/a'
|
|
|
|
|
|
|
|
|
|
terminal = StringIO()
|
|
|
|
|
terminal.isatty = lambda: True
|
|
|
|
|
destination = BytesIO()
|
|
|
|
|
with patch.object(bundle.sys, 'stderr', terminal), \
|
|
|
|
|
patch.object(bundle, 'urlopen', return_value=Response(b'abc')), \
|
|
|
|
|
patch('rich.progress.Progress') as rich_progress:
|
|
|
|
|
display = rich_progress.return_value
|
|
|
|
|
display.refresh.side_effect = OSError('broken terminal')
|
|
|
|
|
bundle._copy_remote('https://farm.example/a', destination, 10)
|
|
|
|
|
self.assertEqual(destination.getvalue(), b'abc')
|
|
|
|
|
display.stop.assert_called_once()
|