standard-plugin-cli 0.1.0

standard-plugin: scaffold, build, check and pack Standard Code plugins
Documentation
//! `check` against real components built in-process from the WIT package
//! (a dummy core module per world, componentized as `wasm32-wasip2` output
//! is), and `pack` on a bundle directory.

use standard_plugin_cli::check::{Report, check};
use standard_plugin_cli::{pack, world};
use wit_parser::{LiftLowerAbi, ManglingAndAbi, Resolve};

/// A component for `world` of the plugin package plus `extra` WIT.
fn component(extra: &str, world: &str) -> Vec<u8> {
    let mut resolve = Resolve::default();
    let package = resolve
        .push_source("plugin.wit", &format!("{}\n{extra}", world::WIT))
        .unwrap();
    let world = resolve.select_world(&[package], Some(world)).unwrap();
    let mut module =
        wit_component::dummy_module(&resolve, world, ManglingAndAbi::Legacy(LiftLowerAbi::Sync));
    wit_component::embed_component_metadata(
        &mut module,
        &resolve,
        world,
        wit_component::StringEncoding::UTF8,
    )
    .unwrap();
    wit_component::ComponentEncoder::default()
        .validate(true)
        .module(&module)
        .unwrap()
        .encode()
        .unwrap()
}

fn ui_manifest(grants: &str) -> String {
    format!(
        r#"{{"apiVersion":2,"kind":"ui","id":"card","version":"0.1.0","module":"card.wasm",
            "surfaces":[{{"id":"card","anchor":"sidebar.card","model":"cells","height":2}}],
            "grants":{{{grants}}}}}"#
    )
}

fn has(messages: &[String], needle: &str) -> bool {
    messages.iter().any(|message| message.contains(needle))
}

#[test]
fn every_grant_needs_a_reason_of_at_most_140_characters() {
    let long = "x".repeat(141);
    let report = check(
        ui_manifest(&format!(
            r#""account.read":"  ","secret:TOKEN":"{long}","values.read:git.*":"Shows the branch""#
        ))
        .as_bytes(),
        None,
    );
    assert!(!report.is_ok());
    // Both bad reasons are reported, not only the first.
    assert!(
        has(&report.errors, "`account.read` has no reason"),
        "{report}"
    );
    assert!(
        has(
            &report.errors,
            "`secret:TOKEN`: the reason is 141 characters"
        ),
        "{report}"
    );
    assert_eq!(report.errors.len(), 2, "{report}");
    let missing = check(ui_manifest(r#""account.read":null"#).as_bytes(), None);
    assert!(has(&missing.errors, "has no reason"), "{missing}");
}

#[test]
fn unknown_malformed_and_out_of_world_grants_are_rejected() {
    let report = check(
        ui_manifest(
            r#""account.write":"Changes things","values.read":"No prefix","process.exec:git":"Runs git""#,
        )
        .as_bytes(),
        None,
    );
    assert!(
        has(&report.errors, "unknown grant \"account.write\""),
        "{report}"
    );
    assert!(has(&report.errors, "needs a key prefix"), "{report}");
    assert!(
        has(&report.errors, "not available to ui plugins"),
        "{report}"
    );
}

#[test]
fn a_component_importing_beyond_its_world_is_rejected() {
    let sneaky = component(
        "interface sneaky { steal: func() -> string; }\n\
         world ui-sneaky { include ui-plugin; import sneaky; }",
        "ui-sneaky",
    );
    let grants =
        r#""account.read":"Shows panes","secret:T":"Signs in","call:card":"Asks the companion""#;
    let report = check(ui_manifest(grants).as_bytes(), Some(&sneaky));
    assert!(
        has(
            &report.errors,
            "imports `standard:plugin/sneaky@2.0.0`, which the ui-plugin world does not offer"
        ),
        "{report}"
    );
    assert_eq!(report.errors.len(), 1, "{report}");

    // The same world without the extra import passes.
    let clean = check(
        ui_manifest(grants).as_bytes(),
        Some(&component("", "ui-plugin")),
    );
    assert!(clean.is_ok(), "{clean}");

    // A ui component in the daemon world lacks the ui exports and imports
    // daemon interfaces.
    let daemon = component("", "daemon-plugin");
    let report = check(ui_manifest("").as_bytes(), Some(&daemon));
    assert!(has(&report.errors, "does not export `frame`"), "{report}");
    assert!(
        has(&report.errors, "daemon-process@2.0.0`, which the ui-plugin"),
        "{report}"
    );

    // Not a component at all.
    let core = wat::parse_str("(module)").unwrap();
    let report = check(ui_manifest("").as_bytes(), Some(&core));
    assert!(has(&report.errors, "not a component"), "{report}");
}

#[test]
fn interfaces_no_grant_covers_and_grants_nothing_uses_are_flagged() {
    // The dummy component calls every ui interface; the manifest grants
    // nothing, so account, secrets and calls would all be denied.
    let everything = component("", "ui-plugin");
    let report = check(ui_manifest("").as_bytes(), Some(&everything));
    assert!(report.is_ok(), "{report}");
    for interface in ["account", "secrets", "calls", "url"] {
        assert!(
            has(
                &report.warnings,
                &format!("uses the `{interface}` interface but no grant")
            ),
            "{interface}: {report}"
        );
    }
    // A daemon holding account.read whose component never imports account.
    let manifest = r#"{"apiVersion":2,"kind":"daemon","id":"d","version":"1","module":"d.wasm",
        "grants":{"panes.write":"Opens a pane for you"}}"#;
    let minimal = component(
        "world bare {
           use event-types.{event};
           export activate: func(config: string);
           export event: func(e: event);
           export daemon-events;
           export companion;
           export drive: func(now-ms: u64) -> option<u64>;
           export deactivate: func();
         }",
        "bare",
    );
    let report: Report = check(manifest.as_bytes(), Some(&minimal));
    assert!(report.is_ok(), "{report}");
    assert!(
        has(&report.warnings, "grant `panes.write` is never used"),
        "{report}"
    );
}

