tw-framework 0.2.3__py3-none-any.whl
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.
- tw_framework/__init__.py +13 -0
- tw_framework/advanced_diagnostics.py +25 -0
- tw_framework/asset_optimizer.py +30 -0
- tw_framework/ast_nodes.py +283 -0
- tw_framework/build.py +7 -0
- tw_framework/build_performance.py +26 -0
- tw_framework/build_report.py +84 -0
- tw_framework/cli.py +1258 -0
- tw_framework/code_splitting.py +76 -0
- tw_framework/common.py +22 -0
- tw_framework/compiler.py +4817 -0
- tw_framework/compiler_stats.py +51 -0
- tw_framework/dead_code.py +44 -0
- tw_framework/dependency_graph.py +91 -0
- tw_framework/deploy.py +7 -0
- tw_framework/dev.py +9 -0
- tw_framework/diagnostics.py +98 -0
- tw_framework/dynamic_imports.py +51 -0
- tw_framework/error_formatter.py +53 -0
- tw_framework/framework.py +3216 -0
- tw_framework/hydration.py +129 -0
- tw_framework/incremental_cache.py +53 -0
- tw_framework/interpreter.py +42 -0
- tw_framework/ir.py +171 -0
- tw_framework/lexer.py +52 -0
- tw_framework/lowering.py +68 -0
- tw_framework/parser.py +167 -0
- tw_framework/partial_rebuild.py +24 -0
- tw_framework/performance_analyzer.py +37 -0
- tw_framework/plugin_runtime.py +196 -0
- tw_framework/production_optimizer.py +82 -0
- tw_framework/reactivity.py +349 -0
- tw_framework/render_css.py +22 -0
- tw_framework/render_html.py +259 -0
- tw_framework/route_optimizer.py +40 -0
- tw_framework/router.py +7 -0
- tw_framework/runtime_model.py +23 -0
- tw_framework/runtime_values.py +31 -0
- tw_framework/semantic.py +162 -0
- tw_framework/server.py +532 -0
- tw_framework/signature.py +51 -0
- tw_framework/static_dynamic_auto.py +32 -0
- tw_framework/streaming.py +135 -0
- tw_framework/test_build_cli_pytest.py +1078 -0
- tw_framework/tree_shaking.py +48 -0
- tw_framework/twm_parser.py +274 -0
- tw_framework-0.2.3.dist-info/METADATA +8 -0
- tw_framework-0.2.3.dist-info/RECORD +51 -0
- tw_framework-0.2.3.dist-info/WHEEL +5 -0
- tw_framework-0.2.3.dist-info/entry_points.txt +2 -0
- tw_framework-0.2.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import http.server
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import socketserver
|
|
8
|
+
import stat
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from tw_framework import cli, compiler, framework, server
|
|
16
|
+
from tw_framework.plugin_runtime import ExtensionManager
|
|
17
|
+
from tw_framework.reactivity import parse_state_block
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _write(path: Path, text: str) -> None:
|
|
21
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
path.write_text(text, encoding="utf-8")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def make_project(tmp_path: Path, *, modular: bool = True, pretty_urls: bool = True) -> Path:
|
|
26
|
+
root = tmp_path / "proj"
|
|
27
|
+
home = root / "[home]"
|
|
28
|
+
(home / "pages").mkdir(parents=True, exist_ok=True)
|
|
29
|
+
(home / "components").mkdir(parents=True, exist_ok=True)
|
|
30
|
+
(home / "layouts").mkdir(parents=True, exist_ok=True)
|
|
31
|
+
|
|
32
|
+
_write(
|
|
33
|
+
root / "tw.config",
|
|
34
|
+
"\n".join(
|
|
35
|
+
[
|
|
36
|
+
"name: Test Site",
|
|
37
|
+
f"pretty_urls: {'true' if pretty_urls else 'false'}",
|
|
38
|
+
f"modular_pipeline: {'true' if modular else 'false'}",
|
|
39
|
+
"search: false",
|
|
40
|
+
"",
|
|
41
|
+
]
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
_write(root / "package.json", '{"name":"test","private":true}\n')
|
|
45
|
+
_write(root / "vercel.json", "{}\n")
|
|
46
|
+
|
|
47
|
+
_write(
|
|
48
|
+
home / "layouts" / "main.tw",
|
|
49
|
+
"<!doctype html><html><head>{head}{styles}</head><body>{slot}{scripts}</body></html>",
|
|
50
|
+
)
|
|
51
|
+
_write(
|
|
52
|
+
home / "index.tw",
|
|
53
|
+
"""
|
|
54
|
+
page {
|
|
55
|
+
title "Home"
|
|
56
|
+
layout "main"
|
|
57
|
+
render static
|
|
58
|
+
}
|
|
59
|
+
BODY { div "Home" }
|
|
60
|
+
""".lstrip(),
|
|
61
|
+
)
|
|
62
|
+
return root
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_static_route_build_pretty_urls(tmp_path: Path):
|
|
66
|
+
root = make_project(tmp_path, pretty_urls=True)
|
|
67
|
+
home = root / "[home]"
|
|
68
|
+
_write(
|
|
69
|
+
home / "pages" / "about.tw",
|
|
70
|
+
"""
|
|
71
|
+
page { title "About" layout "main" render static }
|
|
72
|
+
BODY { h1 "About" }
|
|
73
|
+
""".lstrip(),
|
|
74
|
+
)
|
|
75
|
+
summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
|
|
76
|
+
assert summary.errors == 0
|
|
77
|
+
assert (root / "dist" / "about" / "index.html").exists()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_dynamic_route_single_build(tmp_path: Path):
|
|
81
|
+
root = make_project(tmp_path)
|
|
82
|
+
home = root / "[home]"
|
|
83
|
+
_write(
|
|
84
|
+
home / "pages" / "[id].tw",
|
|
85
|
+
"""
|
|
86
|
+
page { title "Item" layout "main" render static }
|
|
87
|
+
BODY { h1 "{id}" }
|
|
88
|
+
""".lstrip(),
|
|
89
|
+
)
|
|
90
|
+
_write(home / "pages" / "[id].json", json.dumps([{"id": "a"}, {"id": "b"}]))
|
|
91
|
+
summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
|
|
92
|
+
assert summary.errors == 0
|
|
93
|
+
assert (root / "dist" / "a" / "index.html").exists()
|
|
94
|
+
assert (root / "dist" / "b" / "index.html").exists()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_dynamic_route_catch_all_build(tmp_path: Path):
|
|
98
|
+
root = make_project(tmp_path)
|
|
99
|
+
home = root / "[home]"
|
|
100
|
+
_write(
|
|
101
|
+
home / "pages" / "[...x].tw",
|
|
102
|
+
"""
|
|
103
|
+
page { title "Catch" layout "main" render static }
|
|
104
|
+
BODY { p "{x}" }
|
|
105
|
+
""".lstrip(),
|
|
106
|
+
)
|
|
107
|
+
_write(home / "pages" / "[...x].json", json.dumps([{"x": "a/b"}]))
|
|
108
|
+
summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
|
|
109
|
+
assert summary.errors == 0
|
|
110
|
+
assert (root / "dist" / "a" / "b" / "index.html").exists()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_dynamic_route_optional_catch_all_build(tmp_path: Path):
|
|
114
|
+
root = make_project(tmp_path)
|
|
115
|
+
home = root / "[home]"
|
|
116
|
+
_write(
|
|
117
|
+
home / "pages" / "[[...x]].tw",
|
|
118
|
+
"""
|
|
119
|
+
page { title "Opt" layout "main" render static }
|
|
120
|
+
BODY { p "ok" }
|
|
121
|
+
""".lstrip(),
|
|
122
|
+
)
|
|
123
|
+
_write(home / "pages" / "[[...x]].json", json.dumps([{"x": None}]))
|
|
124
|
+
summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
|
|
125
|
+
assert summary.errors == 0
|
|
126
|
+
# optional catch-all can map to `/` (no segments) for null/empty input
|
|
127
|
+
assert (root / "dist" / "index.html").exists()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_imported_component_supports_unicode_text(tmp_path: Path):
|
|
131
|
+
root = make_project(tmp_path)
|
|
132
|
+
home = root / "[home]"
|
|
133
|
+
_write(
|
|
134
|
+
home / "components" / "Profile.tw",
|
|
135
|
+
"""
|
|
136
|
+
div {
|
|
137
|
+
p "Python • JavaScript • HTML • CSS"
|
|
138
|
+
p "Hello — “world” 🚀"
|
|
139
|
+
}
|
|
140
|
+
""".lstrip(),
|
|
141
|
+
)
|
|
142
|
+
_write(
|
|
143
|
+
home / "index.tw",
|
|
144
|
+
"""
|
|
145
|
+
page { title "Home" layout "main" render static }
|
|
146
|
+
BODY {
|
|
147
|
+
import "Profile"
|
|
148
|
+
Profile {}
|
|
149
|
+
}
|
|
150
|
+
""".lstrip(),
|
|
151
|
+
)
|
|
152
|
+
summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
|
|
153
|
+
assert summary.errors == 0
|
|
154
|
+
html = (root / "dist" / "index.html").read_text(encoding="utf-8")
|
|
155
|
+
assert "Python • JavaScript • HTML • CSS" in html
|
|
156
|
+
assert "Hello — “world” 🚀" in html
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_imported_component_repairs_common_mojibake(tmp_path: Path):
|
|
160
|
+
root = make_project(tmp_path)
|
|
161
|
+
home = root / "[home]"
|
|
162
|
+
_write(
|
|
163
|
+
home / "components" / "Profile.tw",
|
|
164
|
+
"""
|
|
165
|
+
div {
|
|
166
|
+
p "Python • JavaScript — HTML"
|
|
167
|
+
}
|
|
168
|
+
""".lstrip(),
|
|
169
|
+
)
|
|
170
|
+
_write(
|
|
171
|
+
home / "index.tw",
|
|
172
|
+
"""
|
|
173
|
+
page { title "Home" layout "main" render static }
|
|
174
|
+
BODY {
|
|
175
|
+
import "Profile"
|
|
176
|
+
Profile {}
|
|
177
|
+
}
|
|
178
|
+
""".lstrip(),
|
|
179
|
+
)
|
|
180
|
+
summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
|
|
181
|
+
assert summary.errors == 0
|
|
182
|
+
html = (root / "dist" / "index.html").read_text(encoding="utf-8")
|
|
183
|
+
assert "Python • JavaScript — HTML" in html
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_component_block_accepts_semicolon_separators(tmp_path: Path):
|
|
187
|
+
root = make_project(tmp_path)
|
|
188
|
+
home = root / "[home]"
|
|
189
|
+
_write(
|
|
190
|
+
home / "components" / "Examples.tw",
|
|
191
|
+
"""
|
|
192
|
+
section {
|
|
193
|
+
div {
|
|
194
|
+
class "example-grid"
|
|
195
|
+
div { h3 "Landing Page"; p "Modern UI page" }
|
|
196
|
+
div { h3 "Dashboard"; p "Admin panel UI" }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
""".lstrip(),
|
|
200
|
+
)
|
|
201
|
+
_write(
|
|
202
|
+
home / "index.tw",
|
|
203
|
+
"""
|
|
204
|
+
page { title "Home" layout "main" render static }
|
|
205
|
+
BODY {
|
|
206
|
+
import "Examples"
|
|
207
|
+
Examples {}
|
|
208
|
+
}
|
|
209
|
+
""".lstrip(),
|
|
210
|
+
)
|
|
211
|
+
summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
|
|
212
|
+
assert summary.errors == 0
|
|
213
|
+
html = (root / "dist" / "index.html").read_text(encoding="utf-8")
|
|
214
|
+
assert "Landing Page" in html
|
|
215
|
+
assert "Modern UI page" in html
|
|
216
|
+
assert "Dashboard" in html
|
|
217
|
+
assert "Admin panel UI" in html
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_circular_component_import_detection(tmp_path: Path):
|
|
221
|
+
root = make_project(tmp_path)
|
|
222
|
+
framework.configure_compiler_paths(str(root))
|
|
223
|
+
_write(root / "[home]" / "components" / "A.tw", 'div { import "B" p "A" }')
|
|
224
|
+
_write(root / "[home]" / "components" / "B.tw", 'div { import "A" p "B" }')
|
|
225
|
+
with pytest.raises(compiler.CompilerError) as exc:
|
|
226
|
+
compiler.collect_component_dependencies("A")
|
|
227
|
+
assert "Circular component import" in str(exc.value)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_component_not_found_error(tmp_path: Path):
|
|
231
|
+
root = make_project(tmp_path)
|
|
232
|
+
home = root / "[home]"
|
|
233
|
+
_write(
|
|
234
|
+
home / "pages" / "broken.tw",
|
|
235
|
+
"""
|
|
236
|
+
page { title "Broken" layout "main" render static }
|
|
237
|
+
BODY { import "Nope" }
|
|
238
|
+
""".lstrip(),
|
|
239
|
+
)
|
|
240
|
+
summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
|
|
241
|
+
assert summary.errors >= 1
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_component_level_load_directive(tmp_path: Path):
|
|
245
|
+
root = make_project(tmp_path)
|
|
246
|
+
home = root / "[home]"
|
|
247
|
+
_write(home / "styles" / "extra.tss", ".x { color red }")
|
|
248
|
+
_write(
|
|
249
|
+
home / "components" / "Card.tw",
|
|
250
|
+
"""
|
|
251
|
+
load @./styles/extra.tss
|
|
252
|
+
div { class "x" text "Card" }
|
|
253
|
+
""".lstrip(),
|
|
254
|
+
)
|
|
255
|
+
_write(
|
|
256
|
+
home / "pages" / "card.tw",
|
|
257
|
+
"""
|
|
258
|
+
page { title "Card" layout "main" render static }
|
|
259
|
+
BODY { import "Card" Card {} }
|
|
260
|
+
""".lstrip(),
|
|
261
|
+
)
|
|
262
|
+
summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
|
|
263
|
+
assert summary.errors == 0
|
|
264
|
+
html = (root / "dist" / "card" / "index.html").read_text("utf-8")
|
|
265
|
+
assert "color: red" in html
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_resolve_source_path_supports_project_and_home_aliases(tmp_path: Path):
|
|
269
|
+
root = make_project(tmp_path)
|
|
270
|
+
framework.configure_compiler_paths(str(root))
|
|
271
|
+
base_dir = str(root / "[home]" / "pages")
|
|
272
|
+
assert compiler.resolve_source_path("page.tss", base_dir) == str(root / "[home]" / "pages" / "page.tss")
|
|
273
|
+
assert compiler.resolve_source_path("@lib/mlkraj.tw", base_dir) == str(root / "lib" / "mlkraj.tw")
|
|
274
|
+
assert compiler.resolve_source_path("@./lib/mlkraj.tw", base_dir) == str(root / "[home]" / "lib" / "mlkraj.tw")
|
|
275
|
+
assert compiler.resolve_source_path("@../style/site.tss", base_dir) == str(root / "[home]" / "style" / "site.tss")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_quoted_load_uses_same_folder(tmp_path: Path):
|
|
279
|
+
root = make_project(tmp_path)
|
|
280
|
+
home = root / "[home]"
|
|
281
|
+
(home / "pages" / "apps").mkdir(parents=True, exist_ok=True)
|
|
282
|
+
_write(home / "pages" / "apps" / "page.tss", ".apps-page { color blue }")
|
|
283
|
+
_write(
|
|
284
|
+
home / "pages" / "apps" / "page.tw",
|
|
285
|
+
"""
|
|
286
|
+
page { title "Apps" layout "main" render static }
|
|
287
|
+
load "page.tss"
|
|
288
|
+
BODY { div { class "apps-page" text "Apps" } }
|
|
289
|
+
""".lstrip(),
|
|
290
|
+
)
|
|
291
|
+
summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
|
|
292
|
+
assert summary.errors == 0
|
|
293
|
+
html = (root / "dist" / "apps" / "page" / "index.html").read_text("utf-8")
|
|
294
|
+
assert "color: blue" in html
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_layout_level_load_directive(tmp_path: Path):
|
|
298
|
+
root = make_project(tmp_path)
|
|
299
|
+
home = root / "[home]"
|
|
300
|
+
_write(home / "components" / "Header.tw", 'header { h1 "Header" }')
|
|
301
|
+
_write(
|
|
302
|
+
home / "layouts" / "main.tw",
|
|
303
|
+
"<!doctype html><html><head>{head}{styles}</head><body>\n"
|
|
304
|
+
"load @./components/Header.tw\n"
|
|
305
|
+
"{slot}{scripts}\n"
|
|
306
|
+
"</body></html>",
|
|
307
|
+
)
|
|
308
|
+
_write(
|
|
309
|
+
home / "pages" / "x.tw",
|
|
310
|
+
"""
|
|
311
|
+
page {
|
|
312
|
+
title "X"
|
|
313
|
+
layout "main"
|
|
314
|
+
render static
|
|
315
|
+
}
|
|
316
|
+
BODY { p "ok" }
|
|
317
|
+
""".lstrip(),
|
|
318
|
+
)
|
|
319
|
+
summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
|
|
320
|
+
assert summary.errors == 0
|
|
321
|
+
html = (root / "dist" / "x" / "index.html").read_text("utf-8")
|
|
322
|
+
assert "<header>" in html and "Header" in html
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def test_layout_chaining_multiple_layouts(tmp_path: Path):
|
|
326
|
+
root = make_project(tmp_path)
|
|
327
|
+
home = root / "[home]"
|
|
328
|
+
_write(home / "layouts" / "outer.tw", "<html><body><main>OUTER{slot}</main></body></html>")
|
|
329
|
+
_write(home / "layouts" / "inner.tw", "<section>INNER{slot}</section>")
|
|
330
|
+
_write(
|
|
331
|
+
home / "pages" / "chain.tw",
|
|
332
|
+
"""
|
|
333
|
+
page {
|
|
334
|
+
title "Chain"
|
|
335
|
+
layout "outer > inner"
|
|
336
|
+
render static
|
|
337
|
+
}
|
|
338
|
+
BODY { p "Hello" }
|
|
339
|
+
""".lstrip(),
|
|
340
|
+
)
|
|
341
|
+
summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
|
|
342
|
+
assert summary.errors == 0
|
|
343
|
+
html = (root / "dist" / "chain" / "index.html").read_text("utf-8")
|
|
344
|
+
assert "OUTER" in html and "INNER" in html and "Hello" in html
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_undefined_symbol_diagnostics_edge_cases(tmp_path: Path):
|
|
348
|
+
root = make_project(tmp_path)
|
|
349
|
+
tw = root / "[home]" / "pages" / "warn.tw"
|
|
350
|
+
_write(
|
|
351
|
+
tw,
|
|
352
|
+
"""
|
|
353
|
+
page { title "Warn" layout "main" render static }
|
|
354
|
+
let ok = 1
|
|
355
|
+
BODY {
|
|
356
|
+
if missingCond { div "x" }
|
|
357
|
+
for item in missingList { div { class "{missingAttr}" text "{item}" } }
|
|
358
|
+
}
|
|
359
|
+
""".lstrip(),
|
|
360
|
+
)
|
|
361
|
+
artifacts = compiler.compile_file_pipeline(str(tw), capture_errors=True)
|
|
362
|
+
warnings = [d for d in artifacts.diagnostics if d.get("severity") == "warning"]
|
|
363
|
+
codes = {d.get("code") for d in warnings}
|
|
364
|
+
assert "TW2001" in codes
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def test_cli_build_exit_code_zero_clean(tmp_path: Path):
|
|
368
|
+
root = make_project(tmp_path)
|
|
369
|
+
args = type(
|
|
370
|
+
"Args",
|
|
371
|
+
(),
|
|
372
|
+
dict(
|
|
373
|
+
project_root=str(root),
|
|
374
|
+
out_dir=str(root / "dist"),
|
|
375
|
+
force=False,
|
|
376
|
+
workers=1,
|
|
377
|
+
dev=False,
|
|
378
|
+
prod=False,
|
|
379
|
+
watch=False,
|
|
380
|
+
analyze=False,
|
|
381
|
+
clean=False,
|
|
382
|
+
no_minify=True,
|
|
383
|
+
fail_on_warnings=False,
|
|
384
|
+
strict=False,
|
|
385
|
+
),
|
|
386
|
+
)()
|
|
387
|
+
assert cli.command_build(args) == 0
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def test_cli_build_prod_fails_on_warnings(tmp_path: Path):
|
|
391
|
+
root = make_project(tmp_path)
|
|
392
|
+
home = root / "[home]"
|
|
393
|
+
_write(home / "pages" / "warn.tw", 'page { title "W" layout "main" render static } BODY { p "{missing}" }')
|
|
394
|
+
args = type(
|
|
395
|
+
"Args",
|
|
396
|
+
(),
|
|
397
|
+
dict(
|
|
398
|
+
project_root=str(root),
|
|
399
|
+
out_dir=str(root / "dist"),
|
|
400
|
+
force=True,
|
|
401
|
+
workers=1,
|
|
402
|
+
dev=False,
|
|
403
|
+
prod=True,
|
|
404
|
+
watch=False,
|
|
405
|
+
analyze=False,
|
|
406
|
+
clean=False,
|
|
407
|
+
no_minify=True,
|
|
408
|
+
fail_on_warnings=False,
|
|
409
|
+
strict=False,
|
|
410
|
+
),
|
|
411
|
+
)()
|
|
412
|
+
assert cli.command_build(args) == 1
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def test_cli_check_json_shape(tmp_path: Path, capsys: pytest.CaptureFixture[str]):
|
|
416
|
+
root = make_project(tmp_path)
|
|
417
|
+
tw = root / "[home]" / "pages" / "ok.tw"
|
|
418
|
+
_write(tw, 'page { title "Ok" layout "main" render static } BODY { p "ok" }')
|
|
419
|
+
args = type("Args", (), dict(file=str(tw), out=None, include_ast=False, include_ir=False))()
|
|
420
|
+
code = cli.command_check(args)
|
|
421
|
+
out = capsys.readouterr().out
|
|
422
|
+
payload = json.loads(out)
|
|
423
|
+
assert code == 0
|
|
424
|
+
assert set(payload.keys()) >= {"diagnostics", "metadata", "dependencies", "route_path", "pipeline", "summary"}
|
|
425
|
+
assert payload["summary"]["errors"] == 0
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def test_cli_check_include_ast_and_ir_are_json_serializable(tmp_path: Path, capsys: pytest.CaptureFixture[str]):
|
|
429
|
+
root = make_project(tmp_path)
|
|
430
|
+
tw = root / "[home]" / "pages" / "ok.tw"
|
|
431
|
+
_write(
|
|
432
|
+
tw,
|
|
433
|
+
"""
|
|
434
|
+
page { title "Ok" layout "main" render static }
|
|
435
|
+
load @../style/extra.tss
|
|
436
|
+
BODY { p "ok" }
|
|
437
|
+
""".lstrip(),
|
|
438
|
+
)
|
|
439
|
+
_write(root / "[home]" / "style" / "extra.tss", ".ok { color red }")
|
|
440
|
+
args = type("Args", (), dict(file=str(tw), out=None, include_ast=True, include_ir=True))()
|
|
441
|
+
code = cli.command_check(args)
|
|
442
|
+
out = capsys.readouterr().out
|
|
443
|
+
payload = json.loads(out)
|
|
444
|
+
assert code == 0
|
|
445
|
+
assert "ast" in payload and "ir" in payload
|
|
446
|
+
assert isinstance(payload["ast"]["loaded_sheets"], list)
|
|
447
|
+
assert payload["metadata"]["diagnostic_summary"]["errors"] == 0
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def test_compile_pipeline_reports_failure_phase(tmp_path: Path):
|
|
451
|
+
root = make_project(tmp_path)
|
|
452
|
+
tw = root / "[home]" / "pages" / "broken.tw"
|
|
453
|
+
_write(tw, 'page { title "Broken" layout "main" render static } BODY { p "oops }')
|
|
454
|
+
artifacts = compiler.compile_file_pipeline(str(tw), capture_errors=True)
|
|
455
|
+
assert artifacts.diagnostics
|
|
456
|
+
assert artifacts.diagnostics[0]["phase"] in {"tokenize", "parse"}
|
|
457
|
+
assert artifacts.metadata["diagnostic_summary"]["errors"] >= 1
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def test_cli_doctor_smoke(tmp_path: Path):
|
|
461
|
+
root = make_project(tmp_path)
|
|
462
|
+
args = type("Args", (), dict(project_root=str(root)))()
|
|
463
|
+
assert cli.command_doctor(args) == 0
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def test_cli_create_scaffold_generates_files(tmp_path: Path):
|
|
467
|
+
target_parent = tmp_path / "parent"
|
|
468
|
+
target_parent.mkdir()
|
|
469
|
+
cli.create_project("hello", str(target_parent))
|
|
470
|
+
root = target_parent / "hello"
|
|
471
|
+
assert (root / "tw.config").exists()
|
|
472
|
+
assert (root / "[home]" / "index.tw").exists()
|
|
473
|
+
assert (root / ".gitignore").exists()
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def test_incremental_build_cache_skips(tmp_path: Path):
|
|
477
|
+
root = make_project(tmp_path)
|
|
478
|
+
out_dir = root / "dist"
|
|
479
|
+
first = framework.build_hidden_site(str(root), str(out_dir), workers=1, minify=False)
|
|
480
|
+
second = framework.build_hidden_site(str(root), str(out_dir), workers=1, minify=False)
|
|
481
|
+
assert first.errors == 0
|
|
482
|
+
assert second.errors == 0
|
|
483
|
+
assert second.skipped >= 1
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def test_dependency_graph_contains_component_deps(tmp_path: Path):
|
|
487
|
+
root = make_project(tmp_path)
|
|
488
|
+
home = root / "[home]"
|
|
489
|
+
_write(home / "components" / "Thing.tw", 'div { p "T" }')
|
|
490
|
+
_write(home / "pages" / "uses.tw", 'page { title "U" layout "main" render static } BODY { import "Thing" Thing {} }')
|
|
491
|
+
framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
|
|
492
|
+
graph_path = root / ".tw" / "cache" / "dependency-graph.json"
|
|
493
|
+
payload = json.loads(graph_path.read_text("utf-8"))
|
|
494
|
+
forward = payload["forward"]
|
|
495
|
+
# At least one page should include the component .tw path as a dependency
|
|
496
|
+
assert any("Thing.tw" in " ".join(deps) for deps in forward.values())
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def test_parse_state_block_basic():
|
|
500
|
+
state = parse_state_block(
|
|
501
|
+
"""
|
|
502
|
+
state {
|
|
503
|
+
count 0
|
|
504
|
+
name "World"
|
|
505
|
+
items [1,2,3]
|
|
506
|
+
}
|
|
507
|
+
""".lstrip()
|
|
508
|
+
)
|
|
509
|
+
assert state["count"] == 0
|
|
510
|
+
assert state["name"] == "World"
|
|
511
|
+
assert state["items"] == [1, 2, 3]
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def test_css_normalization_edge_cases():
|
|
515
|
+
css = framework.compiler.render_css( # type: ignore[attr-defined]
|
|
516
|
+
compiler.build_tss_ast_from_text(
|
|
517
|
+
"""
|
|
518
|
+
.x {
|
|
519
|
+
padding 0
|
|
520
|
+
margin -2
|
|
521
|
+
width 50%
|
|
522
|
+
}
|
|
523
|
+
"""
|
|
524
|
+
)
|
|
525
|
+
)
|
|
526
|
+
assert "padding: 0;" in css
|
|
527
|
+
assert "margin: -2px;" in css
|
|
528
|
+
assert "width: 50%;" in css
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def test_minifiers_do_not_crash():
|
|
532
|
+
assert compiler.minify_html_content("<div> \n <span> x </span>\n</div>")
|
|
533
|
+
assert compiler.minify_css_content("/*x*/ .a { color : red ; }")
|
|
534
|
+
assert compiler.minify_js_content("//c\n/*x*/\nvar a = 1;\n")
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def test_dynamic_json_malformed_returns_empty_and_warns(tmp_path: Path, capsys: pytest.CaptureFixture[str]):
|
|
538
|
+
root = make_project(tmp_path)
|
|
539
|
+
home = root / "[home]"
|
|
540
|
+
_write(home / "pages" / "[id].tw", 'page { title "D" layout "main" render static } BODY { p "x" }')
|
|
541
|
+
_write(home / "pages" / "[id].json", "{not: json")
|
|
542
|
+
summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
|
|
543
|
+
err = capsys.readouterr().err
|
|
544
|
+
assert summary.errors == 0 # malformed dynamic items should not crash the whole build
|
|
545
|
+
assert "Failed to parse dynamic route JSON" in err
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def test_route_collision_warning_and_strict_error(tmp_path: Path):
|
|
549
|
+
root = make_project(tmp_path)
|
|
550
|
+
home = root / "[home]"
|
|
551
|
+
# Static /about
|
|
552
|
+
_write(home / "pages" / "about.tw", 'page { title "A" layout "main" render static } BODY { p "about" }')
|
|
553
|
+
# Dynamic [slug] that also emits /about
|
|
554
|
+
_write(home / "pages" / "[slug].tw", 'page { title "S" layout "main" render static } BODY { p "{slug}" }')
|
|
555
|
+
_write(home / "pages" / "[slug].json", json.dumps([{"slug": "about"}]))
|
|
556
|
+
|
|
557
|
+
non_strict = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False, strict=False)
|
|
558
|
+
assert non_strict.errors == 0
|
|
559
|
+
assert non_strict.route_collisions >= 1
|
|
560
|
+
|
|
561
|
+
strict = framework.build_hidden_site(str(root), str(root / "dist2"), workers=1, minify=False, strict=True)
|
|
562
|
+
assert strict.errors >= 1
|
|
563
|
+
assert strict.route_collisions >= 1
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def test_dynamic_json_schema_validation_is_error(tmp_path: Path):
|
|
567
|
+
root = make_project(tmp_path)
|
|
568
|
+
home = root / "[home]"
|
|
569
|
+
_write(home / "pages" / "[id].tw", 'page { title "D" layout "main" render static } BODY { p "{id}" }')
|
|
570
|
+
# Wrong schema (not list / not {items:[]})
|
|
571
|
+
_write(home / "pages" / "[id].json", json.dumps({"bad": True}))
|
|
572
|
+
summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
|
|
573
|
+
assert summary.errors >= 1
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def test_backup_before_write_manifest_and_dependency_graph(tmp_path: Path):
|
|
577
|
+
root = make_project(tmp_path)
|
|
578
|
+
framework.configure_compiler_paths(str(root))
|
|
579
|
+
# Seed old files
|
|
580
|
+
old_manifest = {"version": compiler.BUILD_MANIFEST_VERSION, "pages": {"x": {"outputs": []}}}
|
|
581
|
+
old_graph = {"version": compiler.DEPENDENCY_GRAPH_VERSION, "forward": {}, "reverse": {}, "metadata": {}}
|
|
582
|
+
(root / ".tw" / "manifest").mkdir(parents=True, exist_ok=True)
|
|
583
|
+
(root / ".tw" / "cache").mkdir(parents=True, exist_ok=True)
|
|
584
|
+
(root / ".tw" / "manifest" / "build-manifest.json").write_text(json.dumps(old_manifest), "utf-8")
|
|
585
|
+
(root / ".tw" / "cache" / "dependency-graph.json").write_text(json.dumps(old_graph), "utf-8")
|
|
586
|
+
|
|
587
|
+
compiler.save_build_manifest({"pages": {}})
|
|
588
|
+
compiler.save_dependency_graph({"forward": {}, "metadata": {}})
|
|
589
|
+
|
|
590
|
+
assert (root / ".tw" / "manifest" / "build-manifest.json.bak").exists()
|
|
591
|
+
assert (root / ".tw" / "cache" / "dependency-graph.json.bak").exists()
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def test_chunk_cache_is_bounded(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
595
|
+
root = make_project(tmp_path)
|
|
596
|
+
framework.configure_compiler_paths(str(root))
|
|
597
|
+
monkeypatch.setenv("TW_CHUNK_CACHE_MAX", "2")
|
|
598
|
+
framework.invalidate_compiler_caches()
|
|
599
|
+
compiler.write_chunk("a", "js")
|
|
600
|
+
compiler.write_chunk("b", "js")
|
|
601
|
+
compiler.write_chunk("c", "js")
|
|
602
|
+
assert len(compiler._CHUNK_CACHE) <= 2
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def test_env_parsing_inline_comments_and_quotes(tmp_path: Path):
|
|
606
|
+
root = make_project(tmp_path)
|
|
607
|
+
env_path = root / ".env"
|
|
608
|
+
env_path.write_text('A=1 # comment\nB="x # not comment"\nC=\'y # not comment\'\n', "utf-8")
|
|
609
|
+
env = framework.load_project_env(str(root), "development")
|
|
610
|
+
assert env["A"] == "1"
|
|
611
|
+
assert env["B"] == "x # not comment"
|
|
612
|
+
assert env["C"] == "y # not comment"
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def test_build_package_json_scaffolds_dependency_sections():
|
|
616
|
+
package_json = json.loads(cli.build_package_json("Demo App"))
|
|
617
|
+
assert package_json["name"] == "demo-app"
|
|
618
|
+
assert package_json["engines"]["node"] == ">=18"
|
|
619
|
+
assert package_json["dependencies"] == {}
|
|
620
|
+
assert package_json["devDependencies"] == {}
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def test_twm_async_helpers_support_http_env_and_pkg(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
624
|
+
if shutil.which("node") is None:
|
|
625
|
+
pytest.skip("Node.js is required for `.twm` runtime tests")
|
|
626
|
+
|
|
627
|
+
root = make_project(tmp_path)
|
|
628
|
+
home = root / "[home]"
|
|
629
|
+
handler_path = home / "api" / "proxy.twm"
|
|
630
|
+
_write(
|
|
631
|
+
handler_path,
|
|
632
|
+
"""
|
|
633
|
+
async fn post(request) {
|
|
634
|
+
const response = await http.post(
|
|
635
|
+
env.require("TW_TEST_TARGET") + "/echo",
|
|
636
|
+
{ hello: "world", incoming: request.body },
|
|
637
|
+
{ timeout: 3000 }
|
|
638
|
+
);
|
|
639
|
+
return {
|
|
640
|
+
json: {
|
|
641
|
+
ok: response.ok,
|
|
642
|
+
status: response.status,
|
|
643
|
+
echoed: response.data.received,
|
|
644
|
+
secret: secrets.require("TW_TEST_SECRET"),
|
|
645
|
+
sep: pkg.require("path").sep
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
""".lstrip(),
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
class EchoHandler(http.server.BaseHTTPRequestHandler):
|
|
653
|
+
def do_POST(self):
|
|
654
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
655
|
+
payload = self.rfile.read(length)
|
|
656
|
+
data = json.loads(payload.decode("utf-8"))
|
|
657
|
+
body = json.dumps({"received": data}).encode("utf-8")
|
|
658
|
+
self.send_response(200)
|
|
659
|
+
self.send_header("Content-Type", "application/json")
|
|
660
|
+
self.send_header("Content-Length", str(len(body)))
|
|
661
|
+
self.end_headers()
|
|
662
|
+
self.wfile.write(body)
|
|
663
|
+
|
|
664
|
+
def log_message(self, fmt, *args):
|
|
665
|
+
return
|
|
666
|
+
|
|
667
|
+
server_instance = socketserver.TCPServer(("127.0.0.1", 0), EchoHandler)
|
|
668
|
+
server_thread = threading.Thread(target=server_instance.serve_forever, daemon=True)
|
|
669
|
+
server_thread.start()
|
|
670
|
+
|
|
671
|
+
monkeypatch.setenv("TW_TEST_TARGET", f"http://127.0.0.1:{server_instance.server_address[1]}")
|
|
672
|
+
monkeypatch.setenv("TW_TEST_SECRET", "top-secret")
|
|
673
|
+
framework.configure_compiler_paths(str(root))
|
|
674
|
+
|
|
675
|
+
try:
|
|
676
|
+
response = framework.execute_twm_api_handler(
|
|
677
|
+
str(handler_path),
|
|
678
|
+
"POST",
|
|
679
|
+
"/api/proxy",
|
|
680
|
+
{"Content-Type": "application/json"},
|
|
681
|
+
{"user": "demo"},
|
|
682
|
+
)
|
|
683
|
+
finally:
|
|
684
|
+
server_instance.shutdown()
|
|
685
|
+
server_thread.join(timeout=2)
|
|
686
|
+
server_instance.server_close()
|
|
687
|
+
|
|
688
|
+
payload = json.loads(response["body"].decode("utf-8"))
|
|
689
|
+
assert response["status"] == 200
|
|
690
|
+
assert payload["ok"] is True
|
|
691
|
+
assert payload["status"] == 200
|
|
692
|
+
assert payload["echoed"] == {"hello": "world", "incoming": {"user": "demo"}}
|
|
693
|
+
assert payload["secret"] == "top-secret"
|
|
694
|
+
assert payload["sep"] in {"/", "\\"}
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def test_global_config_atomic_write(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
698
|
+
# Redirect global config to temp paths
|
|
699
|
+
cfg_dir = tmp_path / "cfg"
|
|
700
|
+
cfg_file = cfg_dir / "config.json"
|
|
701
|
+
monkeypatch.setattr(cli, "GLOBAL_CONFIG_DIR", str(cfg_dir))
|
|
702
|
+
monkeypatch.setattr(cli, "GLOBAL_CONFIG_FILE", str(cfg_file))
|
|
703
|
+
cli.save_global_config({"vercel_token": "t"})
|
|
704
|
+
assert cfg_file.exists()
|
|
705
|
+
data = json.loads(cfg_file.read_text("utf-8"))
|
|
706
|
+
assert data["vercel_token"] == "t"
|
|
707
|
+
assert stat.S_IMODE(os.stat(cfg_dir).st_mode) == 0o700
|
|
708
|
+
assert stat.S_IMODE(os.stat(cfg_file).st_mode) == 0o600
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def test_file_watcher_rescan_ticks_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
712
|
+
root = make_project(tmp_path)
|
|
713
|
+
framework.configure_compiler_paths(str(root))
|
|
714
|
+
project = framework.TWProject(str(root))
|
|
715
|
+
state = framework.TWDevState(project)
|
|
716
|
+
monkeypatch.setenv("TW_WATCH_RESCAN_TICKS", "1")
|
|
717
|
+
monkeypatch.setenv("TW_WATCH_BACKEND", "poll")
|
|
718
|
+
watcher = framework.TWFileWatcher(state, interval=0.2)
|
|
719
|
+
assert watcher.rescan_ticks == 1
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def test_file_watcher_inotify_setup_smoke(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
723
|
+
if not sys.platform.startswith("linux"):
|
|
724
|
+
pytest.skip("inotify backend is Linux-only")
|
|
725
|
+
root = make_project(tmp_path)
|
|
726
|
+
framework.configure_compiler_paths(str(root))
|
|
727
|
+
project = framework.TWProject(str(root))
|
|
728
|
+
state = framework.TWDevState(project)
|
|
729
|
+
monkeypatch.setenv("TW_WATCH_BACKEND", "inotify")
|
|
730
|
+
watcher = framework.TWFileWatcher(state, interval=0.2)
|
|
731
|
+
ok = watcher._inotify_setup()
|
|
732
|
+
if not ok:
|
|
733
|
+
pytest.skip("inotify not available in this environment")
|
|
734
|
+
assert watcher._inotify_fd is not None
|
|
735
|
+
# Cleanup: close fd to avoid leaking across tests
|
|
736
|
+
os.close(watcher._inotify_fd)
|
|
737
|
+
watcher._inotify_fd = None
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def test_thread_safety_build_workers(tmp_path: Path):
|
|
741
|
+
root = make_project(tmp_path)
|
|
742
|
+
home = root / "[home]"
|
|
743
|
+
_write(home / "components" / "Shared.tw", 'div { p "shared" }')
|
|
744
|
+
for i in range(12):
|
|
745
|
+
_write(
|
|
746
|
+
home / "pages" / f"p{i}.tw",
|
|
747
|
+
f"""
|
|
748
|
+
page {{
|
|
749
|
+
title "P{i}"
|
|
750
|
+
layout "main"
|
|
751
|
+
render static
|
|
752
|
+
}}
|
|
753
|
+
BODY {{ import "Shared" Shared {{}} }}
|
|
754
|
+
""".lstrip(),
|
|
755
|
+
)
|
|
756
|
+
summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=4, minify=False)
|
|
757
|
+
assert summary.errors == 0
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def test_component_import_path_traversal_rejected(tmp_path: Path):
|
|
761
|
+
root = make_project(tmp_path)
|
|
762
|
+
framework.configure_compiler_paths(str(root))
|
|
763
|
+
with pytest.raises(compiler.CompilerError):
|
|
764
|
+
compiler.resolve_component_path("../secrets")
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def test_component_name_absolute_and_null_bytes_rejected(tmp_path: Path):
|
|
768
|
+
root = make_project(tmp_path)
|
|
769
|
+
framework.configure_compiler_paths(str(root))
|
|
770
|
+
with pytest.raises(compiler.CompilerError):
|
|
771
|
+
compiler.resolve_component_path("/tmp/secret")
|
|
772
|
+
with pytest.raises(compiler.CompilerError):
|
|
773
|
+
compiler.resolve_component_path("Bad\x00Name")
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def test_extension_manager_warns_before_executing_plugins(tmp_path: Path, caplog: pytest.LogCaptureFixture):
|
|
777
|
+
root = make_project(tmp_path)
|
|
778
|
+
plugin_dir = root / "[home]" / "plugins"
|
|
779
|
+
_write(
|
|
780
|
+
plugin_dir / "sample.py",
|
|
781
|
+
"""
|
|
782
|
+
def register(api):
|
|
783
|
+
api.register_hook("beforeBuild", lambda payload: payload)
|
|
784
|
+
""".lstrip(),
|
|
785
|
+
)
|
|
786
|
+
caplog.set_level("WARNING")
|
|
787
|
+
manager = ExtensionManager(str(root), config={}, env={}).refresh()
|
|
788
|
+
assert manager.extensions
|
|
789
|
+
assert "Executing TW extensions from project code" in caplog.text
|
|
790
|
+
assert "sample.py" in caplog.text
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def test_extension_allowlist_blocks_unlisted_plugins(tmp_path: Path):
|
|
794
|
+
root = make_project(tmp_path)
|
|
795
|
+
plugin_dir = root / "[home]" / "plugins"
|
|
796
|
+
marker = root / "executed.txt"
|
|
797
|
+
_write(
|
|
798
|
+
plugin_dir / "allowed.py",
|
|
799
|
+
"""
|
|
800
|
+
def register(api):
|
|
801
|
+
api.register_hook("beforeBuild", lambda payload: dict(payload, allowed=True))
|
|
802
|
+
""".lstrip(),
|
|
803
|
+
)
|
|
804
|
+
_write(
|
|
805
|
+
plugin_dir / "blocked.py",
|
|
806
|
+
f"""
|
|
807
|
+
from pathlib import Path
|
|
808
|
+
Path({str(marker)!r}).write_text("blocked", encoding="utf-8")
|
|
809
|
+
|
|
810
|
+
def register(api):
|
|
811
|
+
api.register_hook("beforeBuild", lambda payload: payload)
|
|
812
|
+
""".lstrip(),
|
|
813
|
+
)
|
|
814
|
+
manager = ExtensionManager(
|
|
815
|
+
str(root),
|
|
816
|
+
config={"plugin_allowlist": ["allowed.py"]},
|
|
817
|
+
env={},
|
|
818
|
+
).refresh()
|
|
819
|
+
assert [ext.name for ext in manager.extensions] == ["allowed"]
|
|
820
|
+
assert not marker.exists()
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def test_create_project_gitignore_includes_cache_dir(tmp_path: Path):
|
|
824
|
+
target_parent = tmp_path / "parent2"
|
|
825
|
+
target_parent.mkdir()
|
|
826
|
+
cli.create_project("hello2", str(target_parent))
|
|
827
|
+
gitignore = (target_parent / "hello2" / ".gitignore").read_text("utf-8")
|
|
828
|
+
assert ".tw-cache/" in gitignore
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def test_load_config_supports_nested_blocks(tmp_path: Path):
|
|
832
|
+
root = make_project(tmp_path)
|
|
833
|
+
_write(
|
|
834
|
+
root / "tw.config",
|
|
835
|
+
"""
|
|
836
|
+
name: Nested Site
|
|
837
|
+
security:
|
|
838
|
+
headers:
|
|
839
|
+
X-Frame-Options: DENY
|
|
840
|
+
cookies:
|
|
841
|
+
secure: auto
|
|
842
|
+
ssr:
|
|
843
|
+
cache_max: 42
|
|
844
|
+
""".lstrip(),
|
|
845
|
+
)
|
|
846
|
+
framework.configure_compiler_paths(str(root))
|
|
847
|
+
config = compiler.load_config()
|
|
848
|
+
assert config["security"]["headers"]["X-Frame-Options"] == "DENY"
|
|
849
|
+
assert config["cookies"]["secure"] == "auto"
|
|
850
|
+
assert config["ssr"]["cache_max"] == 42
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def test_page_parser_accepts_cache_controls(tmp_path: Path):
|
|
854
|
+
root = make_project(tmp_path)
|
|
855
|
+
page_path = root / "[home]" / "pages" / "edge.tw"
|
|
856
|
+
_write(
|
|
857
|
+
page_path,
|
|
858
|
+
"""
|
|
859
|
+
page {
|
|
860
|
+
title "Edge"
|
|
861
|
+
layout "main"
|
|
862
|
+
render edge
|
|
863
|
+
revalidate 60
|
|
864
|
+
cache_by cookie:session_id
|
|
865
|
+
cache_size 3
|
|
866
|
+
}
|
|
867
|
+
BODY { div "edge" }
|
|
868
|
+
""".lstrip(),
|
|
869
|
+
)
|
|
870
|
+
framework.configure_compiler_paths(str(root))
|
|
871
|
+
page_ast = compiler.load_page_ast_from_file(str(page_path))
|
|
872
|
+
assert page_ast.cache_by == "cookie:session_id"
|
|
873
|
+
assert page_ast.cache_size == 3
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def test_rate_limit_is_opt_in_middleware_primitive(tmp_path: Path):
|
|
877
|
+
root = make_project(tmp_path)
|
|
878
|
+
_write(
|
|
879
|
+
root / "middleware.tw",
|
|
880
|
+
"""
|
|
881
|
+
use {
|
|
882
|
+
match "/api/**"
|
|
883
|
+
rate_limit {
|
|
884
|
+
requests 1
|
|
885
|
+
window 60
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
""".lstrip(),
|
|
889
|
+
)
|
|
890
|
+
framework.configure_compiler_paths(str(root))
|
|
891
|
+
project = framework.TWProject(str(root))
|
|
892
|
+
first = project.apply_middleware("/api/users", {"Cookie": ""}, request_meta={"client_ip": "127.0.0.1"})
|
|
893
|
+
second = project.apply_middleware("/api/users", {"Cookie": ""}, request_meta={"client_ip": "127.0.0.1"})
|
|
894
|
+
assert first["response"] is None
|
|
895
|
+
assert second["response"]["status"] == 429
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def test_render_cookie_header_honors_secure_mode_auto():
|
|
899
|
+
secure = framework.render_cookie_header(
|
|
900
|
+
"session",
|
|
901
|
+
"abc",
|
|
902
|
+
config={"cookies": {"secure": "auto"}},
|
|
903
|
+
request_headers={"X-Forwarded-Proto": "https"},
|
|
904
|
+
server_port=443,
|
|
905
|
+
)
|
|
906
|
+
insecure = framework.render_cookie_header(
|
|
907
|
+
"session",
|
|
908
|
+
"abc",
|
|
909
|
+
config={"cookies": {"secure": "auto"}},
|
|
910
|
+
request_headers={"X-Forwarded-Proto": "http"},
|
|
911
|
+
server_port=80,
|
|
912
|
+
)
|
|
913
|
+
assert "Secure" in secure
|
|
914
|
+
assert "Secure" not in insecure
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def test_is_path_within_blocks_prefix_confusion(tmp_path: Path):
|
|
918
|
+
root = tmp_path / "root"
|
|
919
|
+
root.mkdir()
|
|
920
|
+
sibling = tmp_path / "root-evil" / "file.txt"
|
|
921
|
+
sibling.parent.mkdir()
|
|
922
|
+
sibling.write_text("x", encoding="utf-8")
|
|
923
|
+
assert framework.is_path_within(str(root), str(root / "ok.txt"))
|
|
924
|
+
assert not framework.is_path_within(str(root), str(sibling))
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def test_ssr_cache_respects_namespace_limit():
|
|
928
|
+
cache = server.SSRCache(max_entries=10)
|
|
929
|
+
cache.set("a", b"1", None, namespace="/dashboard", namespace_max=2)
|
|
930
|
+
cache.set("b", b"2", None, namespace="/dashboard", namespace_max=2)
|
|
931
|
+
cache.set("c", b"3", None, namespace="/dashboard", namespace_max=2)
|
|
932
|
+
assert cache.get("a") is None
|
|
933
|
+
assert cache.get("b") == b"2"
|
|
934
|
+
assert cache.get("c") == b"3"
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def test_twm_modules_compile_and_do_not_auto_execute(tmp_path: Path):
|
|
938
|
+
root = make_project(tmp_path)
|
|
939
|
+
home = root / "[home]"
|
|
940
|
+
|
|
941
|
+
# External `.twm` module loaded via the universal `load` keyword.
|
|
942
|
+
_write(
|
|
943
|
+
home / "lib" / "auth.twm",
|
|
944
|
+
"""
|
|
945
|
+
fn login() {
|
|
946
|
+
// only runs when called explicitly from an event
|
|
947
|
+
console.log("login");
|
|
948
|
+
}
|
|
949
|
+
""".lstrip(),
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
# Local SCRIPT { ... } block should behave like a `.twm` module (registered, not executed).
|
|
953
|
+
_write(
|
|
954
|
+
home / "pages" / "mod.tw",
|
|
955
|
+
"""
|
|
956
|
+
load @./lib/auth.twm
|
|
957
|
+
|
|
958
|
+
SCRIPT {
|
|
959
|
+
fn hello() {
|
|
960
|
+
console.log("hello");
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
page { title "M" layout "main" render static }
|
|
965
|
+
BODY {
|
|
966
|
+
button {
|
|
967
|
+
onclick login
|
|
968
|
+
text "Login"
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
""".lstrip(),
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
|
|
975
|
+
assert summary.errors == 0
|
|
976
|
+
|
|
977
|
+
html_path = root / "dist" / "mod" / "index.html"
|
|
978
|
+
assert html_path.exists()
|
|
979
|
+
html = html_path.read_text("utf-8")
|
|
980
|
+
assert "onclick" in html
|
|
981
|
+
assert "__twInvoke" in html or "window.__twInvoke" in html
|
|
982
|
+
|
|
983
|
+
chunks_dir = root / "dist" / "_tw" / "static" / "chunks"
|
|
984
|
+
js_files = list(chunks_dir.glob("*.js"))
|
|
985
|
+
assert js_files, "Expected TW to emit at least one JS chunk for `.twm` bundle"
|
|
986
|
+
joined = "\n".join(p.read_text("utf-8") for p in js_files)
|
|
987
|
+
assert "__twRegistry" in joined
|
|
988
|
+
assert "function login" in joined
|
|
989
|
+
assert "function hello" in joined
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def test_on_load_init_injects_explicit_invoke(tmp_path: Path):
|
|
993
|
+
root = make_project(tmp_path)
|
|
994
|
+
home = root / "[home]"
|
|
995
|
+
|
|
996
|
+
_write(
|
|
997
|
+
home / "lib" / "init.twm",
|
|
998
|
+
"""
|
|
999
|
+
fn init() {
|
|
1000
|
+
console.log("init ran");
|
|
1001
|
+
}
|
|
1002
|
+
""".lstrip(),
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
_write(
|
|
1006
|
+
home / "pages" / "init.tw",
|
|
1007
|
+
"""
|
|
1008
|
+
load @./lib/init.twm
|
|
1009
|
+
|
|
1010
|
+
on load init init
|
|
1011
|
+
|
|
1012
|
+
page { title "Init" layout "main" render static }
|
|
1013
|
+
BODY {
|
|
1014
|
+
div { text "Hello" }
|
|
1015
|
+
}
|
|
1016
|
+
""".lstrip(),
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
|
|
1020
|
+
assert summary.errors == 0
|
|
1021
|
+
|
|
1022
|
+
html_path = root / "dist" / "init" / "index.html"
|
|
1023
|
+
assert html_path.exists()
|
|
1024
|
+
html = html_path.read_text("utf-8")
|
|
1025
|
+
assert "__twInvoke" in html
|
|
1026
|
+
assert "DOMContentLoaded" in html
|
|
1027
|
+
assert '"init"' in html or "'init'" in html
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def test_declarative_script_tag_strategies(tmp_path: Path):
|
|
1031
|
+
root = make_project(tmp_path)
|
|
1032
|
+
home = root / "[home]"
|
|
1033
|
+
|
|
1034
|
+
_write(
|
|
1035
|
+
home / "pages" / "scripts.tw",
|
|
1036
|
+
"""
|
|
1037
|
+
page { title "Scripts" layout "main" render static }
|
|
1038
|
+
BODY {
|
|
1039
|
+
script { src "https://example.com/a.js" strategy beforeInteractive }
|
|
1040
|
+
script { src "https://example.com/b.js" strategy afterInteractive }
|
|
1041
|
+
script { src "https://example.com/c.js" strategy lazyOnload }
|
|
1042
|
+
div { text "ok" }
|
|
1043
|
+
}
|
|
1044
|
+
""".lstrip(),
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
|
|
1048
|
+
assert summary.errors == 0
|
|
1049
|
+
|
|
1050
|
+
html_path = root / "dist" / "scripts" / "index.html"
|
|
1051
|
+
html = html_path.read_text("utf-8")
|
|
1052
|
+
# beforeInteractive should land in <head>
|
|
1053
|
+
assert "<head>" in html
|
|
1054
|
+
assert "https://example.com/a.js" in html.split("</head>", 1)[0]
|
|
1055
|
+
# afterInteractive & lazyOnload should be deferred via loader script
|
|
1056
|
+
assert "https://example.com/b.js" in html
|
|
1057
|
+
assert "https://example.com/c.js" in html
|
|
1058
|
+
assert "DOMContentLoaded" in html
|
|
1059
|
+
assert "addEventListener('load'" in html or "addEventListener(\"load\"" in html
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def test_raw_script_blocks_disabled_by_default(tmp_path: Path):
|
|
1063
|
+
root = make_project(tmp_path)
|
|
1064
|
+
home = root / "[home]"
|
|
1065
|
+
|
|
1066
|
+
_write(
|
|
1067
|
+
home / "pages" / "raw.tw",
|
|
1068
|
+
"""
|
|
1069
|
+
page { title "Raw" layout "main" render static }
|
|
1070
|
+
BODY {
|
|
1071
|
+
script { console.log("should not run") }
|
|
1072
|
+
div { text "x" }
|
|
1073
|
+
}
|
|
1074
|
+
""".lstrip(),
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
summary = framework.build_hidden_site(str(root), str(root / "dist"), workers=1, minify=False)
|
|
1078
|
+
assert summary.errors >= 1
|