@tsplc/build 0.0.0-stage → 0.1.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 (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist/cli.js +1652 -0
  4. package/dist/cli.js.map +34 -0
  5. package/dist/index.js +1552 -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 +17 -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/entrypoint.sh +10 -0
  17. package/docker/install/NOTICE.md +21 -0
  18. package/docker/install/core.sh +36 -0
  19. package/docker/install/decode.c +12 -0
  20. package/docker/install/device/Runtime.ico +0 -0
  21. package/docker/install/device/Runtime.png +0 -0
  22. package/docker/install/device/codesyscontrol.devdesc.xml +1502 -0
  23. package/docker/install/extract.py +43 -0
  24. package/docker/install/helper-launcher.c +46 -0
  25. package/docker/install/install.sh +94 -0
  26. package/docker/install/prerequisites.sh +15 -0
  27. package/docker/install/verify.py +133 -0
  28. package/docker/install/verify.sh +34 -0
  29. package/docker/install/wine-ncrypt/COPYING.LIB +502 -0
  30. package/docker/install/wine-ncrypt/README.md +28 -0
  31. package/docker/install/wine-ncrypt/enable-oaep.py +34 -0
  32. package/docker/install/wine-ncrypt/main.c +619 -0
  33. package/docker/install/wine-ncrypt/oaep.patch +11 -0
  34. package/docker/install/wine-signal/README.md +22 -0
  35. package/docker/install/wine-signal/build.sh +10 -0
  36. package/docker/install/wine-signal/check.sh +34 -0
  37. package/docker/install/wine-signal/noop-i386.c +2 -0
  38. package/docker/install/wine-signal/signal-state.c +105 -0
  39. package/docker/install/wine-signal/tests/avx-registers.c +53 -0
  40. package/docker/install/wine-signal/tests/frame-and-handler.c +192 -0
  41. package/docker/install/wine-signal/tests/loader-i386.c +8 -0
  42. package/docker/install/wine-signal/tests/signal-return.c +47 -0
  43. package/docker/install/wine-signal/wine +4 -0
  44. package/docker/runner.ts +131 -0
  45. package/package.json +53 -4
  46. package/python/build.py +655 -0
  47. package/python/codesys_online.py +300 -0
  48. package/python/test_build.py +444 -0
  49. package/python/test_codesys_online.py +170 -0
  50. package/python/test_installer_packages.py +216 -0
  51. package/python/test_installer_verification.py +307 -0
  52. package/python/test_worker.py +142 -0
  53. package/python/worker.py +89 -0
  54. package/runtime/install.sh +74 -0
  55. package/src/__tests__/fixtures/xml/fb-simple.xml +40 -0
  56. package/src/__tests__/fixtures/xml/fb-state-machine.xml +86 -0
  57. package/src/__tests__/fixtures/xml/prg-cyclic.xml +44 -0
  58. package/src/artifacts.ts +77 -0
  59. package/src/build.ts +87 -0
  60. package/src/cli.ts +116 -0
  61. package/src/config.ts +12 -0
  62. package/src/controller-online.ts +143 -0
  63. package/src/controller-session.ts +229 -0
  64. package/src/diagnostics.ts +66 -0
  65. package/src/docker-build.ts +148 -0
  66. package/src/docker-context.ts +35 -0
  67. package/src/docker-run.ts +104 -0
  68. package/src/host/codesys-command.ts +45 -0
  69. package/src/host/codesys-host.ts +145 -0
  70. package/src/host/codesys-worker.ts +345 -0
  71. package/src/host/windows-path.ts +73 -0
  72. package/src/index.ts +26 -0
  73. package/src/io.ts +127 -0
  74. package/src/libraries.ts +166 -0
  75. package/src/package-path.ts +5 -0
  76. package/src/run.ts +133 -0
  77. package/src/runtime-status.ts +21 -0
  78. package/src/sources.ts +248 -0
  79. package/src/st/declarations.ts +229 -0
  80. package/src/st/lexer.ts +125 -0
  81. package/src/st/parse.ts +202 -0
  82. package/src/st/reader.ts +50 -0
@@ -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()
@@ -0,0 +1,307 @@
1
+ import importlib.util
2
+ import io
3
+ import json
4
+ import os
5
+ import runpy
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+ from pathlib import Path
11
+ from contextlib import redirect_stderr, redirect_stdout
12
+ from types import SimpleNamespace
13
+ from unittest.mock import Mock, patch
14
+
15
+
16
+ path = Path(__file__).parent.parent / 'docker' / 'install' / 'verify.py'
17
+ spec = importlib.util.spec_from_file_location('installer_verification', path)
18
+ verification = importlib.util.module_from_spec(spec)
19
+ spec.loader.exec_module(verification)
20
+
21
+
22
+ # The bundled ARM64 Virtual Control description, which leaves byte-addressing at its default.
23
+ DEVICE_DESCRIPTION = (
24
+ Path(__file__).parent.parent / 'docker' / 'install' / 'device' / 'codesyscontrol.devdesc.xml'
25
+ ).read_text(encoding='utf-8')
26
+
27
+
28
+ def target_setting(name, value):
29
+ return (
30
+ '<ts:section name="memory-layout"><ts:setting name="{0}" type="boolean" access="hide">'
31
+ '<ts:value>{1}</ts:value></ts:setting></ts:section>'
32
+ ).format(name, value)
33
+
34
+
35
+ class InstallerVerificationTests(unittest.TestCase):
36
+ def setUp(self):
37
+ patcher = patch.dict(sys.modules, {
38
+ 'System': SimpleNamespace(Guid=lambda value: value),
39
+ })
40
+ patcher.start()
41
+ self.addCleanup(patcher.stop)
42
+ self.description = DEVICE_DESCRIPTION
43
+ opener = patch.object(io, 'open', side_effect=lambda path, encoding: io.StringIO(self.description))
44
+ opener.start()
45
+ self.addCleanup(opener.stop)
46
+ self.events = []
47
+ self.messages = []
48
+ self.program = SimpleNamespace(
49
+ textual_declaration=SimpleNamespace(replace=Mock()),
50
+ textual_implementation=SimpleNamespace(replace=Mock()),
51
+ )
52
+ self.task = SimpleNamespace(pous=SimpleNamespace(add=Mock()))
53
+ self.manager = SimpleNamespace(
54
+ get_libraries=Mock(side_effect=lambda **args: self.events.append('libraries')),
55
+ )
56
+ task_configuration = SimpleNamespace(create_task=Mock(return_value=self.task))
57
+ self.application = SimpleNamespace(
58
+ create_program=Mock(return_value=self.program),
59
+ create_task_configuration=Mock(return_value=task_configuration),
60
+ create_symbol_config=Mock(),
61
+ get_library_manager=Mock(return_value=self.manager),
62
+ clean=Mock(side_effect=lambda: self.events.append('clean')),
63
+ rebuild=Mock(side_effect=lambda: self.events.append('compile')),
64
+ generate_code=Mock(side_effect=lambda: self.events.append('generate')),
65
+ create_boot_application=Mock(side_effect=lambda *args: self.events.append('boot')),
66
+ )
67
+ self.project = SimpleNamespace(
68
+ active_application=self.application,
69
+ add=Mock(),
70
+ save=Mock(),
71
+ close=Mock(side_effect=lambda: self.events.append('close')),
72
+ )
73
+
74
+ def opened(*args, **kwargs):
75
+ self.events.append('open')
76
+ return self.project
77
+
78
+ self.engine = SimpleNamespace(
79
+ projects=SimpleNamespace(
80
+ create=Mock(return_value=self.project),
81
+ open=Mock(side_effect=opened),
82
+ ),
83
+ device_repository=SimpleNamespace(
84
+ import_device=Mock(), sources=['repository'],
85
+ create_device_identification=lambda *identity: identity, get_device=Mock(return_value=object()),
86
+ ),
87
+ KindOfTask=SimpleNamespace(Cyclic='cyclic'),
88
+ Severity=SimpleNamespace(Error=1, FatalError=2),
89
+ ScriptPromptHandling=SimpleNamespace(LogPrompts='log'),
90
+ system=SimpleNamespace(
91
+ get_message_categories=lambda: ['compiler', 'libraries'],
92
+ get_message_objects=lambda category, severity: self.messages if category == 'libraries' else [],
93
+ clear_messages=lambda category: self.messages.clear(),
94
+ delay=Mock(side_effect=lambda duration: self.events.append('delay')),
95
+ ),
96
+ )
97
+
98
+ def test_compiles_published_program_for_each_controller_and_reopens_the_project(self):
99
+ verification.verify(self.engine)
100
+ self.assertEqual(
101
+ [call.kwargs['path'] for call in self.engine.device_repository.import_device.call_args_list],
102
+ [r'C:\codesys-device\codesyscontrol.devdesc.xml', r'C:\codesys-device-linux-sl\codesyscontrol.devdesc.xml'],
103
+ )
104
+ self.assertEqual([call.args for call in self.project.add.call_args_list], [
105
+ ('Device', 4096, '0000 1008', '4.22.0.0'), ('Device', 4102, '0000 0005', '4.22.0.0'),
106
+ ])
107
+ self.assertIn(
108
+ "{attribute 'symbol' := 'readwrite'}",
109
+ self.program.textual_declaration.replace.call_args.args[0],
110
+ )
111
+ self.assertEqual(self.task.kind_of_task, 'cyclic')
112
+ self.assertEqual(self.task.interval, '20')
113
+ self.task.pous.add.assert_called_with('PLC_PRG')
114
+ self.assertEqual(self.application.create_symbol_config.call_args.args[:2], (True, True))
115
+ self.assertEqual(self.events, [
116
+ 'clean', 'compile', 'generate', 'boot', 'close',
117
+ 'open', 'libraries', 'delay', 'close',
118
+ 'clean', 'compile', 'generate', 'boot', 'close',
119
+ ])
120
+ self.manager.get_libraries.assert_called_with(recursive=True)
121
+
122
+ def test_reads_byte_addressing_like_codesys(self):
123
+ self.assertFalse(verification.uses_byte_addressing(DEVICE_DESCRIPTION))
124
+ self.assertTrue(verification.uses_byte_addressing(target_setting('byte-addressing', 'TRUE')))
125
+ self.assertFalse(verification.uses_byte_addressing(target_setting('byte-addressing', 'false')))
126
+ self.assertFalse(verification.uses_byte_addressing(target_setting('bit-byte-addressing', 'true')))
127
+
128
+ def test_rejects_a_controller_that_numbers_words_by_byte_before_importing_it(self):
129
+ self.description = target_setting('byte-addressing', 'true')
130
+ with self.assertRaisesRegex(RuntimeError, 'sets byte-addressing'):
131
+ verification.verify(self.engine)
132
+ self.engine.device_repository.import_device.assert_not_called()
133
+
134
+ def test_rejects_transitive_library_errors_reported_when_reopening_saved_project(self):
135
+ def report_late_errors(duration):
136
+ self.messages.append(SimpleNamespace(
137
+ text="Could not open library '#VisuShared'.",
138
+ project=SimpleNamespace(path=r'C:\Managed Libraries\AlarmManager.compiled-library-v3'),
139
+ ))
140
+ self.engine.system.delay.side_effect = report_late_errors
141
+ with self.assertRaisesRegex(
142
+ RuntimeError,
143
+ "AlarmManager.compiled-library-v3: Could not open library '#VisuShared'",
144
+ ):
145
+ verification.verify(self.engine)
146
+ self.engine.projects.open.assert_called_once()
147
+ self.assertEqual(self.project.close.call_count, 2)
148
+
149
+ def test_compile_error_stops_before_boot_artifact_and_saved_project_reopen(self):
150
+ self.application.rebuild.side_effect = lambda: self.messages.append(SimpleNamespace(text='Invalid source'))
151
+ with self.assertRaisesRegex(RuntimeError, 'Invalid source'):
152
+ verification.verify(self.engine)
153
+ self.application.generate_code.assert_not_called()
154
+ self.application.create_boot_application.assert_not_called()
155
+ self.engine.projects.open.assert_not_called()
156
+ self.project.close.assert_called_once()
157
+
158
+ def run_verifier_main(self):
159
+ owner = self
160
+
161
+ class Kernel:
162
+ @property
163
+ def ExitProcess(self):
164
+ owner.assertEqual(owner.events, [], 'Bind native exit before compiler work')
165
+ return lambda status: sys.exit(status)
166
+
167
+ output = io.StringIO()
168
+ errors = io.StringIO()
169
+ with patch.dict(sys.modules, {
170
+ 'ctypes': SimpleNamespace(windll=SimpleNamespace(kernel32=Kernel())),
171
+ 'scriptengine': self.engine,
172
+ }), redirect_stdout(output), redirect_stderr(errors):
173
+ with self.assertRaises(SystemExit) as exit:
174
+ runpy.run_path(str(path), run_name='__main__')
175
+ return exit.exception.code, output.getvalue(), errors.getvalue()
176
+
177
+ def test_main_prebinds_native_exit_and_returns_zero_after_verification(self):
178
+ status, output, errors = self.run_verifier_main()
179
+ self.assertEqual(status, 0)
180
+ self.assertIn('project reopen: PASS', output)
181
+ self.assertEqual(errors, '')
182
+
183
+ def test_main_keeps_compile_failure_and_returns_one_using_prebound_native_exit(self):
184
+ self.application.rebuild.side_effect = lambda: self.messages.append(SimpleNamespace(text='Invalid source'))
185
+ status, output, errors = self.run_verifier_main()
186
+ self.assertEqual(status, 1)
187
+ self.assertNotIn('PASS', output)
188
+ self.assertIn('Invalid source', errors)
189
+ self.application.create_boot_application.assert_not_called()
190
+
191
+
192
+ class InstallerVerificationCommandTests(unittest.TestCase):
193
+ """Run the maintained shell gate with only Wine's process boundary replaced."""
194
+
195
+ def setUp(self):
196
+ temporary = tempfile.TemporaryDirectory(prefix='tsplc verification ')
197
+ self.addCleanup(temporary.cleanup)
198
+ self.root = Path(temporary.name)
199
+ self.prefix = self.root / 'wine prefix'
200
+ (self.prefix / 'drive_c').mkdir(parents=True)
201
+ self.calls = self.root / 'calls.jsonl'
202
+ timeout = self.root / 'timeout'
203
+ timeout.write_text(
204
+ '#!' + sys.executable + '\n'
205
+ 'import json, os, pathlib, sys\n'
206
+ 'prefix = pathlib.Path(os.environ["WINEPREFIX"]) / "drive_c"\n'
207
+ 'for extension, content in json.loads(os.environ["TSPLC_VERIFICATION_FILES"]).items():\n'
208
+ ' (prefix / ("codesys-verification." + extension)).write_text(content)\n'
209
+ 'with pathlib.Path(os.environ["TSPLC_VERIFICATION_CALLS"]).open("a") as output:\n'
210
+ ' output.write(json.dumps({"command": sys.argv[1:], "debug": os.environ["WINEDEBUG"]}) + "\\n")\n'
211
+ 'pathlib.Path(sys.argv[-1]).write_text(os.environ["TSPLC_VERIFICATION_TRANSCRIPT"])\n'
212
+ 'print("Fixture vendor verification result")\n'
213
+ 'sys.exit(int(os.environ["TSPLC_VERIFICATION_STATUS"]))\n'
214
+ )
215
+ timeout.chmod(0o755)
216
+ wineserver = self.root / 'wineserver'
217
+ wineserver.write_text(
218
+ '#!' + sys.executable + '\n'
219
+ 'import json, os, pathlib, sys\n'
220
+ 'with pathlib.Path(os.environ["TSPLC_VERIFICATION_CALLS"]).open("a") as output:\n'
221
+ ' output.write(json.dumps({"wineserver": sys.argv[1:]}) + "\\n")\n'
222
+ )
223
+ wineserver.chmod(0o755)
224
+
225
+ def run_verifier(self, status=0, files=None, transcript='Fixture vendor verification result\n'):
226
+ if files is None:
227
+ files = {extension: 'Vendor artifact' for extension in ('project', 'app', 'crc')}
228
+ result = subprocess.run(
229
+ ['sh', str(path.with_name('verify.sh'))],
230
+ env={**os.environ, 'PATH': str(self.root) + os.pathsep + os.environ['PATH'],
231
+ 'WINEPREFIX': str(self.prefix), 'TSPLC_VERIFICATION_STATUS': str(status),
232
+ 'TSPLC_VERIFICATION_FILES': json.dumps(files),
233
+ 'TSPLC_VERIFICATION_TRANSCRIPT': transcript,
234
+ 'TSPLC_VERIFICATION_CALLS': str(self.calls)},
235
+ capture_output=True, text=True,
236
+ )
237
+ calls = [json.loads(line) for line in self.calls.read_text().splitlines()] if self.calls.exists() else []
238
+ return result, calls
239
+
240
+ def test_success_requires_all_boot_files_before_stopping_the_template_wine_server(self):
241
+ result, calls = self.run_verifier()
242
+ self.assertEqual(result.returncode, 0, result.stderr)
243
+ self.assertEqual(calls[0]['debug'], '-all,err+seh')
244
+ self.assertEqual(calls[0]['command'], [
245
+ '--foreground', '15m', 'script', '-q', '-e', '-c',
246
+ "wine cmd /d /c 'C:\\verify.cmd'", str(self.prefix / 'drive_c' / 'codesys-verification.log'),
247
+ ])
248
+ self.assertEqual(calls[1:], [{'wineserver': ['-k']}, {'wineserver': ['-w']}])
249
+ command = (self.prefix / 'drive_c' / 'verify.cmd').read_text()
250
+ self.assertIn('--runscript="C:\\verify.py"', command)
251
+ self.assertEqual(command.splitlines()[-1], 'exit /b %errorlevel%')
252
+
253
+ def test_vendor_failure_is_preserved_even_when_old_boot_artifacts_exist(self):
254
+ for extension in ('project', 'app', 'crc'):
255
+ (self.prefix / 'drive_c' / ('codesys-verification.' + extension)).write_text('Old boot artifact')
256
+ result, calls = self.run_verifier(status=5, files={})
257
+ self.assertEqual(result.returncode, 5)
258
+ self.assertEqual(len(calls), 1)
259
+
260
+ def test_old_boot_artifacts_cannot_satisfy_a_process_that_skips_verification(self):
261
+ for extension in ('project', 'app', 'crc'):
262
+ (self.prefix / 'drive_c' / ('codesys-verification.' + extension)).write_text('Old boot artifact')
263
+ result, calls = self.run_verifier(files={})
264
+ self.assertEqual(result.returncode, 1)
265
+ self.assertEqual(len(calls), 1)
266
+
267
+ def test_native_signal_assertion_rejects_zero_exit_and_fresh_boot_artifacts(self):
268
+ for ending in ('\r\n', '\r\r\n'):
269
+ with self.subTest(line_ending=ending):
270
+ self.calls.write_text('')
271
+ transcript = ending.join([
272
+ 'assertion failed [x86_avx_state_ptr->xsave_header.xfeatures == kSupportedXFeatureBits]: ',
273
+ '(ThreadContextSignals.cpp:414 rt_sigreturn)',
274
+ 'Compile complete -- 0 errors, 0 warnings',
275
+ '',
276
+ ])
277
+ result, calls = self.run_verifier(transcript=transcript)
278
+ self.assertEqual(result.returncode, 1)
279
+ self.assertIn('Wine translation aborted', result.stderr)
280
+ self.assertIn('codesys-verification.log', result.stderr)
281
+ self.assertEqual(len(calls), 1)
282
+ self.assertEqual((self.prefix / 'drive_c' / 'codesys-verification.log').read_bytes().decode(), transcript)
283
+
284
+ def test_ordinary_compiler_assertion_text_does_not_match_native_signal_failure(self):
285
+ result, calls = self.run_verifier(transcript='Compiler diagnostic: assertion failed\n')
286
+ self.assertEqual(result.returncode, 0, result.stderr)
287
+ self.assertEqual(len(calls), 3)
288
+
289
+ def test_missing_or_empty_boot_file_rejects_an_apparently_successful_vendor_exit(self):
290
+ for extension in ('project', 'app', 'crc'):
291
+ for content in (None, ''):
292
+ with self.subTest(extension=extension, content=content):
293
+ for artifact in (self.prefix / 'drive_c').glob('codesys-verification.*'):
294
+ artifact.unlink()
295
+ self.calls.write_text('')
296
+ files = {name: 'Vendor artifact' for name in ('project', 'app', 'crc')}
297
+ if content is None:
298
+ del files[extension]
299
+ else:
300
+ files[extension] = content
301
+ result, calls = self.run_verifier(files=files)
302
+ self.assertEqual(result.returncode, 1)
303
+ self.assertEqual(len(calls), 1)
304
+
305
+
306
+ if __name__ == '__main__':
307
+ unittest.main()
@@ -0,0 +1,142 @@
1
+ import builtins
2
+ import contextlib
3
+ import io
4
+ import json
5
+ import os
6
+ import runpy
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+ from pathlib import Path
11
+ from types import SimpleNamespace
12
+ from unittest.mock import patch
13
+ from build import CompilerError
14
+ from worker import serve
15
+
16
+
17
+ class WorkerTests(unittest.TestCase):
18
+ def run_worker(self, actions, compile):
19
+ with tempfile.TemporaryDirectory() as temporary:
20
+ root = Path(temporary)
21
+ session = root / "build" / ".codesys" / "worker"
22
+ session.mkdir(parents=True)
23
+ commands = []
24
+ for index, action in enumerate(actions + ["stop"]):
25
+ command = {"id": str(index), "action": action}
26
+ if action == "build":
27
+ output = session.parent / "build"
28
+ output.mkdir(exist_ok=True)
29
+ (output / "request.json").write_text(json.dumps({
30
+ "mode": "build", "objects": [], "project": "BottlingLine.project",
31
+
32
+ "output": "runtime/Application.app", "receipt": "compiled.json",
33
+ "template": "Template.project", "templateHash": "template-fixture",
34
+ }))
35
+ commands.append(command)
36
+ pending = iter(commands)
37
+ def wait(milliseconds):
38
+ (session / "request.json").write_text(json.dumps(next(pending)))
39
+ engine = SimpleNamespace(system=SimpleNamespace(delay=wait))
40
+ with patch("worker.build", side_effect=compile):
41
+ serve(engine, str(session))
42
+ responses = [json.loads((session / (command["id"] + ".json")).read_text()) for command in commands]
43
+ errors = [path.read_text() for path in (session.parent / "build").glob("error.txt")]
44
+ return responses, errors
45
+
46
+ def test_handles_multiple_builds_and_health_checks_without_exiting(self):
47
+ requests = []
48
+ def compile(engine, request, loaded, workspace):
49
+ requests.append(request)
50
+ responses, errors = self.run_worker(["ping", "build", "ping", "build"], compile)
51
+ self.assertTrue(all(response["ok"] for response in responses))
52
+ self.assertEqual(errors, [])
53
+ self.assertEqual(len(requests), 2)
54
+ self.assertEqual(requests[0]["project"], requests[1]["project"])
55
+ for request in requests:
56
+ project = Path(request["project"])
57
+ self.assertTrue(project.is_absolute())
58
+ self.assertEqual(Path(request["template"]).parent, project.parent)
59
+ self.assertEqual(Path(request["output"]).parent.parent, project.parent)
60
+
61
+ def test_reports_a_failed_build_and_processes_the_next_request(self):
62
+ failure = "Fixture compiler failure after writing a receipt"
63
+ requests = []
64
+ def compile(engine, request, loaded, workspace):
65
+ requests.append(request)
66
+ if len(requests) == 1:
67
+ Path(request["receipt"]).write_text(json.dumps({"mode": "build"}))
68
+ raise RuntimeError(failure)
69
+ responses, errors = self.run_worker(["build", "build"], compile)
70
+ self.assertFalse(responses[0]["ok"])
71
+ self.assertIn(failure, responses[0]["error"])
72
+ self.assertTrue(responses[1]["ok"])
73
+ self.assertTrue(responses[2]["ok"])
74
+ self.assertEqual(len(errors), 1)
75
+ self.assertIn(failure, errors[0])
76
+
77
+ def test_preserves_structured_compiler_diagnostics_in_failure_response(self):
78
+ diagnostics = [{"text": "Invalid type", "position": "18446744073709551615", "positionText": "vendor context", "object": None}]
79
+ def compile(engine, request, loaded, workspace):
80
+ raise CompilerError(diagnostics)
81
+ responses, errors = self.run_worker(["build", "ping"], compile)
82
+ self.assertEqual(responses[0]["diagnostics"], diagnostics)
83
+ self.assertIn("Invalid type", responses[0]["error"])
84
+ self.assertTrue(responses[1]["ok"])
85
+ self.assertIn("Invalid type", errors[0])
86
+
87
+
88
+ class WorkerMainTests(unittest.TestCase):
89
+ def test_native_exit_is_preloaded_before_serving_and_preserves_success_and_failure(self):
90
+ worker_path = Path(__file__).with_name("worker.py").resolve()
91
+ for native in (True, False):
92
+ for invalid in (True, False):
93
+ with self.subTest(native=native, invalid=invalid), tempfile.TemporaryDirectory() as temporary:
94
+ root = Path(temporary)
95
+ (root / "request.json").write_text("{invalid" if invalid else json.dumps({"id": "stop", "action": "stop"}))
96
+ phase = "startup"
97
+ events = []
98
+ def exited(api, code):
99
+ events.append((api, phase, code))
100
+ raise SystemExit(code)
101
+ class NativeLibrary:
102
+ @property
103
+ def ExitProcess(self):
104
+ events.append(("bind", phase))
105
+ return lambda code: exited("native", code)
106
+ ctypes = SimpleNamespace(windll=SimpleNamespace(kernel32=NativeLibrary()))
107
+ engine = SimpleNamespace(
108
+ system=SimpleNamespace(exit=lambda code: exited("scriptengine", code)),
109
+ ScriptPromptHandling=SimpleNamespace(LogPrompts="LogPrompts"))
110
+ actual_import = builtins.__import__
111
+ def imported(name, *args, **kwargs):
112
+ if name == "ctypes":
113
+ events.append(("import", phase))
114
+ return ctypes
115
+ return actual_import(name, *args, **kwargs)
116
+ def profiled(frame, event, _argument):
117
+ nonlocal phase
118
+ if frame.f_code.co_filename == str(worker_path) and frame.f_code.co_name == "serve":
119
+ if event == "call": phase = "serving"
120
+ elif event == "return": phase = "after-serve"
121
+ previous_profile = sys.getprofile()
122
+ previous_path = list(sys.path)
123
+ try:
124
+ with patch.dict(sys.modules, {"scriptengine": engine}), patch.dict(os.environ, {
125
+ "CODESYS_BUILD_SESSION": str(root), "CODESYS_BUILD_NATIVE_EXIT": str(native).lower(),
126
+ }), patch("builtins.__import__", side_effect=imported), contextlib.redirect_stderr(io.StringIO()):
127
+ sys.setprofile(profiled)
128
+ with self.assertRaises(SystemExit) as result:
129
+ runpy.run_path(str(worker_path), run_name="__main__")
130
+ finally:
131
+ sys.setprofile(previous_profile)
132
+ sys.path[:] = previous_path
133
+ self.assertEqual(result.exception.code, int(invalid))
134
+ self.assertEqual(events, ([
135
+ ("import", "startup"), ("bind", "startup"), ("native", "after-serve", int(invalid)),
136
+ ] if native else [("scriptengine", "after-serve", int(invalid))]))
137
+ if not invalid:
138
+ self.assertEqual(json.loads((root / "stop.json").read_text()), {"id": "stop", "ok": True})
139
+
140
+
141
+ if __name__ == "__main__":
142
+ unittest.main()