@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.
- package/LICENSE +21 -0
- package/README.md +6 -2
- package/dist/cli.js +1652 -0
- package/dist/cli.js.map +34 -0
- package/dist/index.js +1552 -0
- package/dist/index.js.map +31 -0
- package/dist/libraries.js +128 -0
- package/dist/libraries.js.map +10 -0
- package/docker/Dockerfile +173 -0
- package/docker/Dockerfile.dockerignore +17 -0
- package/docker/Dockerfile.source +15 -0
- package/docker/build-image +38 -0
- package/docker/codesys-build +5 -0
- package/docker/config.ts +8 -0
- package/docker/control-linux-sl/Dockerfile +38 -0
- package/docker/entrypoint.sh +10 -0
- package/docker/install/NOTICE.md +21 -0
- package/docker/install/core.sh +36 -0
- package/docker/install/decode.c +12 -0
- package/docker/install/device/Runtime.ico +0 -0
- package/docker/install/device/Runtime.png +0 -0
- package/docker/install/device/codesyscontrol.devdesc.xml +1502 -0
- package/docker/install/extract.py +43 -0
- package/docker/install/helper-launcher.c +46 -0
- package/docker/install/install.sh +94 -0
- package/docker/install/prerequisites.sh +15 -0
- package/docker/install/verify.py +133 -0
- package/docker/install/verify.sh +34 -0
- package/docker/install/wine-ncrypt/COPYING.LIB +502 -0
- package/docker/install/wine-ncrypt/README.md +28 -0
- package/docker/install/wine-ncrypt/enable-oaep.py +34 -0
- package/docker/install/wine-ncrypt/main.c +619 -0
- package/docker/install/wine-ncrypt/oaep.patch +11 -0
- package/docker/install/wine-signal/README.md +22 -0
- package/docker/install/wine-signal/build.sh +10 -0
- package/docker/install/wine-signal/check.sh +34 -0
- package/docker/install/wine-signal/noop-i386.c +2 -0
- package/docker/install/wine-signal/signal-state.c +105 -0
- package/docker/install/wine-signal/tests/avx-registers.c +53 -0
- package/docker/install/wine-signal/tests/frame-and-handler.c +192 -0
- package/docker/install/wine-signal/tests/loader-i386.c +8 -0
- package/docker/install/wine-signal/tests/signal-return.c +47 -0
- package/docker/install/wine-signal/wine +4 -0
- package/docker/runner.ts +131 -0
- package/package.json +53 -4
- package/python/build.py +655 -0
- package/python/codesys_online.py +300 -0
- package/python/test_build.py +444 -0
- package/python/test_codesys_online.py +170 -0
- package/python/test_installer_packages.py +216 -0
- package/python/test_installer_verification.py +307 -0
- package/python/test_worker.py +142 -0
- package/python/worker.py +89 -0
- package/runtime/install.sh +74 -0
- package/src/__tests__/fixtures/xml/fb-simple.xml +40 -0
- package/src/__tests__/fixtures/xml/fb-state-machine.xml +86 -0
- package/src/__tests__/fixtures/xml/prg-cyclic.xml +44 -0
- package/src/artifacts.ts +77 -0
- package/src/build.ts +87 -0
- package/src/cli.ts +116 -0
- package/src/config.ts +12 -0
- package/src/controller-online.ts +143 -0
- package/src/controller-session.ts +229 -0
- package/src/diagnostics.ts +66 -0
- package/src/docker-build.ts +148 -0
- package/src/docker-context.ts +35 -0
- package/src/docker-run.ts +104 -0
- package/src/host/codesys-command.ts +45 -0
- package/src/host/codesys-host.ts +145 -0
- package/src/host/codesys-worker.ts +345 -0
- package/src/host/windows-path.ts +73 -0
- package/src/index.ts +26 -0
- package/src/io.ts +127 -0
- package/src/libraries.ts +166 -0
- package/src/package-path.ts +5 -0
- package/src/run.ts +133 -0
- package/src/runtime-status.ts +21 -0
- package/src/sources.ts +248 -0
- package/src/st/declarations.ts +229 -0
- package/src/st/lexer.ts +125 -0
- package/src/st/parse.ts +202 -0
- 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()
|