@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,444 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import SimpleNamespace
|
|
7
|
+
from unittest.mock import Mock
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from build import CompilerError, build, diagnostic
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Calls(list):
|
|
13
|
+
def __init__(self, task):
|
|
14
|
+
self.task = task
|
|
15
|
+
super().__init__(child.name for child in task.children)
|
|
16
|
+
def add(self, name):
|
|
17
|
+
self.task.create(name, "task_call")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Node:
|
|
21
|
+
def __init__(self, name, kind, parent=None):
|
|
22
|
+
self.name, self.kind, self.parent = name, kind, parent
|
|
23
|
+
self.children = []
|
|
24
|
+
self.declaration, self.implementation = "", ""
|
|
25
|
+
self.textual_declaration = SimpleNamespace(replace=lambda text: setattr(self, "declaration", text))
|
|
26
|
+
self.textual_implementation = SimpleNamespace(replace=lambda text: setattr(self, "implementation", text))
|
|
27
|
+
self.is_folder = kind == "folder"
|
|
28
|
+
self.is_task = kind == "task"
|
|
29
|
+
self.is_task_configuration = kind == "task_configuration"
|
|
30
|
+
self.is_symbol_config = kind == "symbols"
|
|
31
|
+
if self.is_task:
|
|
32
|
+
self.kind_of_task, self.priority, self.interval, self.interval_unit = 1, "1", "20", "ms"
|
|
33
|
+
self.create("PLC_PRG", "task_call")
|
|
34
|
+
@property
|
|
35
|
+
def pous(self):
|
|
36
|
+
# Match the engine: list.remove changes the snapshot, not the task tree.
|
|
37
|
+
return Calls(self)
|
|
38
|
+
@property
|
|
39
|
+
def calls(self):
|
|
40
|
+
return [child.name for child in self.children]
|
|
41
|
+
|
|
42
|
+
def get_name(self):
|
|
43
|
+
return self.name
|
|
44
|
+
def get_children(self):
|
|
45
|
+
return self.children
|
|
46
|
+
def remove(self):
|
|
47
|
+
self.parent.children.remove(self)
|
|
48
|
+
def find_ignore_transient_objects(self, name, recursive):
|
|
49
|
+
matches = [child for child in self.children if child.kind != "task_call" and child.name.lower() == name.lower()]
|
|
50
|
+
if recursive:
|
|
51
|
+
for child in self.children:
|
|
52
|
+
matches.extend(child.find_ignore_transient_objects(name, True))
|
|
53
|
+
return matches
|
|
54
|
+
def create(self, name, kind):
|
|
55
|
+
child = Node(name, kind, self)
|
|
56
|
+
self.children.append(child)
|
|
57
|
+
return child
|
|
58
|
+
def create_task_configuration(self): return self.create("Task Configuration", "task_configuration")
|
|
59
|
+
def create_task(self, name): return self.create(name, "task")
|
|
60
|
+
def create_folder(self, name):
|
|
61
|
+
self.create(name, "folder")
|
|
62
|
+
def create_program(self, name): return self.create(name, "program")
|
|
63
|
+
def create_function_block(self, name): return self.create(name, "function_block")
|
|
64
|
+
def create_function(self, name, return_type): return self.create(name, "function")
|
|
65
|
+
def create_gvl(self, name): return self.create(name, "gvl")
|
|
66
|
+
def create_dut(self, name, kind, base): return self.create(name, kind)
|
|
67
|
+
def create_interface(self, name): return self.create(name, "interface")
|
|
68
|
+
def create_method(self, name, return_type): return self.create(name, "method")
|
|
69
|
+
def create_action(self, name): return self.create(name, "action")
|
|
70
|
+
def create_property(self, name, return_type):
|
|
71
|
+
prop = self.create(name, "property")
|
|
72
|
+
prop.create("Get", "get")
|
|
73
|
+
prop.create("Set", "set")
|
|
74
|
+
return prop
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Controller:
|
|
78
|
+
"""The template's controller; Update Device succeeds only for installed descriptions."""
|
|
79
|
+
is_device = True
|
|
80
|
+
def __init__(self, installed):
|
|
81
|
+
self.installed = installed
|
|
82
|
+
self.identity = SimpleNamespace(type=4096, id="0000 1008", version="4.22.0.0")
|
|
83
|
+
def get_device_identification(self):
|
|
84
|
+
return self.identity
|
|
85
|
+
def update(self, type, id, version):
|
|
86
|
+
if (type, id, version) in self.installed:
|
|
87
|
+
self.identity = SimpleNamespace(type=type, id=id, version=version)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
LINUX_SL = dict(type=4102, id="0000 0005", version="4.22.0.0")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class CompilerFixture:
|
|
94
|
+
def __init__(self):
|
|
95
|
+
self.opened = []
|
|
96
|
+
self.installed = {(4102, "0000 0005", "4.22.0.0")}
|
|
97
|
+
self.fail_compile = False
|
|
98
|
+
self.empty_output = False
|
|
99
|
+
self.engine = SimpleNamespace(
|
|
100
|
+
projects=SimpleNamespace(open=self.open),
|
|
101
|
+
DutType=SimpleNamespace(Structure="structure", Enumeration="enumeration", Union="union", Alias="alias"),
|
|
102
|
+
Severity=SimpleNamespace(Error=1, FatalError=2),
|
|
103
|
+
KindOfTask=SimpleNamespace(Cyclic=1),
|
|
104
|
+
system=SimpleNamespace(get_message_categories=lambda: ["compiler"], get_message_objects=lambda category, severity: [], clear_messages=Mock()),
|
|
105
|
+
)
|
|
106
|
+
def open(self, path, primary):
|
|
107
|
+
app = Node("Application", "application")
|
|
108
|
+
configuration = app.create_task_configuration()
|
|
109
|
+
configuration.create_task("MainTask")
|
|
110
|
+
background = configuration.create_task("BackgroundTask")
|
|
111
|
+
background.children.clear()
|
|
112
|
+
background.pous.add("Monitor")
|
|
113
|
+
app.create("Monitor", "program")
|
|
114
|
+
app.create("Symbols", "symbols")
|
|
115
|
+
app.create("PLC_PRG", "program")
|
|
116
|
+
controller = Controller(self.installed)
|
|
117
|
+
project = SimpleNamespace(path=path, close=Mock(), active_application=app, controller=controller,
|
|
118
|
+
get_children=lambda: [controller])
|
|
119
|
+
def save(): Path(project.path).write_text("compiled project")
|
|
120
|
+
def save_as(destination):
|
|
121
|
+
project.path = destination
|
|
122
|
+
save()
|
|
123
|
+
def rebuild():
|
|
124
|
+
if self.fail_compile: raise RuntimeError("Fixture compiler error")
|
|
125
|
+
def export(destination, update, compact):
|
|
126
|
+
Path(destination).write_text("" if self.empty_output else "boot application")
|
|
127
|
+
project.save, project.save_as = save, save_as
|
|
128
|
+
app.clean, app.rebuild, app.generate_code = Mock(), rebuild, Mock()
|
|
129
|
+
app.create_boot_application = export
|
|
130
|
+
self.opened.append(project)
|
|
131
|
+
return project
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def spec(kind, name, children=None, folder=None, **kwargs):
|
|
135
|
+
return dict(kind=kind, name=name, declaration="source " + name, children=children or [], folder=folder or [], **kwargs)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class BuildTests(unittest.TestCase):
|
|
139
|
+
def setUp(self):
|
|
140
|
+
self.temp = tempfile.TemporaryDirectory()
|
|
141
|
+
self.addCleanup(self.temp.cleanup)
|
|
142
|
+
self.root = Path(self.temp.name)
|
|
143
|
+
self.template = self.root / "Template.project"
|
|
144
|
+
self.template.write_text("original template")
|
|
145
|
+
self.compiler = CompilerFixture()
|
|
146
|
+
self.loaded = {}
|
|
147
|
+
self.counter = 0
|
|
148
|
+
self.objects = [spec("program", "Main", implementation="RETURN;")]
|
|
149
|
+
def request(self, **overrides):
|
|
150
|
+
self.counter += 1
|
|
151
|
+
run = self.root / str(self.counter)
|
|
152
|
+
run.mkdir()
|
|
153
|
+
request = dict(template=str(self.template), templateHash=hashlib.sha256(self.template.read_bytes()).hexdigest(),
|
|
154
|
+
project=str(run / "Application.project"), output=str(run / "Application.app"), receipt=str(run / "compiled.json"),
|
|
155
|
+
objects=self.objects, entry="Main", task="MainTask", symbols=False, removeObjects=["PLC_PRG"], mode="build")
|
|
156
|
+
request.update(overrides)
|
|
157
|
+
return request
|
|
158
|
+
def run_build(self, **overrides):
|
|
159
|
+
request = self.request(**overrides)
|
|
160
|
+
build(self.compiler.engine, request, self.loaded, str(self.root))
|
|
161
|
+
return request
|
|
162
|
+
def find(self, name):
|
|
163
|
+
return self.loaded["project"].active_application.find_ignore_transient_objects(name, True)[0]
|
|
164
|
+
def test_imports_program_and_rewires_only_the_configured_task(self):
|
|
165
|
+
request = self.run_build()
|
|
166
|
+
self.assertEqual(self.find("Main").implementation, "RETURN;")
|
|
167
|
+
self.assertEqual(self.find("MainTask").calls, ["Main"])
|
|
168
|
+
self.assertEqual(self.find("BackgroundTask").calls, ["Monitor"])
|
|
169
|
+
self.assertTrue(Path(request["receipt"]).exists())
|
|
170
|
+
self.assertEqual(self.template.read_text(), "original template")
|
|
171
|
+
def test_explicit_tasks_replace_the_complete_schedule_and_preserve_program_order(self):
|
|
172
|
+
self.objects.append(spec("program", "Second", implementation="RETURN;"))
|
|
173
|
+
tasks = [dict(name="MainTask", intervalNanoseconds="10000000", priority=0, programs=["Second", "Main"])]
|
|
174
|
+
request = self.run_build(tasks=tasks)
|
|
175
|
+
task = self.find("MainTask")
|
|
176
|
+
self.assertEqual((task.kind_of_task, task.interval, task.interval_unit, task.priority), (1, "10", "ms", "0"))
|
|
177
|
+
self.assertEqual(task.calls, ["Second", "Main"])
|
|
178
|
+
self.assertEqual(self.loaded["project"].active_application.find_ignore_transient_objects("BackgroundTask", True), [])
|
|
179
|
+
self.assertEqual(json.loads(Path(request["receipt"]).read_text())["tasks"], tasks)
|
|
180
|
+
def test_creates_missing_tasks_and_reloads_for_schedule_changes(self):
|
|
181
|
+
self.objects.append(spec("program", "Second", implementation="RETURN;"))
|
|
182
|
+
tasks = [dict(name="FastTask", intervalNanoseconds="10000000", priority=1, programs=["Main"]),
|
|
183
|
+
dict(name="SlowTask", intervalNanoseconds="40000000", priority=31, programs=["Second"])]
|
|
184
|
+
self.run_build(tasks=tasks)
|
|
185
|
+
self.assertEqual(self.find("FastTask").calls, ["Main"])
|
|
186
|
+
self.assertEqual(self.find("SlowTask").interval, "40")
|
|
187
|
+
self.objects[0]["implementation"] = "Changed();"
|
|
188
|
+
self.run_build(tasks=tasks)
|
|
189
|
+
self.assertEqual(len(self.compiler.opened), 1)
|
|
190
|
+
tasks[0]["intervalNanoseconds"] = "20000000"
|
|
191
|
+
self.run_build(tasks=tasks)
|
|
192
|
+
self.assertEqual(len(self.compiler.opened), 2)
|
|
193
|
+
tasks[0]["priority"] = 2
|
|
194
|
+
self.run_build(tasks=tasks)
|
|
195
|
+
self.assertEqual(len(self.compiler.opened), 3)
|
|
196
|
+
tasks[0]["programs"], tasks[1]["programs"] = ["Second"], ["Main"]
|
|
197
|
+
self.run_build(tasks=tasks)
|
|
198
|
+
self.assertEqual(len(self.compiler.opened), 4)
|
|
199
|
+
def test_explicit_tasks_reject_invalid_or_missing_programs(self):
|
|
200
|
+
for changes in [dict(intervalNanoseconds="1"), dict(intervalNanoseconds="4294967296000000"), dict(priority=32), dict(programs=[]), dict(programs=["Monitor"]), dict(programs=["Main", "Main"])]:
|
|
201
|
+
task = dict(name="MainTask", intervalNanoseconds="20000000", priority=1, programs=["Main"])
|
|
202
|
+
task.update(changes)
|
|
203
|
+
with self.subTest(changes=changes), self.assertRaises(RuntimeError):
|
|
204
|
+
self.run_build(tasks=[task])
|
|
205
|
+
def test_failed_task_call_readback_discards_the_build(self):
|
|
206
|
+
open_project = self.compiler.open
|
|
207
|
+
def broken_open(path, primary):
|
|
208
|
+
project = open_project(path, primary)
|
|
209
|
+
task = project.active_application.find_ignore_transient_objects("MainTask", True)[0]
|
|
210
|
+
task.create = Mock()
|
|
211
|
+
return project
|
|
212
|
+
self.compiler.engine.projects.open = broken_open
|
|
213
|
+
with self.assertRaisesRegex(RuntimeError, "readback differs"):
|
|
214
|
+
self.run_build(tasks=[dict(name="MainTask", intervalNanoseconds="20000000", priority=1, programs=["Main"])])
|
|
215
|
+
self.assertEqual(self.loaded, {})
|
|
216
|
+
def test_unspecified_template_tasks_cannot_survive_failed_removal(self):
|
|
217
|
+
open_project = self.compiler.open
|
|
218
|
+
def broken_open(path, primary):
|
|
219
|
+
project = open_project(path, primary)
|
|
220
|
+
project.active_application.find_ignore_transient_objects("BackgroundTask", True)[0].remove = Mock()
|
|
221
|
+
return project
|
|
222
|
+
self.compiler.engine.projects.open = broken_open
|
|
223
|
+
with self.assertRaisesRegex(RuntimeError, "Unplanned or duplicate tasks"):
|
|
224
|
+
self.run_build(tasks=[dict(name="MainTask", intervalNanoseconds="20000000", priority=1, programs=["Main"])])
|
|
225
|
+
def test_reuses_the_loaded_project_for_text_changes(self):
|
|
226
|
+
self.run_build()
|
|
227
|
+
self.objects[0]["implementation"] = "NewBody();"
|
|
228
|
+
self.run_build()
|
|
229
|
+
self.assertEqual(len(self.compiler.opened), 1)
|
|
230
|
+
self.assertEqual(self.find("Main").implementation, "NewBody();")
|
|
231
|
+
def test_reloads_for_template_changes(self):
|
|
232
|
+
self.run_build()
|
|
233
|
+
self.template.write_text("changed template")
|
|
234
|
+
self.run_build()
|
|
235
|
+
self.assertEqual(len(self.compiler.opened), 2)
|
|
236
|
+
self.assertEqual(self.compiler.opened[0].path, self.compiler.opened[1].path)
|
|
237
|
+
self.compiler.opened[0].close.assert_called_once()
|
|
238
|
+
self.assertEqual([p.name for p in self.root.glob("project*")], ["project"])
|
|
239
|
+
def test_updates_the_template_controller_and_reloads_when_it_changes(self):
|
|
240
|
+
self.run_build(controller=LINUX_SL)
|
|
241
|
+
self.assertEqual(vars(self.loaded["project"].controller.identity), LINUX_SL)
|
|
242
|
+
self.run_build(controller=LINUX_SL)
|
|
243
|
+
self.assertEqual(len(self.compiler.opened), 1)
|
|
244
|
+
self.run_build()
|
|
245
|
+
self.assertEqual(len(self.compiler.opened), 2)
|
|
246
|
+
self.assertEqual(self.loaded["project"].controller.identity.id, "0000 1008")
|
|
247
|
+
def test_rejects_a_controller_without_an_installed_description(self):
|
|
248
|
+
self.compiler.installed.clear()
|
|
249
|
+
with self.assertRaisesRegex(RuntimeError, "install the device description for 4102 0000 0005 4.22.0.0"):
|
|
250
|
+
self.run_build(controller=LINUX_SL)
|
|
251
|
+
def test_reloads_for_removed_children_and_moved_domains(self):
|
|
252
|
+
block = spec("function_block", "FB_Test", [spec("method", "Run", implementation="RETURN;")], ["old"], implementation="")
|
|
253
|
+
self.objects.append(block)
|
|
254
|
+
self.run_build()
|
|
255
|
+
block["children"] = []
|
|
256
|
+
block["folder"] = ["new", "nested"]
|
|
257
|
+
self.run_build()
|
|
258
|
+
self.assertEqual(len(self.compiler.opened), 2)
|
|
259
|
+
self.assertEqual(self.find("FB_Test").children, [])
|
|
260
|
+
self.assertEqual(self.find("FB_Test").parent.name, "nested")
|
|
261
|
+
def test_imports_every_supported_object_kind(self):
|
|
262
|
+
self.objects.extend([
|
|
263
|
+
spec("dut", "ST_Data", dutType="structure"), spec("dut", "E_State", dutType="enumeration"),
|
|
264
|
+
spec("dut", "U_Data", dutType="union"), spec("dut", "T_Count", dutType="alias"),
|
|
265
|
+
spec("gvl", "Globals"), spec("function", "F_Add", returnType="INT", implementation="F_Add := 1;"),
|
|
266
|
+
spec("function", "F_NoReturn", implementation="RETURN;"),
|
|
267
|
+
spec("interface", "I_Test", [spec("method", "Run")]),
|
|
268
|
+
spec("function_block", "FB_Test", [spec("action", "Reset", implementation="X := 0;"),
|
|
269
|
+
spec("property", "Ready", [spec("get", "Get", implementation="Ready := TRUE;")], returnType="BOOL")], implementation=""),
|
|
270
|
+
])
|
|
271
|
+
self.run_build()
|
|
272
|
+
self.assertEqual(self.find("Ready").children[0].implementation, "Ready := TRUE;")
|
|
273
|
+
self.assertEqual([child.name for child in self.find("Ready").children], ["Get"])
|
|
274
|
+
self.assertEqual(self.find("Reset").implementation, "X := 0;")
|
|
275
|
+
self.assertEqual(self.find("T_Count").kind, "alias")
|
|
276
|
+
self.assertEqual(self.find("F_NoReturn").implementation, "RETURN;")
|
|
277
|
+
def test_reports_missing_task_instead_of_leaving_entry_unscheduled(self):
|
|
278
|
+
with self.assertRaisesRegex(RuntimeError, "one task named Missing"):
|
|
279
|
+
self.run_build(task="Missing")
|
|
280
|
+
def test_discards_a_failed_project_and_recovers_on_the_next_build(self):
|
|
281
|
+
self.compiler.fail_compile = True
|
|
282
|
+
request = self.request()
|
|
283
|
+
with self.assertRaisesRegex(RuntimeError, "Fixture compiler error"):
|
|
284
|
+
build(self.compiler.engine, request, self.loaded, str(self.root))
|
|
285
|
+
self.assertEqual(self.loaded, {})
|
|
286
|
+
self.assertFalse(Path(request["receipt"]).exists())
|
|
287
|
+
self.compiler.opened[0].close.assert_called_once()
|
|
288
|
+
self.compiler.fail_compile = False
|
|
289
|
+
self.run_build()
|
|
290
|
+
self.assertEqual(len(self.compiler.opened), 2)
|
|
291
|
+
def test_rejects_empty_boot_application(self):
|
|
292
|
+
self.compiler.empty_output = True
|
|
293
|
+
with self.assertRaisesRegex(RuntimeError, "non-empty boot application"):
|
|
294
|
+
self.run_build()
|
|
295
|
+
def test_clears_old_diagnostics_before_compilation(self):
|
|
296
|
+
self.run_build()
|
|
297
|
+
self.compiler.engine.system.clear_messages.assert_called_once_with("compiler")
|
|
298
|
+
def test_fails_on_compiler_error_diagnostics(self):
|
|
299
|
+
self.compiler.engine.system.get_message_objects = lambda category, severity: [SimpleNamespace(text="Unresolved identifier")]
|
|
300
|
+
with self.assertRaisesRegex(RuntimeError, "Unresolved identifier"):
|
|
301
|
+
self.run_build()
|
|
302
|
+
|
|
303
|
+
def test_preserves_compiler_position_metadata_without_decoding(self):
|
|
304
|
+
project = SimpleNamespace(path="C:\\scratch\\Application.project", is_root=True, active_application=None)
|
|
305
|
+
folder = SimpleNamespace(get_name=lambda: "Functions", is_root=False, parent=project)
|
|
306
|
+
obj = SimpleNamespace(get_name=lambda: "Main", guid="object-guid", is_root=False, parent=folder)
|
|
307
|
+
message = SimpleNamespace(text="Unresolved identifier", severity="Error", project=project, object=obj,
|
|
308
|
+
position=18446744073709551615, position_text="(Impl) Line 2", position_offset=Decimal(7),
|
|
309
|
+
length=Decimal(4), prefix="C", number=Decimal(46))
|
|
310
|
+
self.compiler.engine.system.get_message_objects = lambda category, severity: [message, SimpleNamespace(text="No position")]
|
|
311
|
+
with self.assertRaises(CompilerError) as failure:
|
|
312
|
+
self.run_build()
|
|
313
|
+
diagnostics = failure.exception.diagnostics
|
|
314
|
+
self.assertEqual(diagnostics[0], dict(category="compiler", text="Unresolved identifier", severity="Error",
|
|
315
|
+
project=project.path, object=dict(name="Main", guid="object-guid", path=["Functions", "Main"], isApplicationChild=None),
|
|
316
|
+
position="18446744073709551615", positionText="(Impl) Line 2", positionOffset=7, length=4, prefix="C", number=46))
|
|
317
|
+
self.assertIsNone(diagnostics[1]["object"])
|
|
318
|
+
self.assertIsNone(diagnostics[1]["positionText"])
|
|
319
|
+
|
|
320
|
+
def test_inaccessible_position_context_cannot_mask_compiler_error(self):
|
|
321
|
+
class RemovedObject(object):
|
|
322
|
+
@property
|
|
323
|
+
def is_root(self): raise RuntimeError("Object removed")
|
|
324
|
+
class ClosedProject(object):
|
|
325
|
+
@property
|
|
326
|
+
def path(self): raise RuntimeError("Project closed")
|
|
327
|
+
message = SimpleNamespace(text="Original compiler error", object=RemovedObject(), project=ClosedProject(),
|
|
328
|
+
position_text="Unrecognized vendor position", position=12)
|
|
329
|
+
result = diagnostic(message, "compiler")
|
|
330
|
+
self.assertEqual(result["text"], "Original compiler error")
|
|
331
|
+
self.assertEqual(result["positionText"], "Unrecognized vendor position")
|
|
332
|
+
self.assertIsNone(result["object"])
|
|
333
|
+
self.assertIsNone(result["project"])
|
|
334
|
+
self.assertEqual(result["metadataErrors"], {"object": "Object removed", "project": "Project closed"})
|
|
335
|
+
|
|
336
|
+
def test_verifies_application_parent_by_guid_instead_of_name(self):
|
|
337
|
+
application = SimpleNamespace(guid="application-guid", get_name=lambda: "CustomApplication", is_root=False, parent=SimpleNamespace(is_root=True))
|
|
338
|
+
project = SimpleNamespace(path="private.project", active_application=application)
|
|
339
|
+
obj = SimpleNamespace(get_name=lambda: "Main", guid="program-guid", is_root=False, parent=application)
|
|
340
|
+
message = SimpleNamespace(text="Compiler error", object=obj, project=project)
|
|
341
|
+
identity = diagnostic(message, "compiler")["object"]
|
|
342
|
+
self.assertTrue(identity["isApplicationChild"])
|
|
343
|
+
self.assertEqual(identity["applicationPath"], ["Main"])
|
|
344
|
+
obj.parent = SimpleNamespace(guid="template-folder-guid", get_name=lambda: "CustomApplication", is_root=False, parent=application)
|
|
345
|
+
identity = diagnostic(message, "compiler")["object"]
|
|
346
|
+
self.assertFalse(identity["isApplicationChild"])
|
|
347
|
+
self.assertEqual(identity["applicationPath"], ["CustomApplication", "Main"])
|
|
348
|
+
|
|
349
|
+
def test_preserves_verified_application_relative_paths_for_nested_pous_and_duts(self):
|
|
350
|
+
project = SimpleNamespace(path="private.project", is_root=True)
|
|
351
|
+
device = SimpleNamespace(guid="device-guid", get_name=lambda: "Device", is_root=False, parent=project)
|
|
352
|
+
application = SimpleNamespace(guid="application-guid", get_name=lambda: "CustomApplication", is_root=False, parent=device)
|
|
353
|
+
project.active_application = application
|
|
354
|
+
controls = SimpleNamespace(guid="controls-guid", get_name=lambda: "controls", is_root=False, parent=application)
|
|
355
|
+
folder = SimpleNamespace(guid="folder-guid", get_name=lambda: "pumps", is_root=False, parent=controls)
|
|
356
|
+
for name in ("F_Pump", "T_Pump"):
|
|
357
|
+
obj = SimpleNamespace(guid=name + "-guid", get_name=lambda: name, is_root=False, parent=folder)
|
|
358
|
+
message = SimpleNamespace(text="Original compiler error", object=obj, project=project)
|
|
359
|
+
identity = diagnostic(message, "compiler")["object"]
|
|
360
|
+
self.assertEqual(identity["path"], ["Device", "CustomApplication", "controls", "pumps", name])
|
|
361
|
+
self.assertEqual(identity["applicationPath"], ["controls", "pumps", name])
|
|
362
|
+
self.assertFalse(identity["isApplicationChild"])
|
|
363
|
+
|
|
364
|
+
def test_same_named_other_application_has_no_verified_relative_identity(self):
|
|
365
|
+
project = SimpleNamespace(path="private.project", is_root=True)
|
|
366
|
+
application = SimpleNamespace(guid="active-guid", get_name=lambda: "Application", is_root=False, parent=project)
|
|
367
|
+
other = SimpleNamespace(guid="other-guid", get_name=lambda: "Application", is_root=False, parent=project)
|
|
368
|
+
project.active_application = application
|
|
369
|
+
folder = SimpleNamespace(guid="folder-guid", get_name=lambda: "controls", is_root=False, parent=other)
|
|
370
|
+
obj = SimpleNamespace(guid="program-guid", get_name=lambda: "Main", is_root=False, parent=folder)
|
|
371
|
+
identity = diagnostic(SimpleNamespace(text="Compiler error", object=obj, project=project), "compiler")["object"]
|
|
372
|
+
self.assertEqual(identity["path"], ["Application", "controls", "Main"])
|
|
373
|
+
self.assertFalse(identity["isApplicationChild"])
|
|
374
|
+
self.assertNotIn("applicationPath", identity)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class Channel(list):
|
|
378
|
+
"""A raw byte channel with eight bit elements, like CODESYS's EDS-imported payload."""
|
|
379
|
+
def __init__(self, identifier, kind, bits=8):
|
|
380
|
+
super().__init__(SimpleNamespace(user_comment="", io_mapping=SimpleNamespace(variable=None)) for _ in range(8))
|
|
381
|
+
self.id, self.channel_type, self.bit_size, self.user_comment = identifier, kind, bits, ""
|
|
382
|
+
self.io_mapping = Mapping()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class Mapping:
|
|
386
|
+
def __init__(self, address="%IB99"):
|
|
387
|
+
self.automatic_iec_address, self.address, self.variable = True, address, None
|
|
388
|
+
@property
|
|
389
|
+
def manual_iec_address(self):
|
|
390
|
+
return self.address
|
|
391
|
+
@manual_iec_address.setter
|
|
392
|
+
def manual_iec_address(self, value):
|
|
393
|
+
# The engine rejects a manual address once automatic addressing is disabled.
|
|
394
|
+
if not self.automatic_iec_address:
|
|
395
|
+
raise ValueError("Invalid address: The existing address is not valid.")
|
|
396
|
+
self.address = value
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class DeviceTreeTests(unittest.TestCase):
|
|
400
|
+
engine = SimpleNamespace(ChannelType=SimpleNamespace(Input="input", Output="output"))
|
|
401
|
+
|
|
402
|
+
def parameters(self, inputs, outputs, bits=8):
|
|
403
|
+
channels = [Channel(100 + index, "input", bits) for index in range(inputs)]
|
|
404
|
+
channels += [Channel(200 + index, "output") for index in range(outputs)]
|
|
405
|
+
return dict((channel.id, channel) for channel in reversed(channels))
|
|
406
|
+
|
|
407
|
+
def test_pins_payload_bytes_at_their_offsets_and_labels_bound_io(self):
|
|
408
|
+
from build import map_channels, pin_channels, verify_pinned
|
|
409
|
+
parameters = self.parameters(3, 2)
|
|
410
|
+
# Automatic addresses that already follow the first channel stay automatic.
|
|
411
|
+
parameters[101].io_mapping.address = "%IB17"
|
|
412
|
+
device = {
|
|
413
|
+
"name": "remoteIo",
|
|
414
|
+
"inputs": {"address": "%IB16", "size": 3},
|
|
415
|
+
"outputs": {"address": "%QB28", "size": 2},
|
|
416
|
+
"comments": [{"address": "%IX17.4", "text": "Sensor"}, {"address": "%IB16", "text": "Count (2 bytes)"}],
|
|
417
|
+
}
|
|
418
|
+
pinned = pin_channels(self.engine, None, parameters, device)
|
|
419
|
+
self.assertEqual(sorted(pinned), ["%IB16", "%IB17", "%IB18", "%QB28", "%QB29"])
|
|
420
|
+
self.assertEqual([parameters[identifier].io_mapping.manual_iec_address for identifier in (100, 101, 102)], ["%IB16", "%IB17", "%IB18"])
|
|
421
|
+
self.assertEqual([parameters[identifier].io_mapping.automatic_iec_address for identifier in (100, 101, 102)], [False, True, False])
|
|
422
|
+
map_channels(pinned, {"devices": [device], "mappings": [
|
|
423
|
+
{"address": "%IX17.4", "variable": "Line.Sensor"}, {"address": "%QB29", "variable": "Line.Motor"},
|
|
424
|
+
]}, "Application")
|
|
425
|
+
self.assertEqual(parameters[101][4].io_mapping.variable, "Application.Line.Sensor")
|
|
426
|
+
self.assertEqual(parameters[201].io_mapping.variable, "Application.Line.Motor")
|
|
427
|
+
# Mapped channels need no comment; IO that keeps AT is named in its comment.
|
|
428
|
+
self.assertEqual(parameters[101][4].user_comment, "")
|
|
429
|
+
self.assertEqual(parameters[100].user_comment, "Count (2 bytes)")
|
|
430
|
+
verify_pinned(pinned)
|
|
431
|
+
parameters[101].io_mapping.address = "%IB0"
|
|
432
|
+
with self.assertRaisesRegex(RuntimeError, "Channel %IB17 moved to %IB0"):
|
|
433
|
+
verify_pinned(pinned)
|
|
434
|
+
|
|
435
|
+
def test_rejects_typed_channels_and_unplaced_directions(self):
|
|
436
|
+
from build import pin_channels
|
|
437
|
+
with self.assertRaisesRegex(RuntimeError, "reader inputs are not 2 raw byte channels"):
|
|
438
|
+
pin_channels(self.engine, None, self.parameters(2, 0, 16), {"name": "reader", "inputs": {"address": "%IB0", "size": 2}})
|
|
439
|
+
with self.assertRaisesRegex(RuntimeError, "reader has outputs without a configured address"):
|
|
440
|
+
pin_channels(self.engine, None, self.parameters(0, 1), {"name": "reader"})
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
if __name__ == "__main__":
|
|
444
|
+
unittest.main()
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import unittest
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
from unittest.mock import Mock, patch
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TargetInvocationException(Exception):
|
|
8
|
+
def __init__(self, inner):
|
|
9
|
+
super().__init__('Exception has been thrown by the target of an invocation')
|
|
10
|
+
self.InnerException = inner
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Only .NET imports are unavailable in CPython. The operation orchestrator and
|
|
14
|
+
# compiler diagnostic gate below are the maintained code, not copied logic.
|
|
15
|
+
with patch.dict(sys.modules, {
|
|
16
|
+
'System': SimpleNamespace(Activator=None, AppDomain=None, Array=None, Object=None,
|
|
17
|
+
Enum=SimpleNamespace(Parse=lambda kind, value: value),
|
|
18
|
+
Environment=SimpleNamespace(ProcessorCount=2)),
|
|
19
|
+
'System.Net': SimpleNamespace(Dns=SimpleNamespace(GetHostAddresses=lambda host: ['127.0.0.1']),
|
|
20
|
+
IPAddress=SimpleNamespace(Parse=lambda address: address)),
|
|
21
|
+
'System.Reflection': SimpleNamespace(BindingFlags=None, TargetInvocationException=TargetInvocationException),
|
|
22
|
+
}):
|
|
23
|
+
import codesys_online
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OnlineLifecycleTests(unittest.TestCase):
|
|
27
|
+
def setUp(self):
|
|
28
|
+
self.events = []
|
|
29
|
+
self.messages = []
|
|
30
|
+
self.ids = {'codeId': 'downloaded-code', 'dataId': 'downloaded-data'}
|
|
31
|
+
self.app = SimpleNamespace(
|
|
32
|
+
guid='application-guid', parent=SimpleNamespace(is_device=True),
|
|
33
|
+
get_children=lambda: [], get_name=lambda: 'Application',
|
|
34
|
+
is_uptodate=False, is_online_change_possible=True,
|
|
35
|
+
)
|
|
36
|
+
self.app.parent.set_gateway_and_ip_address = Mock()
|
|
37
|
+
self.project = SimpleNamespace(active_application=self.app, save=Mock(), close=Mock())
|
|
38
|
+
self.source_imported = False
|
|
39
|
+
self.wrapper = None
|
|
40
|
+
self.core = SimpleNamespace(Logout=Mock(), Login=Mock(return_value=True),
|
|
41
|
+
CreateBootProject=Mock(), Download=Mock())
|
|
42
|
+
self.boot_method = Mock()
|
|
43
|
+
self.boot_method.Invoke.side_effect = lambda core, args: core.CreateBootProject()
|
|
44
|
+
self.boot_api = SimpleNamespace(GetMethod=Mock(return_value=self.boot_method))
|
|
45
|
+
|
|
46
|
+
def clean():
|
|
47
|
+
self.events.append('clean')
|
|
48
|
+
def compile_source():
|
|
49
|
+
self.assertTrue(self.source_imported)
|
|
50
|
+
self.events.append('compile')
|
|
51
|
+
self.app.clean = Mock(side_effect=clean)
|
|
52
|
+
self.app.rebuild = Mock(side_effect=compile_source)
|
|
53
|
+
self.app.build = Mock(side_effect=compile_source)
|
|
54
|
+
self.app.generate_code = Mock(side_effect=lambda: self.events.append('generate'))
|
|
55
|
+
|
|
56
|
+
def wrapper(application):
|
|
57
|
+
self.events.append('online-wrapper')
|
|
58
|
+
self.wrapper = SimpleNamespace(
|
|
59
|
+
login=Mock(), start=Mock(), logout=Mock(), Dispose=Mock(),
|
|
60
|
+
read_value=Mock(return_value='42'),
|
|
61
|
+
)
|
|
62
|
+
return self.wrapper
|
|
63
|
+
self.engine = SimpleNamespace(
|
|
64
|
+
projects=SimpleNamespace(open=Mock(return_value=self.project)),
|
|
65
|
+
online=SimpleNamespace(
|
|
66
|
+
gateways=SimpleNamespace(add_new_gateway=Mock(return_value=SimpleNamespace(guid='gateway'))),
|
|
67
|
+
create_online_device=Mock(return_value=SimpleNamespace(Dispose=Mock())),
|
|
68
|
+
create_online_application=Mock(side_effect=wrapper),
|
|
69
|
+
),
|
|
70
|
+
OnlineChangeOption=SimpleNamespace(Never='Never'),
|
|
71
|
+
Severity=SimpleNamespace(Error=1, FatalError=2),
|
|
72
|
+
system=SimpleNamespace(
|
|
73
|
+
get_message_categories=lambda: ['compiler'],
|
|
74
|
+
clear_messages=lambda category: self.messages.clear(),
|
|
75
|
+
get_message_objects=lambda category, severity: self.messages,
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
def imported(*args):
|
|
79
|
+
self.events.append('import')
|
|
80
|
+
self.source_imported = True
|
|
81
|
+
replacements = {
|
|
82
|
+
'apply_controller': Mock(side_effect=lambda *args: self.events.append('controller')),
|
|
83
|
+
'import_objects': Mock(side_effect=imported),
|
|
84
|
+
'core_application': Mock(return_value=self.core),
|
|
85
|
+
'session_login': Mock(),
|
|
86
|
+
'controller_ids': Mock(return_value=self.ids),
|
|
87
|
+
'controller_state': Mock(return_value='run'),
|
|
88
|
+
'require_current_boot_project': Mock(),
|
|
89
|
+
'load_downloaded_reference': Mock(return_value=self.ids),
|
|
90
|
+
'reconcile': Mock(),
|
|
91
|
+
'generate_online_code': Mock(),
|
|
92
|
+
'consolidated_manager': Mock(return_value=SimpleNamespace(GetCompileContext=Mock())),
|
|
93
|
+
'context_ids': Mock(return_value=self.ids),
|
|
94
|
+
'loaded_type': Mock(side_effect=lambda name:
|
|
95
|
+
self.boot_api if name == '_3S.CoDeSys.Core.Online.IOnlineApplication3' else None),
|
|
96
|
+
}
|
|
97
|
+
patcher = patch.multiple(codesys_online, **replacements)
|
|
98
|
+
patcher.start()
|
|
99
|
+
self.addCleanup(patcher.stop)
|
|
100
|
+
self.request = {
|
|
101
|
+
'project': 'Application.project', 'operation': 'download', 'symbols': False,
|
|
102
|
+
'objects': [], 'removeObjects': ['Visualization Manager'],
|
|
103
|
+
'online': {'gateway': {'host': 'gateway', 'port': 1217},
|
|
104
|
+
'device': {'host': 'plc', 'port': 11740}},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
def test_download_rebuilds_imported_tree_before_online_login(self):
|
|
108
|
+
result = codesys_online.run_online(self.engine, self.request)
|
|
109
|
+
self.assertEqual(self.events, ['import', 'clean', 'compile', 'generate', 'online-wrapper'])
|
|
110
|
+
self.assertEqual(result['controllerIds'], self.ids)
|
|
111
|
+
self.core.CreateBootProject.assert_called_once()
|
|
112
|
+
self.boot_api.GetMethod.assert_called_once_with('CreateBootProject')
|
|
113
|
+
self.boot_method.Invoke.assert_called_once_with(self.core, None)
|
|
114
|
+
self.project.close.assert_called_once()
|
|
115
|
+
self.app.build.assert_not_called()
|
|
116
|
+
|
|
117
|
+
def test_download_switches_the_template_controller_before_importing(self):
|
|
118
|
+
self.request['controller'] = {'type': 4102, 'id': '0000 0005', 'version': '4.22.0.0'}
|
|
119
|
+
codesys_online.run_online(self.engine, self.request)
|
|
120
|
+
self.assertEqual(self.events[:2], ['controller', 'import'])
|
|
121
|
+
|
|
122
|
+
def test_invalid_full_download_source_never_creates_online_wrapper_or_transfers(self):
|
|
123
|
+
self.app.rebuild.side_effect = RuntimeError('Invalid source declaration')
|
|
124
|
+
with self.assertRaisesRegex(RuntimeError, 'Invalid source declaration'):
|
|
125
|
+
codesys_online.run_online(self.engine, self.request)
|
|
126
|
+
self.engine.online.create_online_application.assert_not_called()
|
|
127
|
+
self.core.CreateBootProject.assert_not_called()
|
|
128
|
+
self.project.close.assert_called_once()
|
|
129
|
+
|
|
130
|
+
def test_online_change_keeps_the_downloaded_reference_and_builds_incrementally(self):
|
|
131
|
+
# The downloaded project already carries the controller.
|
|
132
|
+
self.request.update(operation='online-change', controller={
|
|
133
|
+
'type': 4102, 'id': '0000 0005', 'version': '4.22.0.0',
|
|
134
|
+
}, baseline={
|
|
135
|
+
'applicationGuid': self.app.guid, 'controllerIds': self.ids, 'objects': [],
|
|
136
|
+
})
|
|
137
|
+
result = codesys_online.run_online(self.engine, self.request)
|
|
138
|
+
self.assertEqual(self.events, ['online-wrapper', 'import', 'compile'])
|
|
139
|
+
self.app.clean.assert_not_called()
|
|
140
|
+
self.app.rebuild.assert_not_called()
|
|
141
|
+
self.app.generate_code.assert_not_called()
|
|
142
|
+
self.core.Download.assert_called_once_with(True, 'UseProjectSetting')
|
|
143
|
+
self.assertEqual(result['state'], 'run')
|
|
144
|
+
|
|
145
|
+
def test_full_download_rejects_library_errors_reported_after_transfer(self):
|
|
146
|
+
def report_library_error(core):
|
|
147
|
+
self.messages.append(SimpleNamespace(text="Could not open library '#VisuShared'."))
|
|
148
|
+
codesys_online.session_login.side_effect = report_library_error
|
|
149
|
+
with self.assertRaisesRegex(RuntimeError, "Could not open library '#VisuShared'"):
|
|
150
|
+
codesys_online.run_online(self.engine, self.request)
|
|
151
|
+
self.core.CreateBootProject.assert_called_once()
|
|
152
|
+
self.project.close.assert_called_once()
|
|
153
|
+
|
|
154
|
+
def test_boot_failure_preserves_vendor_clr_stack_and_never_confirms_completion(self):
|
|
155
|
+
stack = ('System.ArgumentException: The path is not of a legal form.\n'
|
|
156
|
+
' at System.IO.Path.GetFullPath(String path)\n'
|
|
157
|
+
' at Vendor.BootProvider.PrepareTransfer()')
|
|
158
|
+
self.core.CreateBootProject.side_effect = TargetInvocationException(
|
|
159
|
+
SimpleNamespace(ToString=lambda: stack))
|
|
160
|
+
with self.assertRaises(RuntimeError) as failure:
|
|
161
|
+
codesys_online.run_online(self.engine, self.request)
|
|
162
|
+
self.assertEqual(str(failure.exception), 'Controller boot project creation failed:\n' + stack)
|
|
163
|
+
self.core.CreateBootProject.assert_called_once()
|
|
164
|
+
codesys_online.require_current_boot_project.assert_not_called()
|
|
165
|
+
codesys_online.session_login.assert_not_called()
|
|
166
|
+
self.project.close.assert_called_once()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == '__main__':
|
|
170
|
+
unittest.main()
|