#[test]
fn machine_full_covers_the_process_and_watch_interfaces() {
    let daemon = |grants: &str| {
        format!(
            r#"{{"apiVersion":2,"kind":"daemon","id":"d","version":"1","module":"d.wasm",
                "grants":{{{grants}}}}}"#
        )
    };
    let everything = component("", "daemon-plugin");
    let narrow = check(daemon("").as_bytes(), Some(&everything));
    for interface in ["daemon-process", "daemon-watch"] {
        assert!(
            has(
                &narrow.warnings,
                &format!("uses the `{interface}` interface but no grant")
            ),
            "{interface}: {narrow}"
        );
    }
    let full = check(
        daemon(r#""machine.full":"Runs your build tools""#).as_bytes(),
        Some(&everything),
    );
    assert!(full.is_ok(), "{full}");
    for interface in ["daemon-process", "daemon-watch"] {
        assert!(
            !has(
                &full.warnings,
                &format!("uses the `{interface}` interface but no grant")
            ),
            "{interface}: {full}"
        );
    }
}

#[test]
fn the_double_play_pattern_is_flagged() {
    let report = check(
        ui_manifest(
            r#""events.on:global.*":"Follows builds","values.write:builds.*":"Records the result""#,
        )
        .as_bytes(),
        None,
    );
    assert!(report.is_ok(), "{report}");
    assert!(has(&report.warnings, "double play"), "{report}");
    // Listening alone, or acting alone, is fine.
    for grants in [
        r#""events.on:global.*":"Follows builds""#,
        r#""values.write:builds.*":"Records the result""#,
    ] {
        let report = check(ui_manifest(grants).as_bytes(), None);
        assert!(!has(&report.warnings, "double play"), "{report}");
    }
}

#[test]
fn packing_a_bundle_twice_gives_the_same_account_package() {
    let temp = tempfile::tempdir().unwrap();
    let bundle = temp.path();
    std::fs::write(bundle.join("standard-plugin.json"), ui_manifest("")).unwrap();
    std::fs::write(bundle.join("card.wasm"), component("", "ui-plugin")).unwrap();
    std::fs::create_dir_all(bundle.join("browser/interfaces")).unwrap();
    std::fs::write(bundle.join("browser/card.js"), "export {}").unwrap();
    std::fs::write(bundle.join("browser/interfaces/a.d.ts"), "").unwrap();
    let first = pack::package(bundle).unwrap();
    // Touching the files changes their timestamps, not the package.
    std::thread::sleep(std::time::Duration::from_millis(20));
    std::fs::write(bundle.join("browser/card.js"), "export {}").unwrap();
    let second = pack::package(bundle).unwrap();
    assert_eq!(
        pack::content_hash(&first.bytes),
        pack::content_hash(&second.bytes)
    );
    assert_eq!(pack::content_hash(&first.bytes).len(), 64);
    assert_eq!(first.manifest.ui.unwrap().entry, "ui/card.wasm");
    assert_eq!(
        pack::entry_names(&first.bytes).unwrap(),
        [
            "manifest.json",
            "ui/standard-plugin.json",
            "ui/card.wasm",
            "ui/browser/card.js",
            "ui/browser/interfaces/a.d.ts"
        ]
    );
}

#[test]
fn a_config_schema_is_checked_against_the_subset_hosts_render() {
    let manifest = |config: &str| {
        format!(
            r#"{{"apiVersion":2,"kind":"ui","id":"card","version":"0.1.0","module":"card.wasm",
                "surfaces":[{{"id":"card","anchor":"sidebar.card","model":"cells","height":2}}],
                "config":{config}}}"#
        )
    };
    let good = check(
        manifest(
            r#"{"type":"object","properties":{"refreshSeconds":{"type":"integer",
                "minimum":300,"maximum":1800,"default":300,"title":"Refresh every"}}}"#,
        )
        .as_bytes(),
        None,
    );
    assert!(good.is_ok(), "{good}");
    assert!(good.manifest.unwrap().config.is_some());
    // A typo in a keyword, and a default the schema itself refuses.
    let typo = check(
        manifest(r#"{"type":"object","properties":{"refresh":{"type":"integer","defualt":3}}}"#)
            .as_bytes(),
        None,
    );
    assert!(has(&typo.errors, "unknown keyword \"defualt\""), "{typo}");
    let out_of_range = check(
        manifest(
            r#"{"type":"object","properties":{"refresh":{"type":"integer","minimum":300,"default":5}}}"#,
        )
        .as_bytes(),
        None,
    );
    assert!(
        has(
            &out_of_range.errors,
            "config.refresh.default is below the minimum"
        ),
        "{out_of_range}"
    );
}