025d9c1563
req: public_api/001 req: v1_release/007
389 lines
12 KiB
Rust
389 lines
12 KiB
Rust
use std::path::Path;
|
|
|
|
#[test]
|
|
fn canonical_examples_do_not_author_browser_javascript() {
|
|
// req: examples/005
|
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
|
let examples = root.join("examples");
|
|
let mut failures = Vec::new();
|
|
scan_examples(&examples, &mut |path, text| {
|
|
if path.components().any(|part| part.as_os_str() == "tests") {
|
|
return;
|
|
}
|
|
for (line_no, line) in text.lines().enumerate() {
|
|
let trimmed = line.trim();
|
|
if trimmed.contains("<script") && !allowed_example_script(path, trimmed) {
|
|
failures.push(format!(
|
|
"{}:{}: only the hemx runtime or explicit opaque-island scripts are allowed",
|
|
path.display(),
|
|
line_no + 1
|
|
));
|
|
}
|
|
if contains_inline_event_handler(trimmed) {
|
|
failures.push(format!(
|
|
"{}:{}: inline on*= handler is not allowed",
|
|
path.display(),
|
|
line_no + 1
|
|
));
|
|
}
|
|
if trimmed.to_ascii_lowercase().contains("javascript:") {
|
|
failures.push(format!(
|
|
"{}:{}: javascript: URL is not allowed",
|
|
path.display(),
|
|
line_no + 1
|
|
));
|
|
}
|
|
}
|
|
});
|
|
|
|
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn canonical_examples_do_not_author_low_level_resource_plumbing() {
|
|
// req: dx/002 req: ceremony/002 req: ceremony/004 req: examples/003
|
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
|
let examples = root.join("examples");
|
|
let forbidden = [
|
|
"ResourceId::new",
|
|
".id().id",
|
|
"include!(concat!",
|
|
"OUT_DIR",
|
|
"global_exports(",
|
|
"EffectWriter",
|
|
"RuntimeOpcode",
|
|
"data-hid",
|
|
"data-sid",
|
|
"data-fid",
|
|
"data-aid",
|
|
"data-handle-id",
|
|
"data-slot-id",
|
|
"data-form-id",
|
|
"data-atom-id",
|
|
"::lower(include_str!",
|
|
"lower_html(",
|
|
"render_html(",
|
|
"HandlerRegistry",
|
|
".into_registry()",
|
|
"InteractionForm",
|
|
"register_handle(",
|
|
"SafeHtml",
|
|
"hemx::advanced",
|
|
"advanced::slots",
|
|
"KeyedSlot",
|
|
"Slot<",
|
|
"Effect::",
|
|
"Payload::",
|
|
"NavigateMode",
|
|
"ScopeKey",
|
|
"opcode",
|
|
"wire format",
|
|
"manual generated",
|
|
"RenderSlotExt",
|
|
"RenderKeyedSlotExt",
|
|
".render(&",
|
|
".html(ui::render",
|
|
".html(hemx::render",
|
|
".html(super::ui::render",
|
|
"ui::put(",
|
|
"ui::append(",
|
|
"ui::replace(",
|
|
"application/hemx",
|
|
"EffectBatch",
|
|
"__h=",
|
|
"name=\"__h\"",
|
|
"name='__h'",
|
|
];
|
|
let mut failures = Vec::new();
|
|
scan_examples(&examples, &mut |path, text| {
|
|
if path.components().any(|part| part.as_os_str() == "tests") {
|
|
return;
|
|
}
|
|
let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
|
|
return;
|
|
};
|
|
if !matches!(ext, "rs" | "heml" | "html") {
|
|
return;
|
|
}
|
|
let runtime_source = if ext == "rs" {
|
|
text.split("#[cfg(test)]").next().unwrap_or(text)
|
|
} else {
|
|
text
|
|
};
|
|
for (line_no, line) in runtime_source.lines().enumerate() {
|
|
if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) {
|
|
failures.push(format!(
|
|
"{}:{}: example runtime code must use generated resources, not `{token}`",
|
|
path.display(),
|
|
line_no + 1
|
|
));
|
|
}
|
|
}
|
|
});
|
|
|
|
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn public_prelude_does_not_export_low_level_primitives() {
|
|
// req: public_api/005 req: dx/006
|
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
|
let facade = std::fs::read_to_string(root.join("hemx/src/lib.rs")).unwrap();
|
|
let prelude = facade
|
|
.split("pub mod prelude {")
|
|
.nth(1)
|
|
.and_then(|tail| tail.split("\n}").next())
|
|
.expect("hemx facade exposes prelude module");
|
|
let exported = prelude
|
|
.split(|c: char| !(c == '_' || c.is_ascii_alphanumeric()))
|
|
.filter(|part| !part.is_empty())
|
|
.collect::<Vec<_>>();
|
|
for token in [
|
|
"SafeHtml",
|
|
"Effect",
|
|
"Slot",
|
|
"KeyedSlot",
|
|
"ResourceId",
|
|
"EffectBatch",
|
|
"BuildFingerprint",
|
|
"Atom",
|
|
"ComponentRef",
|
|
"EventName",
|
|
"GeneratedTarget",
|
|
"Handle",
|
|
"ParamName",
|
|
"page",
|
|
] {
|
|
assert!(
|
|
!exported.contains(&token),
|
|
"hemx::prelude must not export low-level `{token}`"
|
|
);
|
|
}
|
|
assert!(prelude.contains("Html"), "hemx::prelude should export Html");
|
|
assert!(
|
|
prelude.contains("IntoEffect"),
|
|
"hemx::prelude should export IntoEffect"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn canonical_example_docs_do_not_teach_low_level_plumbing() {
|
|
// req: dx/002 req: dx/003 req: examples/003
|
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
|
let examples = root.join("examples");
|
|
let forbidden = [
|
|
"generated handles and slots",
|
|
"generated hemx handles",
|
|
"generated handle ids",
|
|
"numeric handle ids",
|
|
"runtime lowering",
|
|
"application/hemx responses",
|
|
"EffectBatch",
|
|
"manual registry",
|
|
"HandlerRegistry",
|
|
".into_registry()",
|
|
"InteractionForm",
|
|
"register_handle(",
|
|
"lower_html(",
|
|
"render_html(",
|
|
"SafeHtml",
|
|
"KeyedSlot",
|
|
"Slot<",
|
|
"Effect::",
|
|
"opcode",
|
|
"wire format",
|
|
"manual generated",
|
|
"Effect::batch",
|
|
"Effect::class",
|
|
"Effect::move",
|
|
"Effect::set",
|
|
"Effect::broadcast",
|
|
"Effect::ack",
|
|
"SyncEffect::",
|
|
"postcard DOM ops",
|
|
"data-hid",
|
|
"data-sid",
|
|
".render(&",
|
|
"addEventListener(",
|
|
"querySelector",
|
|
"querySelectorAll",
|
|
];
|
|
let mut failures = Vec::new();
|
|
scan_examples(&examples, &mut |path, text| {
|
|
if path.extension().and_then(|ext| ext.to_str()) != Some("md")
|
|
|| is_advanced_boundary_doc(text)
|
|
{
|
|
return;
|
|
}
|
|
for (line_no, line) in text.lines().enumerate() {
|
|
if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) {
|
|
failures.push(format!(
|
|
"{}:{}: example docs must teach generated ergonomic APIs, not `{token}`",
|
|
path.display(),
|
|
line_no + 1
|
|
));
|
|
}
|
|
}
|
|
});
|
|
|
|
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn example_docs_do_not_show_runtime_metadata_as_authoring_contract() {
|
|
// req: dx/006 req: misc/006
|
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
|
let examples = root.join("examples");
|
|
let forbidden = [
|
|
"data-hid",
|
|
"data-sid",
|
|
"data-aid",
|
|
"data-fid",
|
|
"[data-hid",
|
|
"[data-sid",
|
|
];
|
|
let mut failures = Vec::new();
|
|
scan_examples(&examples, &mut |path, text| {
|
|
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
|
return;
|
|
}
|
|
for (line_no, line) in text.lines().enumerate() {
|
|
if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) {
|
|
failures.push(format!(
|
|
"{}:{}: example docs must describe generated authoring APIs, not runtime metadata `{token}`",
|
|
path.display(),
|
|
line_no + 1
|
|
));
|
|
}
|
|
}
|
|
});
|
|
|
|
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn browser_e2e_does_not_shortcut_product_interactions() {
|
|
// req: examples/005
|
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
|
let browser_e2e = root.join("examples/techdemo/tests/browser_e2e.rs");
|
|
let source = std::fs::read_to_string(browser_e2e).unwrap();
|
|
|
|
assert!(
|
|
!source.contains("fetch(\"/\""),
|
|
"browser E2E must drive UI, not post directly"
|
|
);
|
|
assert!(
|
|
!source.contains("applyBatch(buffer"),
|
|
"browser E2E must not apply wire batches manually"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn html_examples_slot_targets_dispatch_to_component_put() {
|
|
// req: runtime/005 req: examples/001 req: htmx_equivalents/001
|
|
// Regression: slots named after a component (e.g. contact_card, editable_row)
|
|
// must lower injected partials using that component's handle table, not the
|
|
// containing page's handle table.
|
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
|
let template_dir = root.join("examples/html_examples/templates");
|
|
let out_dir = std::env::temp_dir().join(format!(
|
|
"hemx-test-html-examples-slot-targets-{}",
|
|
std::process::id()
|
|
));
|
|
|
|
let _ = std::fs::remove_dir_all(&out_dir);
|
|
std::fs::create_dir_all(&out_dir).unwrap();
|
|
|
|
let result = std::panic::catch_unwind(|| {
|
|
hemx_build::app()
|
|
.template_dir(&template_dir)
|
|
.out_dir(&out_dir)
|
|
.run()
|
|
.expect("hemx-build should generate html_examples artifacts");
|
|
|
|
let generated =
|
|
std::fs::read_to_string(out_dir.join("hemx.generated.rs")).unwrap_or_default();
|
|
|
|
for component in ["contact_card", "editable_row"] {
|
|
let component_path = format!("super::super::{component}");
|
|
let impl_header = format!("impl<T> SlotTarget<T, {component_path}::Component>");
|
|
let put_call = format!("{component_path}::put(self.slot, view)");
|
|
assert!(
|
|
generated.contains(&impl_header),
|
|
"gallery::targets should have a component-specific SlotTarget impl for `{component}`; missing `{impl_header}`"
|
|
);
|
|
assert!(
|
|
generated.contains(&put_call),
|
|
"gallery::targets::{component} put/replace should dispatch to `{component_path}::put`; missing `{put_call}`"
|
|
);
|
|
}
|
|
});
|
|
|
|
let _ = std::fs::remove_dir_all(&out_dir);
|
|
result.unwrap();
|
|
}
|
|
|
|
fn scan_examples(dir: &Path, visit: &mut impl FnMut(&Path, &str)) {
|
|
for entry in std::fs::read_dir(dir).unwrap() {
|
|
let entry = entry.unwrap();
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
scan_examples(&path, visit);
|
|
continue;
|
|
}
|
|
if !is_example_source(&path) {
|
|
continue;
|
|
}
|
|
let text = std::fs::read_to_string(&path).unwrap();
|
|
visit(&path, &text);
|
|
}
|
|
}
|
|
|
|
fn is_advanced_boundary_doc(text: &str) -> bool {
|
|
text.contains("advanced/low-level north-star boundary sketch")
|
|
}
|
|
|
|
fn is_example_source(path: &Path) -> bool {
|
|
matches!(
|
|
path.extension().and_then(|ext| ext.to_str()),
|
|
Some("rs" | "heml" | "html" | "md")
|
|
)
|
|
}
|
|
|
|
fn allowed_example_script(path: &Path, line: &str) -> bool {
|
|
// req: examples/005
|
|
line.contains(r#"<script +src="self.runtime_src" defer></script>"#)
|
|
|| (path.ends_with("examples/techdemo/templates/app_shell.heml")
|
|
&& line.contains(r#"<script src="/island.js" defer></script>"#))
|
|
|| (path.ends_with("examples/saas/templates/app_shell.heml")
|
|
&& line.contains(r#"<script src="/metrics.js" defer></script>"#))
|
|
|| (path.ends_with("examples/kanban/templates/legacy_sync_fixture.heml")
|
|
&& line.contains(r#"<script type="module" src="/sync.js"></script>"#))
|
|
}
|
|
|
|
fn contains_inline_event_handler(line: &str) -> bool {
|
|
let bytes = line.as_bytes();
|
|
let mut i = 0;
|
|
while i + 3 < bytes.len() {
|
|
let boundary = bytes[i].is_ascii_whitespace() || bytes[i] == b'<';
|
|
if boundary
|
|
&& bytes[i + 1] == b'o'
|
|
&& bytes[i + 2] == b'n'
|
|
&& bytes[i + 3].is_ascii_lowercase()
|
|
{
|
|
let mut j = i + 4;
|
|
while j < bytes.len() && bytes[j].is_ascii_lowercase() {
|
|
j += 1;
|
|
}
|
|
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
|
|
j += 1;
|
|
}
|
|
if j < bytes.len() && bytes[j] == b'=' {
|
|
return true;
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
false
|
|
}
|