microsoft-webui-discovery 3.0.3

External component discovery for WebUI framework
Documentation
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

use std::fs;
use std::path::{Path, PathBuf};

use webui_discovery::{
    discover_source, discover_source_with_plugin, DiscoveredComponent, DiscoveryPlugin,
    FastDiscoveryPlugin, PackageContext, PreparedPackage, WebUIDiscoveryPlugin,
};

type TestResult = Result<(), Box<dyn std::error::Error>>;

fn package(root: &Path, name: &str, manifest: &str) -> std::io::Result<PathBuf> {
    let package = root.join("node_modules").join(name);
    fs::create_dir_all(&package)?;
    fs::write(package.join("package.json"), manifest)?;
    Ok(package)
}

#[test]
fn npm_sources_reject_traversal_before_loading_an_outside_package() -> TestResult {
    let root = tempfile::tempdir()?;
    package(root.path(), "foo", "{}")?;
    package(root.path(), "@fixture/button", "{}")?;
    let outside = root.path().join("outside");
    fs::create_dir(&outside)?;
    fs::write(outside.join("package.json"), "{}")?;
    fs::write(outside.join("outside-card.html"), "<span>Outside</span>")?;
    let plugins: [&dyn DiscoveryPlugin; 2] =
        [&WebUIDiscoveryPlugin::new(), &FastDiscoveryPlugin::new()];
    for plugin in plugins {
        for source in [
            "foo/../../outside",
            "foo/../../outside/*",
            "@fixture/../../outside",
            "@fixture/button/../../../outside",
        ] {
            let result = discover_source_with_plugin(source, root.path(), plugin);
            assert!(result.is_err(), "{source} loaded an unrelated package");
        }
        let explicit = discover_source_with_plugin("./outside", root.path(), plugin)?;
        assert_eq!(explicit.components[0].tag_name, "outside-card");
    }
    Ok(())
}

#[test]
fn npm_sources_reject_invalid_identifier_forms_with_actionable_errors() -> TestResult {
    let root = tempfile::tempdir()?;
    for source in [
        "",
        "@",
        "@scope/",
        "@scope//button",
        "@scope/./button",
        "@scope/../button",
        "@scope/button/extra",
        "@scope\\button",
        "foo/bar",
        "foo\\..\\outside",
        "foo//bar",
        "foo@1.0.0",
        "foo%2fbar",
        "foo:bar",
        "foo bar",
        "foo/*/bar",
        "foo\0bar",
    ] {
        let error = discover_source(source, root.path())
            .err()
            .ok_or("invalid npm source was accepted")?;
        let message = error.to_string();
        assert!(
            message.contains("Invalid npm component source"),
            "{source:?}: {message}"
        );
        assert!(message.contains("./"), "missing local-path guidance");
    }
    Ok(())
}

#[test]
fn npm_sources_accept_package_and_collection_identifiers() -> TestResult {
    let root = tempfile::tempdir()?;
    for name in ["my-widget", "my_widget", "my.widget", "@fixture/ui-kit"] {
        let directory = package(root.path(), name, "{}")?;
        fs::create_dir(directory.join("components"))?;
        fs::write(
            directory.join("components/test-card.html"),
            "<span>Card</span>",
        )?;
        for source in [name.to_string(), format!("{name}/*")] {
            assert_eq!(discover_source(&source, root.path())?.components.len(), 1);
        }
    }
    Ok(())
}

