@tsplc/build 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist/cli.js +1696 -0
  4. package/dist/cli.js.map +34 -0
  5. package/dist/index.js +1683 -0
  6. package/dist/index.js.map +31 -0
  7. package/dist/libraries.js +128 -0
  8. package/dist/libraries.js.map +10 -0
  9. package/docker/Dockerfile +173 -0
  10. package/docker/Dockerfile.dockerignore +18 -0
  11. package/docker/Dockerfile.source +15 -0
  12. package/docker/build-image +38 -0
  13. package/docker/codesys-build +5 -0
  14. package/docker/config.ts +8 -0
  15. package/docker/control-linux-sl/Dockerfile +38 -0
  16. package/docker/edge-gateway/Dockerfile +17 -0
  17. package/docker/entrypoint.sh +10 -0
  18. package/docker/install/NOTICE.md +21 -0
  19. package/docker/install/core.sh +36 -0
  20. package/docker/install/decode.c +12 -0
  21. package/docker/install/device/Runtime.ico +0 -0
  22. package/docker/install/device/Runtime.png +0 -0
  23. package/docker/install/device/codesyscontrol.devdesc.xml +1502 -0
  24. package/docker/install/extract.py +43 -0
  25. package/docker/install/helper-launcher.c +46 -0
  26. package/docker/install/install.sh +94 -0
  27. package/docker/install/prerequisites.sh +15 -0
  28. package/docker/install/verify.py +133 -0
  29. package/docker/install/verify.sh +34 -0
  30. package/docker/install/wine-ncrypt/COPYING.LIB +502 -0
  31. package/docker/install/wine-ncrypt/README.md +28 -0
  32. package/docker/install/wine-ncrypt/enable-oaep.py +34 -0
  33. package/docker/install/wine-ncrypt/main.c +619 -0
  34. package/docker/install/wine-ncrypt/oaep.patch +11 -0
  35. package/docker/install/wine-signal/README.md +22 -0
  36. package/docker/install/wine-signal/build.sh +10 -0
  37. package/docker/install/wine-signal/check.sh +34 -0
  38. package/docker/install/wine-signal/noop-i386.c +2 -0
  39. package/docker/install/wine-signal/signal-state.c +105 -0
  40. package/docker/install/wine-signal/tests/avx-registers.c +53 -0
  41. package/docker/install/wine-signal/tests/frame-and-handler.c +192 -0
  42. package/docker/install/wine-signal/tests/loader-i386.c +8 -0
  43. package/docker/install/wine-signal/tests/signal-return.c +47 -0
  44. package/docker/install/wine-signal/wine +4 -0
  45. package/docker/runner.ts +156 -0
  46. package/package.json +53 -4
  47. package/python/build.py +660 -0
  48. package/python/codesys_login.py +100 -0
  49. package/python/codesys_online.py +327 -0
  50. package/python/test_build.py +444 -0
  51. package/python/test_codesys_login.py +120 -0
  52. package/python/test_codesys_online.py +184 -0
  53. package/python/test_installer_packages.py +216 -0
  54. package/python/test_installer_verification.py +307 -0
  55. package/python/test_worker.py +142 -0
  56. package/python/worker.py +102 -0
  57. package/runtime/install.sh +74 -0
  58. package/src/__tests__/fixtures/xml/fb-simple.xml +40 -0
  59. package/src/__tests__/fixtures/xml/fb-state-machine.xml +86 -0
  60. package/src/__tests__/fixtures/xml/prg-cyclic.xml +44 -0
  61. package/src/artifacts.ts +77 -0
  62. package/src/build.ts +87 -0
  63. package/src/cli.ts +116 -0
  64. package/src/config.ts +12 -0
  65. package/src/controller-online.ts +297 -0
  66. package/src/controller-session.ts +298 -0
  67. package/src/diagnostics.ts +68 -0
  68. package/src/docker-build.ts +163 -0
  69. package/src/docker-context.ts +35 -0
  70. package/src/docker-run.ts +103 -0
  71. package/src/host/codesys-command.ts +45 -0
  72. package/src/host/codesys-host.ts +145 -0
  73. package/src/host/codesys-worker.ts +348 -0
  74. package/src/host/windows-path.ts +73 -0
  75. package/src/index.ts +43 -0
  76. package/src/io.ts +127 -0
  77. package/src/libraries.ts +166 -0
  78. package/src/package-path.ts +5 -0
  79. package/src/run.ts +133 -0
  80. package/src/runtime-status.ts +21 -0
  81. package/src/sources.ts +248 -0
  82. package/src/st/declarations.ts +229 -0
  83. package/src/st/lexer.ts +125 -0
  84. package/src/st/parse.ts +202 -0
  85. package/src/st/reader.ts +50 -0
