384 lines
22 KiB
Python
384 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""Portable packaging-boundary tests; no Qt, GUI or production sandbox bypass."""
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import runpy
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / 'tools'))
|
|
from collect_linux_dependencies import (ldd_paths, license_candidates, license_supplements,
|
|
inventory, PackageDatabase, pacman_fields,
|
|
pdfium_supplemental_notices, qt_embedded_notices,
|
|
qt_runtime_files, sha256)
|
|
from package_linux_development import (MANIFEST, PACKAGE_NAME, copy_install, describe_payload,
|
|
payload_files, verify_and_extract, write_archive)
|
|
|
|
|
|
class LinuxPackageTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temporary = tempfile.TemporaryDirectory(prefix='docview-package-test-')
|
|
self.addCleanup(self.temporary.cleanup)
|
|
self.root = Path(self.temporary.name)
|
|
self.payload = self.root / 'payload'
|
|
(self.payload / 'bin').mkdir(parents=True)
|
|
(self.payload / 'bin/docview').write_bytes(b'fixture executable\n')
|
|
(self.payload / 'bin/docview').chmod(0o755)
|
|
(self.payload / 'share/doc/docview').mkdir(parents=True)
|
|
(self.payload / 'share/doc/docview/NOTICE.txt').write_text('Synthetic test material.\n')
|
|
self.manifest()
|
|
|
|
def manifest(self):
|
|
(self.payload / MANIFEST).write_text(json.dumps(describe_payload(self.payload, 0), sort_keys=True))
|
|
|
|
def archive(self, name='valid.tar.gz'):
|
|
path = self.root / name
|
|
write_archive(self.payload, path, 0)
|
|
return path
|
|
|
|
def test_failed_smoke_removes_previous_success_aggregates(self):
|
|
archive = self.archive()
|
|
invalid_archive = self.root / 'invalid.tar.gz'
|
|
invalid_archive.write_bytes(b'not a tar archive')
|
|
cases = [
|
|
('direct-missing', 'smoke.py', '--binary', self.root / 'missing', FileNotFoundError),
|
|
('direct-launch', 'smoke.py', '--binary', self.payload / 'bin/docview', subprocess.CalledProcessError),
|
|
('package-missing', 'smoke_linux_package.py', '--archive', self.root / 'missing', FileNotFoundError),
|
|
('package-invalid', 'smoke_linux_package.py', '--archive', invalid_archive, tarfile.ReadError),
|
|
('package-launch', 'smoke_linux_package.py', '--archive', archive, subprocess.CalledProcessError),
|
|
('package-sandbox', 'smoke_linux_package.py', '--archive', archive, SystemExit),
|
|
]
|
|
for name, script, option, source, exception in cases:
|
|
with self.subTest(name=name):
|
|
output = self.root / name
|
|
output.mkdir()
|
|
for aggregate in ('results.json', 'package-smoke.json'):
|
|
(output / aggregate).write_text('{"success":true}')
|
|
screenshot = output / 'previous-image.png'
|
|
screenshot.write_bytes(b'previous image is not success evidence')
|
|
arguments = [str(ROOT / 'tests' / script), option, str(source), '--output', str(output)]
|
|
environment = {'QTWEBENGINE_DISABLE_SANDBOX': '1' if name == 'package-sandbox' else '0',
|
|
'QTWEBENGINE_CHROMIUM_FLAGS': ''}
|
|
with patch.object(sys, 'argv', arguments), patch.dict(os.environ, environment), \
|
|
patch('subprocess.run', side_effect=subprocess.CalledProcessError(7, 'synthetic-failed-launch')) as launch:
|
|
with self.assertRaises(exception):
|
|
runpy.run_path(arguments[0], run_name='__main__')
|
|
self.assertEqual(launch.call_count, 1 if name.endswith('-launch') else 0)
|
|
self.assertFalse((output / 'results.json').exists())
|
|
self.assertFalse((output / 'package-smoke.json').exists())
|
|
self.assertEqual(screenshot.read_bytes(), b'previous image is not success evidence')
|
|
|
|
def test_reproducible_bytes_ignore_mtime_and_creation_order(self):
|
|
first = self.archive()
|
|
for path in payload_files(self.payload):
|
|
os.utime(path, (123456, 654321))
|
|
second = self.archive('second.tar.gz')
|
|
self.assertEqual(sha256(first), sha256(second))
|
|
relocated = verify_and_extract(first, self.root / 'relocated')
|
|
third = self.root / 'third.tar.gz'
|
|
write_archive(relocated, third, 0)
|
|
self.assertEqual(sha256(first), sha256(third))
|
|
self.assertEqual((relocated / 'bin/docview').stat().st_mode & 0o777, 0o755)
|
|
|
|
def test_modified_payload_rejected_against_manifest(self):
|
|
(self.payload / 'bin/docview').write_bytes(b'modified executable\n')
|
|
with self.assertRaisesRegex(ValueError, 'integrity'):
|
|
verify_and_extract(self.archive(), self.root / 'extract')
|
|
|
|
def test_unlisted_file_rejected(self):
|
|
(self.payload / 'unexpected').write_text('not listed')
|
|
with self.assertRaisesRegex(ValueError, 'entries differ'):
|
|
verify_and_extract(self.archive(), self.root / 'extract')
|
|
|
|
def test_duplicate_manifest_entry_rejected(self):
|
|
path = self.payload / MANIFEST
|
|
value = json.loads(path.read_text())
|
|
value['files'].append(value['files'][0])
|
|
path.write_text(json.dumps(value))
|
|
with self.assertRaisesRegex(ValueError, 'manifest file entry'):
|
|
verify_and_extract(self.archive(), self.root / 'extract')
|
|
|
|
def test_archive_path_link_and_duplicate_rejected(self):
|
|
for kind in ('traversal', 'symlink', 'duplicate'):
|
|
with self.subTest(kind=kind):
|
|
archive = self.root / (kind + '.tar.gz')
|
|
with tarfile.open(archive, 'w:gz') as tar:
|
|
info = tarfile.TarInfo(PACKAGE_NAME + ('/../escape' if kind == 'traversal' else '/entry'))
|
|
info.mode = 0o644
|
|
if kind == 'symlink':
|
|
info.type = tarfile.SYMTYPE
|
|
info.linkname = '/tmp'
|
|
tar.addfile(info, io.BytesIO())
|
|
if kind == 'duplicate':
|
|
tar.addfile(info, io.BytesIO())
|
|
with self.assertRaisesRegex(ValueError, 'Unsafe or duplicate'):
|
|
verify_and_extract(archive, self.root / (kind + '-out'))
|
|
|
|
def test_staging_rejects_symlink_and_hardlink(self):
|
|
target = self.payload / 'bin/docview'
|
|
link = self.payload / 'alias'
|
|
link.symlink_to(target)
|
|
with self.assertRaisesRegex(ValueError, 'link or special'):
|
|
payload_files(self.payload)
|
|
link.unlink()
|
|
os.link(target, link)
|
|
with self.assertRaisesRegex(ValueError, 'multiply-linked'):
|
|
payload_files(self.payload)
|
|
|
|
def test_install_whitelist_and_missing_binary(self):
|
|
with self.assertRaisesRegex(ValueError, 'missing'):
|
|
copy_install(self.payload, self.root / 'copy')
|
|
for name in ('docview-pdf-worker', 'docview-archive-worker'):
|
|
(self.payload / 'bin' / name).write_bytes(b'test worker')
|
|
(self.payload / 'lib').mkdir()
|
|
(self.payload / 'lib/libpdfium.so').write_bytes(b'test library')
|
|
(self.payload / 'user-state.json').write_text('must not be packaged')
|
|
with self.assertRaisesRegex(ValueError, 'Unexpected file'):
|
|
copy_install(self.payload, self.root / 'copy')
|
|
|
|
def test_ldd_missing_and_unknown_output_fail_closed(self):
|
|
output = 'linux-vdso.so.1 (0x123)\nlibx.so => /usr/lib/libx.so (0x456)\n/lib64/ld.so (0x789)\n'
|
|
self.assertEqual(ldd_paths(output), {Path('/usr/lib/libx.so'), Path('/lib64/ld.so')})
|
|
for invalid in ('libx.so => not found\n', 'unexpected output\n'):
|
|
with self.assertRaises(ValueError):
|
|
ldd_paths(invalid)
|
|
|
|
def test_package_license_labels_preserved_and_shared_text_collected(self):
|
|
desc = pacman_fields('%NAME%\nexample\n\n%LICENSE%\nLGPL-2.1-only\nMIT OR BSD-3-Clause\n')
|
|
self.assertEqual(desc['LICENSE'], ['LGPL-2.1-only', 'MIT OR BSD-3-Clause'])
|
|
licenses = self.root / 'licenses'
|
|
(licenses / 'spdx').mkdir(parents=True)
|
|
(licenses / 'spdx/LGPL-2.1-only.txt').write_text('shared license fixture')
|
|
(licenses / 'example').mkdir()
|
|
(licenses / 'example/COPYING').write_text('specific copyright fixture')
|
|
self.assertEqual(len(license_candidates('example', desc, licenses)), 2)
|
|
|
|
def qt_fixture(self):
|
|
query = {'QT_VERSION': '6.11.2'}
|
|
for name in ('LIBEXECS', 'QML', 'PLUGINS', 'DATA', 'TRANSLATIONS'):
|
|
path = self.root / 'qt' / name.lower()
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
query['QT_INSTALL_' + name] = str(path)
|
|
(Path(query['QT_INSTALL_LIBEXECS']) / 'QtWebEngineProcess').write_bytes(b'fixture helper')
|
|
for name in ('qtbase_ja.qm', 'qtdeclarative_ja.qm'):
|
|
(Path(query['QT_INSTALL_TRANSLATIONS']) / name).write_bytes(b'fixture ' + name.encode())
|
|
return query
|
|
|
|
def test_japanese_qt_catalogs_required_independently(self):
|
|
query = self.qt_fixture()
|
|
directory = Path(query['QT_INSTALL_TRANSLATIONS'])
|
|
required = {directory / name for name in ('qtbase_ja.qm', 'qtdeclarative_ja.qm')}
|
|
self.assertTrue(required <= qt_runtime_files(query))
|
|
# Unrelated languages or a merged deployment catalog must not hide a
|
|
# missing required catalog in this Arch system-runtime package.
|
|
(directory / 'qt_ja.qm').write_bytes(b'fixture merged catalog')
|
|
(directory / 'qtbase_en.qm').write_bytes(b'fixture other language')
|
|
for path in sorted(required):
|
|
with self.subTest(catalog=path.name):
|
|
data = path.read_bytes()
|
|
path.unlink()
|
|
with self.assertRaisesRegex(ValueError, 'Required Japanese Qt translation is missing: ' + path.name):
|
|
qt_runtime_files(query)
|
|
path.write_bytes(data)
|
|
|
|
def test_translation_owner_license_and_source_are_in_inventory(self):
|
|
query = self.qt_fixture()
|
|
database_root = self.root / 'pacman'
|
|
database_root.mkdir()
|
|
catalogs = [Path(query['QT_INSTALL_TRANSLATIONS']) / name
|
|
for name in ('qtbase_ja.qm', 'qtdeclarative_ja.qm')]
|
|
helper = Path(query['QT_INSTALL_LIBEXECS']) / 'QtWebEngineProcess'
|
|
for name in ('qt6-base', 'qt6-declarative', 'qt6-webengine', 'qt6-translations', 'tomlplusplus', 'glibc'):
|
|
directory = database_root / name
|
|
directory.mkdir()
|
|
owned = catalogs if name == 'qt6-translations' else [helper] if name == 'qt6-webengine' else []
|
|
directory.joinpath('desc').write_text(
|
|
f'%NAME%\n{name}\n\n%BASE%\n{name}\n\n%VERSION%\n6.11.2-1\n\n'
|
|
'%ARCH%\nany\n\n%URL%\nhttps://example.invalid/qt\n\n%LICENSE%\nLGPL-3.0-only\n')
|
|
directory.joinpath('files').write_text('%FILES%\n' + ''.join(str(path)[1:] + '\n' for path in owned))
|
|
database = PackageDatabase(database_root)
|
|
self.assertEqual([database.owner(path) for path in catalogs], ['qt6-translations'] * 2)
|
|
project = self.root / 'project'
|
|
for directory in (project / '.deps/pdfium/lib', project / 'cmake', self.payload / 'lib',
|
|
self.payload / 'share/doc/docview/pdfium'):
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
for name in ('docview-pdf-worker', 'docview-archive-worker'):
|
|
(self.payload / 'bin' / name).write_bytes(b'fixture worker')
|
|
for path in (project / '.deps/pdfium/lib/libpdfium.so', self.payload / 'lib/libpdfium.so'):
|
|
path.write_bytes(b'fixture PDFium')
|
|
(self.payload / 'share/doc/docview/pdfium/LICENSE').write_text('fixture PDFium notice')
|
|
lock = {name: 'fixture' for name in ('version', 'upstream', 'upstreamCommit', 'binaryProvider',
|
|
'linuxX64Archive', 'linuxX64Sha256', 'buildOptions')}
|
|
(project / 'cmake/pdfium.lock.json').write_text(json.dumps(lock))
|
|
notice = self.root / 'LGPL-3.0-only.txt'
|
|
notice.write_text('synthetic translation license notice')
|
|
notice_source = {'url': 'https://example.invalid/qttranslations/COPYING',
|
|
'version': '6.11.2', 'downloadSha256': sha256(notice)}
|
|
def supplements(name, version):
|
|
return [(notice, notice_source)] if name == 'qt6-translations' else []
|
|
def command(args):
|
|
return '\n'.join(key + ':' + value for key, value in query.items()) if args[-1] == '--query' else ''
|
|
output = self.root / 'inventory'
|
|
embedded_metadata = ROOT / 'resources/licenses/qt-embedded-notices/sources.json'
|
|
with patch('collect_linux_dependencies.ROOT', project), \
|
|
patch('collect_linux_dependencies.PackageDatabase', return_value=database), \
|
|
patch('collect_linux_dependencies.run', side_effect=command), \
|
|
patch('collect_linux_dependencies.license_candidates', return_value=[]), \
|
|
patch('collect_linux_dependencies.license_supplements', side_effect=supplements), \
|
|
patch('collect_linux_dependencies.qt_embedded_notices',
|
|
return_value=(embedded_metadata, json.loads(embedded_metadata.read_text()))), \
|
|
patch('collect_linux_dependencies.pdfium_supplemental_notices', return_value={}), \
|
|
patch('collect_linux_dependencies.sys.platform', 'linux'), \
|
|
patch('collect_linux_dependencies.platform.machine', return_value='x86_64'), \
|
|
patch('collect_linux_dependencies.platform.freedesktop_os_release', return_value={'ID': 'arch'}):
|
|
result = inventory(self.payload, output)
|
|
component = result['components']['qt6-translations']
|
|
self.assertEqual(component['distribution'], 'system-not-bundled')
|
|
self.assertEqual(component['version'], '6.11.2-1')
|
|
self.assertEqual(component['architecture'], 'any')
|
|
self.assertEqual(component['licenseLabelsFromPackage'], ['LGPL-3.0-only'])
|
|
self.assertEqual(component['source']['packageBase'], 'qt6-translations')
|
|
self.assertEqual(component['source']['recipeRepository'],
|
|
'https://gitlab.archlinux.org/archlinux/packaging/packages/qt6-translations')
|
|
self.assertEqual(component['source']['status'], 'not-collected')
|
|
self.assertEqual(component['noticeStatus'], 'collected-package-or-upstream-texts-not-a-compliance-verdict')
|
|
self.assertEqual(len(component['licenseTexts']), 1)
|
|
collected = component['licenseTexts'][0]
|
|
self.assertEqual((output / collected['path']).read_bytes(), notice.read_bytes())
|
|
self.assertEqual(collected['sha256'], sha256(notice))
|
|
rows = [row for row in result['systemFiles'] if row['package'] == 'qt6-translations']
|
|
self.assertEqual({row['path'] for row in rows}, {str(path) for path in catalogs})
|
|
for row in rows:
|
|
self.assertEqual(row['sha256'], sha256(Path(row['path'])))
|
|
self.assertEqual(row['size'], Path(row['path']).stat().st_size)
|
|
self.assertIn('qt6-translations 6.11.2-1 [system-not-bundled]', (output / 'NOTICE.txt').read_text())
|
|
self.assertFalse(any(path.suffix == '.qm' for path in output.rglob('*')))
|
|
embedded = result['components']['qt6-webengine']['embeddedResourceNotices']
|
|
self.assertEqual(embedded['noticeCount'], 7)
|
|
self.assertEqual(embedded['sourceResourceCount'], 13)
|
|
self.assertIs(embedded['completeChromiumNotices'], False)
|
|
self.assertEqual(embedded['runtimeDistribution'], 'system-not-bundled')
|
|
self.assertEqual(sha256(output / embedded['metadataPath']), sha256(embedded_metadata))
|
|
for row in result['components']['qt6-webengine']['licenseTexts']:
|
|
self.assertEqual(sha256(output / row['path']), row['sha256'])
|
|
|
|
def qt_notice_fixture(self):
|
|
destination = self.root / 'qt-notices'
|
|
shutil.copytree(ROOT / 'resources/licenses/qt-embedded-notices', destination)
|
|
metadata = json.loads((destination / 'sources.json').read_text())
|
|
return destination, metadata, [dict(row) for row in metadata['dataPacks']]
|
|
|
|
def test_qt_embedded_notices_bind_exact_target_and_seven_texts(self):
|
|
base, metadata, inventory_rows = self.qt_notice_fixture()
|
|
source, result = qt_embedded_notices('6.11.2', '6.11.2-1', inventory_rows, base)
|
|
self.assertEqual(source, base / 'sources.json')
|
|
self.assertEqual(result, metadata)
|
|
self.assertEqual(len(result['files']), 7)
|
|
self.assertEqual(sum(len(row['sourceResources']) for row in result['files']), 13)
|
|
self.assertIs(result['completeChromiumNotices'], False)
|
|
|
|
def test_qt_embedded_notices_reject_different_versions(self):
|
|
base, _, inventory_rows = self.qt_notice_fixture()
|
|
for qt, package in [('6.11.3', '6.11.2-1'), ('6.11.2', '6.11.2-2')]:
|
|
with self.subTest(qt=qt, package=package), self.assertRaisesRegex(ValueError, 'target version'):
|
|
qt_embedded_notices(qt, package, inventory_rows, base)
|
|
|
|
def test_qt_embedded_notices_reject_missing_duplicate_or_other_datapacks(self):
|
|
base, _, rows = self.qt_notice_fixture()
|
|
cases = [rows[1:], rows + [rows[0]]]
|
|
for key, wrong in [('path', '/usr/share/qt6/resources/other.pak'), ('package', 'other-package'),
|
|
('sha256', '0' * 64), ('size', rows[0]['size'] + 1)]:
|
|
cases.append([{**rows[0], key: wrong}, rows[1]])
|
|
for changed in cases:
|
|
with self.subTest(rows=changed), self.assertRaisesRegex(ValueError, 'DataPack inventory mismatch'):
|
|
qt_embedded_notices('6.11.2', '6.11.2-1', changed, base)
|
|
|
|
def test_qt_embedded_notices_reject_missing_or_modified_text(self):
|
|
base, metadata, rows = self.qt_notice_fixture()
|
|
path = base / metadata['files'][0]['name']
|
|
original = path.read_bytes()
|
|
path.unlink()
|
|
with self.assertRaisesRegex(ValueError, 'text missing'):
|
|
qt_embedded_notices('6.11.2', '6.11.2-1', rows, base)
|
|
for data in (b'X' + original[1:], original + b'\n'):
|
|
path.write_bytes(data)
|
|
with self.assertRaisesRegex(ValueError, 'digest/size mismatch'):
|
|
qt_embedded_notices('6.11.2', '6.11.2-1', rows, base)
|
|
|
|
def test_qt_embedded_notices_reject_unreviewed_metadata_changes(self):
|
|
base, metadata, rows = self.qt_notice_fixture()
|
|
path = base / 'sources.json'
|
|
for changed in ({**metadata, 'completeChromiumNotices': True},
|
|
{**metadata, 'files': metadata['files'][:-1]},
|
|
{**metadata, 'files': metadata['files'] + [metadata['files'][0]]},
|
|
{**metadata, 'files': [{**metadata['files'][0], 'name': '../outside'}] + metadata['files'][1:]},
|
|
{**metadata, 'dataPacks': [{**rows[0], 'sha256': '0' * 64}, rows[1]]}):
|
|
path.write_text(json.dumps(changed))
|
|
with self.assertRaisesRegex(ValueError, 'metadata changed'):
|
|
qt_embedded_notices('6.11.2', '6.11.2-1', rows, base)
|
|
path.unlink()
|
|
with self.assertRaisesRegex(ValueError, 'metadata missing'):
|
|
qt_embedded_notices('6.11.2', '6.11.2-1', rows, base)
|
|
path.write_bytes(b'x' * 65537)
|
|
with self.assertRaisesRegex(ValueError, 'oversized'):
|
|
qt_embedded_notices('6.11.2', '6.11.2-1', rows, base)
|
|
|
|
def test_upstream_license_supplement_requires_version_and_hash(self):
|
|
root = self.root / 'supplement'
|
|
(root / 'example').mkdir(parents=True)
|
|
path = root / 'example/COPYING'
|
|
path.write_text('upstream text fixture')
|
|
(root / 'sources.json').write_text(json.dumps([{'package': 'example', 'version': '1.2.3',
|
|
'files': {'COPYING': sha256(path)}}]))
|
|
self.assertEqual(len(license_supplements('example', '2:1.2.3-4', root)), 1)
|
|
with self.assertRaisesRegex(ValueError, 'version needs review'):
|
|
license_supplements('example', '1.2.4-1', root)
|
|
path.write_text('altered')
|
|
with self.assertRaisesRegex(ValueError, 'digest mismatch'):
|
|
license_supplements('example', '1.2.3-4', root)
|
|
|
|
def test_pdfium_notices_are_bound_to_binary_source_and_exact_files(self):
|
|
folder = self.payload / 'share/doc/docview/pdfium/supplemental'
|
|
folder.mkdir(parents=True)
|
|
(self.payload / 'lib').mkdir()
|
|
binary = self.payload / 'lib/libpdfium.so'
|
|
binary.write_bytes(b'synthetic PDFium')
|
|
lock = {'version': '1.2.3.4', 'upstreamCommit': 'a' * 40}
|
|
rows = []
|
|
for name in ['libcxx-LICENSE.txt', 'libcxxabi-LICENSE.txt']:
|
|
path = folder / name
|
|
path.write_bytes(b'synthetic notice\n')
|
|
rows.append({'name': name, 'sha256': sha256(path), 'size': path.stat().st_size})
|
|
metadata = {'schemaVersion': 1, 'pdfiumVersion': lock['version'],
|
|
'pdfiumUpstreamCommit': lock['upstreamCommit'], 'pdfiumLibrarySha256': sha256(binary),
|
|
'files': rows, 'scope': 'fixture', 'providerRecipeCommit': 'b' * 40}
|
|
manifest = folder / 'sources.json'
|
|
manifest.write_text(json.dumps(metadata))
|
|
self.assertEqual(len(pdfium_supplemental_notices(self.payload, lock)['files']), 2)
|
|
for key, wrong in [('version', '9.9.9.9'), ('upstreamCommit', 'c' * 40)]:
|
|
with self.subTest(key=key), self.assertRaisesRegex(ValueError, 'needs review'):
|
|
pdfium_supplemental_notices(self.payload, {**lock, key: wrong})
|
|
binary.write_bytes(b'different PDFium')
|
|
with self.assertRaisesRegex(ValueError, 'needs review'):
|
|
pdfium_supplemental_notices(self.payload, lock)
|
|
binary.write_bytes(b'synthetic PDFium')
|
|
(folder / rows[0]['name']).write_bytes(b'changed notice')
|
|
with self.assertRaisesRegex(ValueError, 'digest or size mismatch'):
|
|
pdfium_supplemental_notices(self.payload, lock)
|
|
for replacement in [[rows[0]], [rows[0], rows[0]], [{**rows[0], 'name': '../outside'}, rows[1]]]:
|
|
manifest.write_text(json.dumps({**metadata, 'files': replacement}))
|
|
with self.subTest(files=replacement), self.assertRaisesRegex(ValueError, 'notice set'):
|
|
pdfium_supplemental_notices(self.payload, lock)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|