#[test]
fn native_scope_skips_utilities_but_reports_invalid_component_files() -> TestResult {
    let root = tempfile::tempdir()?;
    let child = root.path().join("app");
    fs::create_dir_all(child.join("node_modules/unrelated"))?;
    let component = package(root.path(), "@fixture/button", "{}")?;
    package(root.path(), "@fixture/utils", r#"{"main":"./index.js"}"#)?;
    fs::create_dir(component.join("components"))?;
    fs::write(
        component.join("components/test-button.html"),
        "<button>Example</button>",
    )?;
    let result = discover_source("@fixture", &child)?;
    assert_eq!(result.components.len(), 1);
    assert_eq!(result.components[0].tag_name, "test-button");
    let wildcard = discover_source("@fixture/*", &child)?;
    assert_eq!(wildcard.components.len(), 1);
    assert_eq!(wildcard.components[0].tag_name, "test-button");

    fs::write(component.join("components/test-button.html"), [0xff])?;
    let error = discover_source("@fixture", &child)
        .err()
        .ok_or("scope must surface bad HTML")?;
    assert!(format!("{error:#}").contains("test-button.html"));
    Ok(())
}

#[test]
fn fast_scope_skips_non_components_but_reports_declared_missing_assets() -> TestResult {
    let root = tempfile::tempdir()?;
    let child = root.path().join("app");
    fs::create_dir_all(child.join("node_modules/unrelated"))?;
    let component = package(
        root.path(),
        "@fixture/button",
        r#"{
        "customElements":"custom-elements.json",
        "exports":{"./template-webui.html":"./button.template-webui.html"}
    }"#,
    )?;
    package(root.path(), "@fixture/tokens", r#"{"main":"./index.js"}"#)?;
    fs::write(
        component.join("custom-elements.json"),
        r#"{
        "modules":[{
            "path":"button.js",
            "declarations":[{"name":"Button","tagName":"fast-button"}]
        }]
    }"#,
    )?;
    fs::write(
        component.join("button.template-webui.html"),
        "<f-template name=\"fast-button\"><template>Button</template></f-template>",
    )?;
    let plugin = FastDiscoveryPlugin::new();
    let result = discover_source_with_plugin("@fixture", &child, &plugin)?;
    assert_eq!(result.components.len(), 1);
    assert_eq!(result.components[0].tag_name, "fast-button");

    fs::remove_file(component.join("button.template-webui.html"))?;
    let error = discover_source_with_plugin("@fixture", &child, &plugin)
        .err()
        .ok_or("scope must surface missing declared templates")?;
    assert!(format!("{error:#}").contains("button.template-webui.html"));
    Ok(())
}

#[test]
fn fast_scope_aborts_for_unresolved_bare_manifest_modules() -> TestResult {
    let root = tempfile::tempdir()?;
    let aggregate = package(
        root.path(),
        "@fixture/aggregate",
        r#"{"customElements":"custom-elements.json"}"#,
    )?;
    fs::write(
        aggregate.join("custom-elements.json"),
        r#"{
        "modules":[{
            "path":"@missing/module/component.js",
            "declarations":[{"name":"Card","tagName":"fixture-card"}]
        }]
    }"#,
    )?;
    package(root.path(), "@fixture/utility", r#"{"main":"./index.js"}"#)?;

    let error = discover_source_with_plugin("@fixture", root.path(), &FastDiscoveryPlugin::new())
        .err()
        .ok_or("scope must abort for an unresolved bare module")?;
    let message = format!("{error:#}");
    assert!(message.contains("@fixture/aggregate"));
    assert!(message.contains("@missing/module"));
    Ok(())
}

struct InlinePlugin;

impl DiscoveryPlugin for InlinePlugin {
    fn cache_namespace(&self) -> &'static str {
        "fixture-inline"
    }
    fn discover_local(&self, _root: &Path) -> anyhow::Result<Vec<DiscoveredComponent>> {
        Ok(Vec::new())
    }
    fn prepare_package(&self, package: PackageContext<'_>) -> anyhow::Result<PreparedPackage> {
        Ok(PreparedPackage::Uncached(vec![DiscoveredComponent {
            tag_name: "inline-component".to_string(),
            html_content: "<span>Inline</span>".to_string(),
            css_content: None,
            is_client_owned: false,
            source: package.name.to_string(),
        }]))
    }
}

#[test]
fn custom_plugins_keep_default_scope_support_without_file_dependencies() -> TestResult {
    let root = tempfile::tempdir()?;
    package(root.path(), "@fixture/inline", "{}")?;
    let result = discover_source_with_plugin("@fixture", root.path(), &InlinePlugin)?;
    assert_eq!(result.components.len(), 1);
    assert_eq!(result.components[0].tag_name, "inline-component");
    Ok(())
}

