//! Extension points (ยง5.5) end-to-end via `pic apply` + `invoke`: //! * a GROUP declares a module name `theme` an extension point (with a //! default body) and a group endpoint `render` that imports it, //! * an app under the group provides its OWN `theme`; invoking the inherited //! `render` in that app's context resolves the APP's `theme` (the //! inversion) โ€” NOT sealed to the group, //! * an app that provides no `theme` falls back to the group's default body, //! * `pull` round-trips the declaration (a re-applied pulled manifest is a //! no-op, so `--prune` never silently drops it). use std::fs; use tempfile::TempDir; use crate::common; use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard}; fn manifest_dir() -> TempDir { let dir = TempDir::new().expect("tempdir"); fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir"); dir } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn extension_point_resolves_per_app_with_default_fallback() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let group = common::unique_slug("ep-grp"); let app_a = common::unique_slug("ep-a"); let app_b = common::unique_slug("ep-b"); // GroupGuard first so it drops LAST (after apps + group scripts). let _g = GroupGuard::new(&env.url, &env.token, &group); common::pic_as(&env) .args(["groups", "create", &group]) .assert() .success(); let dir = manifest_dir(); // --- Group manifest: a default `theme` body, a `render` endpoint that // imports `theme`, and the `[[extension_points]]` declaration. --- fs::write( dir.path().join("scripts/defaulttheme.rhai"), r#"fn name() { "default" }"#, ) .unwrap(); fs::write( dir.path().join("scripts/render.rhai"), r#"import "theme" as t; t::name()"#, ) .unwrap(); let group_manifest = format!( "[group]\nslug = \"{group}\"\nname = \"EP Group\"\n\n\ [[scripts]]\nname = \"defaulttheme\"\nfile = \"scripts/defaulttheme.rhai\"\nkind = \"module\"\n\n\ [[scripts]]\nname = \"render\"\nfile = \"scripts/render.rhai\"\n\n\ [[extension_points]]\nname = \"theme\"\ndefault = \"defaulttheme\"\n" ); let group_path = dir.path().join("group.toml"); fs::write(&group_path, &group_manifest).unwrap(); let out = common::pic_as(&env) .args(["apply", "--file"]) .arg(&group_path) .output() .expect("group apply"); assert!( out.status.success(), "group apply failed: {}", String::from_utf8_lossy(&out.stderr) ); // Group scripts block group deletion (RESTRICT) โ€” guard them. let _gr = ScriptGuard::new( &env.url, &env.token, &group_script_id(&env, &group, "render"), ); let _gd = ScriptGuard::new( &env.url, &env.token, &group_script_id(&env, &group, "defaulttheme"), ); // --- App A under the group: provides its OWN `theme` + a caller that // invokes the inherited `render`. --- let _ga = AppGuard::new(&env.url, &env.token, &app_a); common::pic_as(&env) .args(["apps", "create", &app_a, "--group", &group]) .assert() .success(); fs::write( dir.path().join("scripts/theme-a.rhai"), r#"fn name() { "app-a" }"#, ) .unwrap(); fs::write( dir.path().join("scripts/caller.rhai"), r#"invoke("render", #{})"#, ) .unwrap(); let app_a_manifest = format!( "[app]\nslug = \"{app_a}\"\nname = \"EP A\"\n\n\ [[scripts]]\nname = \"theme\"\nfile = \"scripts/theme-a.rhai\"\nkind = \"module\"\n\n\ [[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n" ); let app_a_path = dir.path().join("a.toml"); fs::write(&app_a_path, &app_a_manifest).unwrap(); common::pic_as(&env) .args(["apply", "--file"]) .arg(&app_a_path) .assert() .success(); // The inherited `render` imports the ext point `theme`; in App A's context // it resolves App A's OWN `theme`. let caller_a = app_script_id(&env, &app_a, "caller"); assert_eq!( invoke_body(&env, &caller_a), serde_json::json!("app-a"), "extension point must resolve the inheriting app's module" ); // --- App B under the group: provides NO `theme` โ†’ render falls back to the // group's default body. --- let _gb = AppGuard::new(&env.url, &env.token, &app_b); common::pic_as(&env) .args(["apps", "create", &app_b, "--group", &group]) .assert() .success(); let app_b_manifest = format!( "[app]\nslug = \"{app_b}\"\nname = \"EP B\"\n\n\ [[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n" ); let app_b_path = dir.path().join("b.toml"); fs::write(&app_b_path, &app_b_manifest).unwrap(); common::pic_as(&env) .args(["apply", "--file"]) .arg(&app_b_path) .assert() .success(); let caller_b = app_script_id(&env, &app_b, "caller"); assert_eq!( invoke_body(&env, &caller_b), serde_json::json!("default"), "an app providing no module must fall back to the ext point's default" ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn extension_point_round_trips_through_pull() { let Some(fx) = common::fixture_or_skip() else { return; }; let env = common::admin_env(fx); let app = common::unique_slug("ep-pull"); let _a = AppGuard::new(&env.url, &env.token, &app); common::pic_as(&env) .args(["apps", "create", &app]) .assert() .success(); // Apply an app manifest that declares an extension point with a default. let dir = manifest_dir(); fs::write( dir.path().join("scripts/theme.rhai"), r#"fn name() { "x" }"#, ) .unwrap(); let manifest = format!( "[app]\nslug = \"{app}\"\nname = \"EP Pull\"\n\n\ [[scripts]]\nname = \"theme\"\nfile = \"scripts/theme.rhai\"\nkind = \"module\"\n\n\ [[extension_points]]\nname = \"theme_slot\"\ndefault = \"theme\"\n" ); let manifest_path = dir.path().join("picloud.toml"); fs::write(&manifest_path, &manifest).unwrap(); common::pic_as(&env) .args(["apply", "--file"]) .arg(&manifest_path) .assert() .success(); // Pull into a fresh dir, then re-plan: the extension point must round-trip // (no Create/Delete), so `--prune` would never drop it. let pulled = manifest_dir(); common::pic_as(&env) .args(["pull", &app, "--dir"]) .arg(pulled.path()) .assert() .success(); let toml = fs::read_to_string(pulled.path().join("picloud.toml")).unwrap(); assert!( toml.contains("theme_slot"), "pulled manifest must export the extension point:\n{toml}" ); let plan = common::pic_as(&env) .args(["plan", "--file"]) .arg(pulled.path().join("picloud.toml")) .output() .expect("plan"); let stdout = String::from_utf8_lossy(&plan.stdout); assert!( !stdout.to_lowercase().contains("create") && !stdout.to_lowercase().contains("delete"), "re-planning a pulled manifest must be a no-op:\n{stdout}" ); } /// Id of the named script in a group (`pic scripts ls --group `). fn group_script_id(env: &common::TestEnv, group: &str, name: &str) -> String { named_id(env, &["scripts", "ls", "--group", group], name) } /// Id of the named script in an app (`pic scripts ls --app `). fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String { named_id(env, &["scripts", "ls", "--app", app], name) } fn named_id(env: &common::TestEnv, args: &[&str], name: &str) -> String { let ls = common::pic_as(env).args(args).output().expect("scripts ls"); let table = String::from_utf8(ls.stdout).unwrap(); table .lines() .map(common::cells) .find(|c| c.get(2) == Some(&name)) .and_then(|c| c.first().map(|s| (*s).to_string())) .unwrap_or_else(|| panic!("script `{name}` not found:\n{table}")) } fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value { let out = common::pic_as(env) .args(["scripts", "invoke", id]) .output() .expect("scripts invoke"); assert!( out.status.success(), "invoke failed: {}", String::from_utf8_lossy(&out.stderr) ); serde_json::from_slice(&out.stdout).expect("invoke body is JSON") }