@@ -0,0 +1,184 @@
1
+ import os
2
+ import sys
3
+ import tempfile
4
+ import unittest
5
+ from types import SimpleNamespace
6
+ from unittest.mock import Mock, patch
7
+
8
+
9
+ class TargetInvocationException(Exception):
10
+ def __init__(self, inner):
11
+ super().__init__('Exception has been thrown by the target of an invocation')
12
+ self.InnerException = inner
13
+
14
+
15
+ # Only .NET imports are unavailable in CPython. The operation orchestrator and
16
+ # compiler diagnostic gate below are the maintained code, not copied logic.
17
+ with patch.dict(sys.modules, {
18
+ 'System': SimpleNamespace(Activator=None, AppDomain=None, Array=None, Object=None,
19
+ Enum=SimpleNamespace(Parse=lambda kind, value: value),
20
+ Environment=SimpleNamespace(ProcessorCount=2)),
21
+ 'System.Net': SimpleNamespace(Dns=SimpleNamespace(GetHostAddresses=lambda host: ['127.0.0.1']),
22
+ IPAddress=SimpleNamespace(Parse=lambda address: address)),
23
+ 'System.Reflection': SimpleNamespace(BindingFlags=None, TargetInvocationException=TargetInvocationException),
24
+ }):
25
+ import codesys_online
26
+
27
+
28
+ class OnlineLifecycleTests(unittest.TestCase):
29
+ def setUp(self):
30
+ self.events = []
31
+ self.messages = []
32
+ self.ids = {'codeId': 'downloaded-code', 'dataId': 'downloaded-data'}
33
+ self.app = SimpleNamespace(
34
+ guid='application-guid', parent=SimpleNamespace(is_device=True),
35
+ get_children=lambda: [], get_name=lambda: 'Application',
36
+ is_uptodate=False, is_online_change_possible=True,
37
+ )
38
+ self.app.parent.set_gateway_and_ip_address = Mock()
39
+ self.project = SimpleNamespace(active_application=self.app, save=Mock(), close=Mock())
40
+ self.source_imported = False
41
+ self.wrapper = None
42
+ self.core = SimpleNamespace(Logout=Mock(), Login=Mock(return_value=True),
43
+ CreateBootProject=Mock(), Download=Mock())
44
+ self.boot_method = Mock()
45
+ self.boot_method.Invoke.side_effect = lambda core, args: core.CreateBootProject()
46
+ self.boot_api = SimpleNamespace(GetMethod=Mock(return_value=self.boot_method))
47
+
48
+ def clean():
49
+ self.events.append('clean')
50
+ def compile_source():
51
+ self.assertTrue(self.source_imported)
52
+ self.events.append('compile')
53
+ self.app.clean = Mock(side_effect=clean)
54
+ self.app.rebuild = Mock(side_effect=compile_source)
55
+ self.app.build = Mock(side_effect=compile_source)
56
+ self.app.generate_code = Mock(side_effect=lambda: self.events.append('generate'))
57
+
58
+ def wrapper(application):
59
+ self.events.append('online-wrapper')
60
+ self.wrapper = SimpleNamespace(
61
+ login=Mock(), start=Mock(), logout=Mock(), Dispose=Mock(),
62
+ read_value=Mock(return_value='42'),
63
+ )
64
+ return self.wrapper
65
+ self.engine = SimpleNamespace(
66
+ projects=SimpleNamespace(open=Mock(return_value=self.project)),
67
+ online=SimpleNamespace(
68
+ gateways=SimpleNamespace(add_new_gateway=Mock(return_value=SimpleNamespace(guid='gateway'))),
69
+ create_online_device=Mock(return_value=SimpleNamespace(Dispose=Mock())),
70
+ create_online_application=Mock(side_effect=wrapper),
71
+ ),
72
+ OnlineChangeOption=SimpleNamespace(Never='Never'),
73
+ Severity=SimpleNamespace(Error=1, FatalError=2),
74
+ system=SimpleNamespace(
75
+ get_message_categories=lambda: ['compiler'],
76
+ clear_messages=lambda category: self.messages.clear(),
77
+ get_message_objects=lambda category, severity: self.messages,
78
+ ),
79
+ )
80
+ def imported(*args):
81
+ self.events.append('import')
82
+ self.source_imported = True
83
+ replacements = {
84
+ 'apply_controller': Mock(side_effect=lambda *args: self.events.append('controller')),
85
+ 'import_objects': Mock(side_effect=imported),
86
+ 'core_application': Mock(return_value=self.core),
87
+ 'session_login': Mock(),
88
+ 'controller_ids': Mock(return_value=self.ids),
89
+ 'controller_state': Mock(return_value='run'),
90
+ 'require_current_boot_project': Mock(),
91
+ 'load_downloaded_reference': Mock(return_value=self.ids),
92
+ 'reconcile': Mock(),
93
+ 'generate_online_code': Mock(),
94
+ 'consolidated_manager': Mock(return_value=SimpleNamespace(GetCompileContext=Mock())),
95
+ 'context_ids': Mock(return_value=self.ids),
96
+ 'loaded_type': Mock(side_effect=lambda name:
97
+ self.boot_api if name == '_3S.CoDeSys.Core.Online.IOnlineApplication3' else None),
98
+ }
99
+ patcher = patch.multiple(codesys_online, **replacements)
100
+ patcher.start()
101
+ self.addCleanup(patcher.stop)
102
+ self.request = {
103
+ 'project': 'Application.project', 'operation': 'download', 'symbols': False,
104
+ 'objects': [], 'removeObjects': ['Visualization Manager'],
105
+ 'online': {'gateway': {'host': 'gateway', 'port': 1217},
106
+ 'device': {'host': 'plc', 'port': 11740}},
107
+ }
108
+
109
+ def test_download_rebuilds_imported_tree_before_online_login(self):
110
+ result = codesys_online.run_online(self.engine, self.request)
111
+ self.assertEqual(self.events, ['import', 'clean', 'compile', 'generate', 'online-wrapper'])
112
+ self.assertEqual(result['controllerIds'], self.ids)
113
+ self.core.CreateBootProject.assert_called_once()
114
+ self.boot_api.GetMethod.assert_called_once_with('CreateBootProject')
115
+ self.boot_method.Invoke.assert_called_once_with(self.core, None)
116
+ self.project.close.assert_called_once()
117
+ self.app.build.assert_not_called()
118
+
119
+ def test_download_switches_the_template_controller_before_importing(self):
120
+ self.request['controller'] = {'type': 4102, 'id': '0000 0005', 'version': '4.22.0.0'}
121
+ codesys_online.run_online(self.engine, self.request)
122
+ self.assertEqual(self.events[:2], ['controller', 'import'])
123
+
124
+ def test_invalid_full_download_source_never_creates_online_wrapper_or_transfers(self):
125
+ self.app.rebuild.side_effect = RuntimeError('Invalid source declaration')
126
+ with self.assertRaisesRegex(RuntimeError, 'Invalid source declaration'):
127
+ codesys_online.run_online(self.engine, self.request)
128
+ self.engine.online.create_online_application.assert_not_called()
129
+ self.core.CreateBootProject.assert_not_called()
130
+ self.project.close.assert_called_once()
131
+
132
+ def test_online_change_keeps_the_downloaded_reference_and_builds_incrementally(self):
133
+ # The downloaded project already carries the controller.
134
+ self.request.update(operation='online-change', controller={
135
+ 'type': 4102, 'id': '0000 0005', 'version': '4.22.0.0',
136
+ }, baseline={
137
+ 'applicationGuid': self.app.guid, 'controllerIds': self.ids, 'objects': [],
138
+ })
139
+ result = codesys_online.run_online(self.engine, self.request)
140
+ self.assertEqual(self.events, ['online-wrapper', 'import', 'compile'])
141
+ self.app.clean.assert_not_called()
142
+ self.app.rebuild.assert_not_called()
143
+ self.app.generate_code.assert_not_called()
144
+ self.core.Download.assert_called_once_with(True, 'UseProjectSetting')
145
+ self.assertEqual(result['state'], 'run')
146
+
147
+ def test_full_download_rejects_library_errors_reported_after_transfer(self):
148
+ def report_library_error(core):
149
+ self.messages.append(SimpleNamespace(text="Could not open library '#VisuShared'."))
150
+ codesys_online.session_login.side_effect = report_library_error
151
+ with self.assertRaisesRegex(RuntimeError, "Could not open library '#VisuShared'"):
152
+ codesys_online.run_online(self.engine, self.request)
153
+ self.core.CreateBootProject.assert_called_once()
154
+ self.project.close.assert_called_once()
155
+
156
+ def test_full_download_reports_device_placeholders_a_login_cannot_resolve(self):
157
+ def report_unresolved_placeholder(core):
158
+ self.messages.append(SimpleNamespace(text="Could not open library '#System_VisuInputs'. "
159
+ "(Reason: The placeholder library 'System_VisuInputs' could not be resolved.)"))
160
+ codesys_online.session_login.side_effect = report_unresolved_placeholder
161
+ with tempfile.TemporaryDirectory() as directory:
162
+ self.request['progress'] = os.path.join(directory, 'progress.log')
163
+ result = codesys_online.run_online(self.engine, self.request)
164
+ with open(self.request['progress']) as log:
165
+ self.assertIn('nothing resolves: #System_VisuInputs', log.read())
166
+ self.assertEqual(result['state'], 'run')
167
+
168
+ def test_boot_failure_preserves_vendor_clr_stack_and_never_confirms_completion(self):
169
+ stack = ('System.ArgumentException: The path is not of a legal form.\n'
170
+ ' at System.IO.Path.GetFullPath(String path)\n'
171
+ ' at Vendor.BootProvider.PrepareTransfer()')
172
+ self.core.CreateBootProject.side_effect = TargetInvocationException(
173
+ SimpleNamespace(ToString=lambda: stack))
174
+ with self.assertRaises(RuntimeError) as failure:
175
+ codesys_online.run_online(self.engine, self.request)
176
+ self.assertEqual(str(failure.exception), 'Controller boot project creation failed:\n' + stack)
177
+ self.core.CreateBootProject.assert_called_once()
178
+ codesys_online.require_current_boot_project.assert_not_called()
179
+ codesys_online.session_login.assert_not_called()
180
+ self.project.close.assert_called_once()
181
+
182
+
183
+ if __name__ == '__main__':
184
+ unittest.main()
@@ -0,0 +1,216 @@
1
+ """Exercise the installer's extraction against the pinned MSI package inventory."""
2
+ import csv
3
+ import io
4
+ import json
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import unittest
11
+ import zipfile
12
+ from pathlib import Path
13
+
14
+
15
+ installer = (Path(__file__).parent.parent / 'docker' / 'install' / 'install.sh').read_text()
16
+ marker = 'python3 - "$msi" "$work/setup-files.tsv" "$WINEPREFIX/drive_c" <<\'PY\'\n'
17
+ extractor = installer.split(marker, 1)[1].split('\nPY\n', 1)[0]
18
+
19
+ # These identities are from the SP22 Patch 3 MSI's ISSetupFile table.
20
+ packages = {
21
+ 'CODESYS Scripting 4.2.0.0': 'scripting',
22
+ 'CODESYS RISC Front End 4.0.2.0': 'risc',
23
+ 'CODESYS Code Generator ARM64 4.0.1.0': 'arm64',
24
+ 'CODESYS Library Dependency Inspection 1.1.0.0': 'library-inspection',
25
+ 'CODESYS Base Libraries 4.0.1.0': 'base-libraries',
26
+ 'CODESYS Visualization Support 4.7.0.0': 'visualization-support',
27
+ 'CODESYS Math Libraries 4.0.0.0': 'math-libraries',
28
+ 'CODESYS Recipes 4.7.0.0': 'recipes',
29
+ 'CODESYS Trace 4.4.0.0': 'trace',
30
+ 'CODESYS Communication 4.8.0.0': 'communication',
31
+ 'CODESYS Visualization 4.10.0.0': 'visualization',
32
+ 'CODESYS Ethernet Adapter 4.2.0.0': 'ethernet-adapter',
33
+ 'CODESYS EDS Import 4.3.0.0': 'eds-import',
34
+ 'CODESYS NetX 4.1.0.0': 'netx',
35
+ 'CODESYS EtherNetIP 4.9.1.0': 'ethernetip',
36
+ 'CODESYS SoftMotion 4.20.2.0': 'softmotion',
37
+ }
38
+
39
+
40
+ class InstallerPackageTests(unittest.TestCase):
41
+ def setUp(self):
42
+ temporary = tempfile.TemporaryDirectory()
43
+ self.addCleanup(temporary.cleanup)
44
+ self.root = Path(temporary.name)
45
+ self.output = self.root / 'output'
46
+ self.output.mkdir()
47
+ self.table = self.root / 'setup-files.tsv'
48
+ self.rows = []
49
+ for name, output in packages.items():
50
+ stream = 'ISSetupFile.' + output
51
+ self.rows.append({'FileName': 'Packages\\' + name + '.package', 'Stream': stream})
52
+ with zipfile.ZipFile(self.root / stream, 'w') as archive:
53
+ archive.writestr('package.manifest', '<Package/>')
54
+ tool = self.root / 'msiinfo'
55
+ tool.write_text(
56
+ '#!' + sys.executable + '\n'
57
+ 'import pathlib, sys\n'
58
+ 'sys.stdout.buffer.write((pathlib.Path(sys.argv[2]) / sys.argv[3]).read_bytes())\n'
59
+ )
60
+ tool.chmod(0o755)
61
+
62
+ def extract(self):
63
+ with self.table.open('w', newline='') as output:
64
+ writer = csv.DictWriter(output, ['FileName', 'Stream'], delimiter='\t')
65
+ writer.writeheader()
66
+ writer.writerows(self.rows)
67
+ return subprocess.run(
68
+ [sys.executable, '-c', extractor, str(self.root), str(self.table), str(self.output)],
69
+ env={**os.environ, 'PATH': str(self.root) + os.pathsep + os.environ['PATH']},
70
+ capture_output=True,
71
+ text=True,
72
+ )
73
+
74
+ def test_extracts_complete_symbol_publishing_dependencies_in_installable_order(self):
75
+ result = self.extract()
76
+ self.assertEqual(result.returncode, 0, result.stderr)
77
+ self.assertEqual(
78
+ {path.stem for path in self.output.iterdir()},
79
+ set(packages.values()),
80
+ )
81
+ # Component selection must preserve the original vendor archive bytes.
82
+ for name in packages.values():
83
+ self.assertEqual(
84
+ (self.output / (name + '.package')).read_bytes(),
85
+ (self.root / ('ISSetupFile.' + name)).read_bytes(),
86
+ )
87
+ order = re.search(r'for package in (.+); do', installer).group(1).split()
88
+ self.assertEqual(set(order), set(packages.values()))
89
+ self.assertLess(order.index('library-inspection'), order.index('math-libraries'))
90
+ for prerequisite in (
91
+ 'visualization-support', 'trace', 'recipes', 'communication',
92
+ 'base-libraries', 'math-libraries',
93
+ ):
94
+ self.assertLess(order.index(prerequisite), order.index('visualization'))
95
+
96
+ def test_visualization_uses_official_headless_components(self):
97
+ self.assertIn('set "packageComponents="', installer)
98
+ self.assertIn(
99
+ 'if "%~1"=="visualization" set "packageComponents=--components=2;3;4"',
100
+ installer,
101
+ )
102
+ self.assertIn('"C:\\%~1.package" %packageComponents%', installer)
103
+
104
+ def test_rejects_missing_visualization_prerequisite_before_package_installation(self):
105
+ self.rows = [row for row in self.rows if row['Stream'] != 'ISSetupFile.trace']
106
+ result = self.extract()
107
+ self.assertNotEqual(result.returncode, 0)
108
+ self.assertIn('Missing bundled packages: Packages\\CODESYS Trace 4.4.0.0.package', result.stderr)
109
+
110
+ def test_rejects_corrupt_embedded_package_data(self):
111
+ archive = io.BytesIO()
112
+ with zipfile.ZipFile(archive, 'w') as package:
113
+ package.writestr('package.manifest', 'unchanged manifest')
114
+ corrupted = archive.getvalue().replace(b'unchanged manifest', b'corrupted manifest', 1)
115
+ (self.root / 'ISSetupFile.visualization').write_bytes(corrupted)
116
+ result = self.extract()
117
+ self.assertNotEqual(result.returncode, 0)
118
+ self.assertIn('Corrupt package: Packages\\CODESYS Visualization 4.10.0.0.package', result.stderr)
119
+
120
+ def test_generated_package_command_captures_windows_status_before_printing(self):
121
+ prefix = self.output / 'drive_c'
122
+ prefix.mkdir()
123
+ start = installer.index("printf '%s\\r\\n' '@echo off'")
124
+ end = installer.index('# Symbol publishing', start)
125
+ result = subprocess.run(
126
+ ['sh', '-eu', '-c', installer[start:end]],
127
+ env={**os.environ, 'WINEPREFIX': str(self.output)},
128
+ capture_output=True, text=True,
129
+ )
130
+ self.assertEqual(result.returncode, 0, result.stderr)
131
+ command = (prefix / 'install-package.cmd').read_bytes().decode().splitlines()
132
+ call = next(index for index, line in enumerate(command) if line.startswith('PackageManagerCLI.exe '))
133
+ self.assertEqual(command[call + 1], 'set "packageStatus=%errorlevel%"')
134
+ self.assertEqual(command[call + 2],
135
+ 'if not "%packageStatus%"=="0" echo PackageManagerCLI Windows exit status: %packageStatus%')
136
+ self.assertEqual(command[call + 3], 'exit /b %packageStatus%')
137
+
138
+ def run_package_loop(self, failed_package, log_bytes='Finalizing the original package'.encode('utf-16'), failed_logger=False):
139
+ prefix = self.output / 'drive_c'
140
+ prefix.mkdir()
141
+ log = prefix / 'package-install.log'
142
+ if log_bytes is None:
143
+ log.mkdir() # A path that exists but cannot be read as a log file.
144
+ else:
145
+ log.write_bytes(log_bytes)
146
+ calls = self.root / 'calls.jsonl'
147
+ tool = self.root / 'timeout'
148
+ tool.write_text(
149
+ '#!' + sys.executable + '\n'
150
+ 'import json, os, pathlib, shlex, sys\n'
151
+ 'command = sys.argv[sys.argv.index("-c") + 1]\n'
152
+ 'package = shlex.split(command)[-1]\n'
153
+ 'debug = os.environ.get("WINEDEBUG", "")\n'
154
+ 'with pathlib.Path(os.environ["INSTALLER_TEST_CALLS"]).open("a") as out:\n'
155
+ ' out.write(json.dumps({"package": package, "debug": debug}) + "\\n")\n'
156
+ 'if package == os.environ["INSTALLER_TEST_FAILURE"]:\n'
157
+ ' if "err+seh" in debug: print("fixture err:seh:NtRaiseException Unhandled exception code c0000005", file=sys.stderr)\n'
158
+ ' sys.exit(5)\n'
159
+ )
160
+ tool.chmod(0o755)
161
+ if failed_logger:
162
+ logger = self.root / 'python3'
163
+ logger.write_text('#!/bin/sh\necho "Fixture log helper failed" >&2\nexit 19\n')
164
+ logger.chmod(0o755)
165
+ start = installer.index('for package in ')
166
+ end = installer.index('\ndone\n', start) + len('\ndone\n')
167
+ # Exercise the real shell failure/logging loop with a controlled process result.
168
+ script = installer[:installer.index('work=/tmp/codesys-setup')] + installer[start:end]
169
+ result = subprocess.run(
170
+ ['sh', '-c', script],
171
+ env={**os.environ, 'WINEPREFIX': str(self.output), 'WINEDEBUG': '-all',
172
+ 'PATH': str(self.root) + os.pathsep + os.environ['PATH'],
173
+ 'INSTALLER_TEST_CALLS': str(calls), 'INSTALLER_TEST_FAILURE': failed_package},
174
+ capture_output=True, text=True,
175
+ )
176
+ return result, [json.loads(line) for line in calls.read_text().splitlines()]
177
+
178
+ def test_package_failure_keeps_exit_status_and_fatal_diagnostics_without_continuing(self):
179
+ result, calls = self.run_package_loop('visualization-support')
180
+ self.assertEqual(result.returncode, 5)
181
+ self.assertIn('Unhandled exception code c0000005', result.stderr)
182
+ self.assertIn('Package visualization-support installer exited with status 5', result.stdout)
183
+ self.assertIn('Finalizing the original package', result.stdout)
184
+ self.assertEqual(calls[-1]['package'], 'visualization-support')
185
+ self.assertNotIn('Installed visualization-support package', result.stdout)
186
+ self.assertTrue(all(call['debug'] == '-all,err+seh' for call in calls))
187
+
188
+ def test_successful_package_loop_remains_successful(self):
189
+ result, calls = self.run_package_loop('')
190
+ self.assertEqual(result.returncode, 0, result.stderr)
191
+ self.assertEqual({call['package'] for call in calls}, set(packages.values()))
192
+ self.assertEqual(result.stdout.count('Installed '), len(packages))
193
+ self.assertEqual(result.stderr, '')
194
+
195
+ def test_truncated_utf16_log_never_masks_package_failure(self):
196
+ result, calls = self.run_package_loop('visualization-support', b'\xff\xfe\x00')
197
+ self.assertEqual(result.returncode, 5)
198
+ self.assertIn('Package visualization-support installer exited with status 5', result.stdout)
199
+ self.assertNotIn('UnicodeDecodeError', result.stderr)
200
+ self.assertEqual(calls[-1]['package'], 'visualization-support')
201
+
202
+ def test_unreadable_package_log_never_masks_package_failure(self):
203
+ result, calls = self.run_package_loop('visualization-support', None)
204
+ self.assertEqual(result.returncode, 5)
205
+ self.assertIn('Could not read package install log', result.stderr)
206
+ self.assertEqual(calls[-1]['package'], 'visualization-support')
207
+
208
+ def test_failed_log_helper_never_masks_package_failure(self):
209
+ result, calls = self.run_package_loop('visualization-support', failed_logger=True)
210
+ self.assertEqual(result.returncode, 5)
211
+ self.assertIn('Fixture log helper failed', result.stderr)
212
+ self.assertEqual(calls[-1]['package'], 'visualization-support')
213
+
214
+
215
+ if __name__ == '__main__':
216
+ unittest.main()