#[test]
fn fast_uses_default_html_when_manifest_metadata_is_absent_or_empty() -> TestResult {
    let root = tempfile::tempdir()?;
    let plain = package(
        root.path(),
        "@fixture/plain",
        r#"{"main":"./unrelated.js"}"#,
    )?;
    fs::create_dir_all(plain.join("components"))?;
    fs::write(
        plain.join("components/plain-card.html"),
        "<span>Plain</span>",
    )?;
    fs::write(
        plain.join("components/plain-card.css"),
        "span { color: blue; }",
    )?;
    let plugin = FastDiscoveryPlugin::new();
    let result = discover_source_with_plugin("@fixture/*", root.path(), &plugin)?;
    assert_eq!(result.components.len(), 1);
    assert_eq!(result.components[0].tag_name, "plain-card");
    assert_eq!(result.components[0].html_content, "<span>Plain</span>");
    assert!(!result.components[0].is_client_owned);

    fs::write(
        plain.join("package.json"),
        r#"{"customElements":"custom-elements.json"}"#,
    )?;
    fs::write(plain.join("custom-elements.json"), r#"{"modules":[]}"#)?;
    fs::write(
        plain.join("components/plain-card.ts"),
        "export class PlainCard {}",
    )?;
    let result = discover_source_with_plugin("@fixture/plain/*", root.path(), &plugin)?;
    assert_eq!(result.components.len(), 1);
    assert!(result.components[0].is_client_owned);
    Ok(())
}

#[test]
fn fast_manifest_components_take_precedence_and_plain_components_fill_gaps() -> TestResult {
    let root = tempfile::tempdir()?;
    let mixed = package(
        root.path(),
        "@fixture/mixed",
        r#"{
        "webui":{"components":["./components"]},
        "customElements":"custom-elements.json",
        "exports":{"./template-webui.html":"./dist/button.template-webui.html"}
    }"#,
    )?;
    fs::create_dir_all(mixed.join("dist"))?;
    fs::create_dir_all(mixed.join("components"))?;
    fs::write(
        mixed.join("custom-elements.json"),
        r#"{
        "modules":[{
            "path":"dist/button.js",
            "declarations":[{"name":"Button","tagName":"fast-button"}]
        }]
    }"#,
    )?;
    fs::write(
        mixed.join("dist/button.template-webui.html"),
        "<f-template name=\"fast-button\"><template>FAST</template></f-template>",
    )?;
    fs::write(
        mixed.join("components/plain-card.html"),
        "<span>Fallback</span>",
    )?;
    fs::write(mixed.join("components/fast-button.html"), [0xff])?;
    fs::write(
        mixed.join("components/unused.template-webui.html"),
        "Must not register",
    )?;
    let result =
        discover_source_with_plugin("@fixture/mixed", root.path(), &FastDiscoveryPlugin::new())?;
    assert_eq!(result.components.len(), 2);
    let declared = result
        .components
        .iter()
        .find(|component| component.tag_name == "fast-button")
        .ok_or("manifest component missing")?;
    assert!(declared.html_content.contains("<f-template"));
    assert!(result
        .components
        .iter()
        .any(|component| component.tag_name == "plain-card"));
    fs::write(
        mixed.join("package.json"),
        r#"{
        "webui":{"components":[]},
        "customElements":"custom-elements.json",
        "exports":{"./template-webui.html":"./dist/button.template-webui.html"}
    }"#,
    )?;
    let result =
        discover_source_with_plugin("@fixture/mixed", root.path(), &FastDiscoveryPlugin::new())?;
    assert_eq!(result.components.len(), 1);
    assert_eq!(result.components[0].tag_name, "fast-button");
    fs::remove_file(mixed.join("dist/button.template-webui.html"))?;
    assert!(discover_source_with_plugin(
        "@fixture/mixed",
        root.path(),
        &FastDiscoveryPlugin::new()
    )
    .is_err());
    Ok(())
}