defmodule BDS.Scripting.ApiTest do use ExUnit.Case, async: false @tiny_png_1 Base.decode64!( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/a6sAAAAASUVORK5CYII=" ) @tiny_png_2 Base.decode64!( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADUlEQVR42mP8z/C/HwAF/gL+qJNmNwAAAABJRU5ErkJggg==" ) alias BDS.Repo alias BDS.Scripts.Script alias BDS.Templates.Template defmodule StubRuntime do def generate(_endpoint, request, _opts) do message_content = get_in(request, [:messages, Access.at(1), "content"]) content = if is_binary(message_content) and String.contains?(message_content, "Detect the language") do Jason.encode!(%{"language_code" => "de"}) else Jason.encode!(%{"title" => "Stub", "alt" => "Stub alt", "caption" => "Stub caption"}) end {:ok, %{ content: content, json: Jason.decode!(content), tool_calls: [], usage: %{input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_write_tokens: 0} }} end end setup do :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()}) temp_dir = Path.join(System.tmp_dir!(), "bds-scripting-api-#{System.unique_integer([:positive])}") File.mkdir_p!(temp_dir) on_exit(fn -> File.rm_rf(temp_dir) end) {:ok, project} = BDS.Projects.create_project(%{name: "Scripting API", data_path: temp_dir}) %{project: project} end test "project capabilities expose current backend data through explicit bds namespaces", %{ project: project } do assert {:ok, post} = BDS.Posts.create_post(%{ project_id: project.id, title: "Capability Post", content: "Body", language: "en", tags: ["elixir"], categories: ["article"] }) assert {:ok, _published_post} = BDS.Posts.publish_post(post.id) assert {:ok, _tags} = BDS.Tags.sync_tags_from_posts(project.id) source = [ "function main()", " local meta = bds.meta.get_project_metadata()", " local fetched = bds.posts.get_by_slug('capability-post')", " local tags = bds.tags.get_all()", " return {", " project_name = meta.name,", " post_title = fetched.title,", " tag_count = #tags", " }", "end" ] |> Enum.join("\n") assert {:ok, %{ "project_name" => "Scripting API", "post_title" => "Capability Post", "tag_count" => 1 }} = BDS.Scripting.execute_project_script(project.id, source, "main") end test "macro execution uses explicit project capabilities and degrades failures to empty output", %{ project: project } do source = [ "function render()", " local meta = bds.meta.get_project_metadata()", " return '' .. meta.name .. ''", "end" ] |> Enum.join("\n") assert {:ok, "Scripting API"} = BDS.Scripting.execute_macro(project.id, source, []) bad_source = "function render() error('boom') end" assert {:error, _reason} = BDS.Scripting.execute_macro(project.id, bad_source, []) end test "project scripting exposes project, post, script, template, metadata, and task namespaces", %{ project: project } do assert {:ok, post} = BDS.Posts.create_post(%{ project_id: project.id, title: "Lua Coverage", content: "Body", language: "en" }) assert {:ok, published_post} = BDS.Posts.publish_post(post.id) assert {:ok, _created_script} = BDS.Scripts.create_script(%{ project_id: project.id, title: "Existing Utility", kind: :utility, content: "function main() return true end" }) assert {:ok, _created_template} = BDS.Templates.create_template(%{ project_id: project.id, title: "Existing Template", kind: :post, content: "
{{ post.title }}
" }) source = [ "function main()", " local active = bds.projects.get_active()", " local projects = bds.projects.get_all()", " local created = bds.scripts.create({ title = 'Lua Utility', kind = 'utility', content = 'function main() return 42 end' })", " local templates = bds.templates.get_all()", " local created_template = bds.templates.create({ title = 'Lua Template', kind = 'partial', content = '

Lua

' })", " local updated_meta = bds.meta.update_project_metadata({ description = 'Updated from Lua' })", " local task_status = bds.tasks.status_snapshot()", " local fetched = bds.posts.get_by_slug('lua-coverage')", " return {", " active_project = active.name,", " project_count = #projects,", " created_script_title = created.title,", " existing_script_title = bds.scripts.get(created.id).title,", " existing_template_title = templates[1].title,", " created_template_kind = created_template.kind,", " meta_description = updated_meta.description,", " task_active_count = task_status.active_count,", " published_post_slug = fetched.slug,", " published_post_status = bds.posts.get(fetched.id).status", " }", "end" ] |> Enum.join("\n") assert {:ok, result} = BDS.Scripting.execute_project_script(project.id, source, "main", [], copy_to_clipboard: fn _text -> true end, title_bar_metrics: fn -> %{macos_left_inset: 64} end, notify_renderer_ready: fn -> true end, open_folder: fn _path -> "" end, show_item_in_folder: fn _path -> :ok end, trigger_menu_action: fn _action -> :ok end ) assert %{ "active_project" => "Scripting API", "created_script_title" => "Lua Utility", "existing_script_title" => "Lua Utility", "existing_template_title" => "Existing Template", "created_template_kind" => "partial", "meta_description" => "Updated from Lua", "task_active_count" => 0, "published_post_status" => "published" } = result assert result["project_count"] >= 1 assert result["published_post_slug"] == published_post.slug assert %Script{title: "Lua Utility"} = Repo.get_by(Script, project_id: project.id, title: "Lua Utility") assert %Template{title: "Lua Template"} = Repo.get_by(Template, project_id: project.id, title: "Lua Template") end test "project scripting exposes remaining old-app compatibility namespaces", %{project: project} do assert {:ok, _endpoint} = BDS.AI.put_endpoint(:airplane, %{url: "http://stub.local", model: "stub-model"}) assert :ok = BDS.AI.set_airplane_mode(true) assert {:ok, post} = BDS.Posts.create_post(%{ project_id: project.id, title: "Embedding Source", content: "Guten Tag aus Berlin", language: "de" }) source = [ "function main()", " local app = bds.app.get_system_language()", " local data_paths = bds.app.get_data_paths()", " local folder_meta = bds.app.read_project_metadata(data_paths.project)", " local sync_available = bds.sync.check_availability()", " local repo_state = bds.sync.get_repo_state()", " local publish_job = bds.publish.upload_site({ ssh_host = 'example.test', ssh_user = 'deploy', ssh_remote_path = '/srv/www', ssh_mode = 'scp' })", " local detect = bds.chat.detect_post_language('Hallo Welt', 'Guten Tag aus Berlin')", " local progress = bds.embeddings.get_progress()", " local similar = bds.embeddings.find_similar('" <> post.id <> "', 5)", " return {", " system_language = app,", " project_path = data_paths.project,", " folder_name = folder_meta.name,", " sync_available = sync_available,", " repo_initialized = repo_state.is_initialized,", " publish_job_id = publish_job.id,", " detected_language = detect.language,", " progress_total = progress.total,", " similar_count = #similar", " }", "end" ] |> Enum.join("\n") assert {:ok, result} = BDS.Scripting.execute_project_script(project.id, source, "main", [], ai_runtime: StubRuntime, publishing_uploader: fn _target, _files, _credentials -> :ok end ) assert is_binary(result["system_language"]) assert result["project_path"] == project.data_path assert result["folder_name"] == "Scripting API" assert is_boolean(result["sync_available"]) assert result["repo_initialized"] == false assert is_binary(result["publish_job_id"]) assert result["detected_language"] == "de" assert result["progress_total"] >= 1 assert result["similar_count"] == 0 end test "project scripting exposes project metadata, rebuild, and task listing helpers", %{ project: project } do assert {:ok, script} = BDS.Scripts.create_script(%{ project_id: project.id, title: "Published Utility", kind: :utility, content: "function main() return 1 end" }) assert {:ok, _published_script} = BDS.Scripts.publish_script(script.id) assert {:ok, template} = BDS.Templates.create_template(%{ project_id: project.id, title: "Published Post Template", kind: :post, content: "
{{ post.title }}
" }) assert {:ok, _published_template} = BDS.Templates.publish_template(template.id) assert {:ok, running_task} = BDS.Tasks.register_external_task("preview build", %{ group_id: "generation", group_name: "Generation" }) on_exit(fn -> _ = BDS.Tasks.complete_task(running_task.id) end) source = [ "function main()", " local updated = bds.projects.update('" <> project.id <> "', { description = 'Updated through Lua' })", " bds.meta.set_publishing_preferences({ ssh_host = 'example.test', ssh_user = 'deploy', ssh_remote_path = '/srv/www', ssh_mode = 'scp' })", " local prefs = bds.meta.get_publishing_preferences()", " local categories = bds.meta.get_categories()", " local scripts = bds.scripts.rebuild_from_files()", " local templates = bds.templates.rebuild_from_files()", " local enabled_post_templates = bds.templates.get_enabled_by_kind('post')", " local tasks = bds.tasks.get_all()", " local running = bds.tasks.get_running()", " bds.meta.clear_publishing_preferences()", " local cleared = bds.meta.get_publishing_preferences()", " return {", " project_description = updated.description,", " ssh_mode = prefs.ssh_mode,", " category_count = #categories,", " rebuilt_script_count = #scripts,", " rebuilt_template_count = #templates,", " enabled_post_template_count = #enabled_post_templates,", " task_count = #tasks,", " running_count = #running,", " cleared_ssh_mode = cleared.ssh_mode", " }", "end" ] |> Enum.join("\n") assert {:ok, result} = BDS.Scripting.execute_project_script(project.id, source, "main", [], copy_to_clipboard: fn _text -> true end, title_bar_metrics: fn -> %{macos_left_inset: 64} end, notify_renderer_ready: fn -> true end, open_folder: fn _path -> "" end, show_item_in_folder: fn _path -> :ok end, trigger_menu_action: fn _action -> :ok end ) assert result["project_description"] == "Updated through Lua" assert result["ssh_mode"] == "scp" assert result["category_count"] >= 1 assert result["rebuilt_script_count"] >= 1 assert result["rebuilt_template_count"] >= 1 assert result["enabled_post_template_count"] >= 1 assert result["task_count"] >= 1 assert result["running_count"] >= 1 assert result["cleared_ssh_mode"] == "scp" end test "project scripting exposes post, tag, and translation lookup helpers", %{project: project} do assert {:ok, post} = BDS.Posts.create_post(%{ project_id: project.id, title: "Lookup Post", excerpt: "Search me", content: "Elixir lookup body with tag coverage", language: "en", tags: ["elixir", "lua"], categories: ["article", "guide"] }) assert {:ok, _published_post} = BDS.Posts.publish_post(post.id) assert {:ok, _translation} = BDS.Posts.upsert_post_translation(post.id, "de", %{ title: "Nachschlagebeitrag", excerpt: "Suche mich", content: "Deutscher Inhalt" }) assert {:ok, _tags} = BDS.Tags.sync_tags_from_posts(project.id) source = [ "function main()", " local meta_tags = bds.meta.get_tags()", " local with_counts = bds.tags.get_with_counts()", " local tag_posts = bds.tags.get_posts_with_tag(bds.tags.get_by_name('elixir').id)", " local post_tags = bds.posts.get_tags()", " local post_tag_counts = bds.posts.get_tags_with_counts()", " local post_categories = bds.posts.get_categories()", " local post_category_counts = bds.posts.get_categories_with_counts()", " local translations = bds.posts.get_translations('" <> post.id <> "')", " local translation = bds.posts.get_translation('" <> post.id <> "', 'de')", " local published = bds.posts.has_published_version('" <> post.id <> "')", " local search = bds.posts.search('lookup')", " return {", " meta_tag_count = #meta_tags,", " tag_with_count = with_counts[1].count,", " tag_post_count = #tag_posts,", " post_tag_count = #post_tags,", " post_tag_count_rows = #post_tag_counts,", " category_count = #post_categories,", " category_count_rows = #post_category_counts,", " translation_count = #translations,", " translation_title = translation.title,", " has_published = published,", " search_count = #search", " }", "end" ] |> Enum.join("\n") assert {:ok, result} = BDS.Scripting.execute_project_script(project.id, source, "main") assert result["meta_tag_count"] >= 2 assert result["tag_with_count"] >= 1 assert result["tag_post_count"] == 1 assert result["post_tag_count"] >= 2 assert result["post_tag_count_rows"] >= 2 assert result["category_count"] >= 2 assert result["category_count_rows"] >= 2 assert result["translation_count"] == 1 assert result["translation_title"] == "Nachschlagebeitrag" assert result["has_published"] == true assert result["search_count"] >= 1 end test "project scripting exposes remaining post and media parity helpers", %{project: project} do media_source_path = write_binary_fixture(project.data_path, "image-a.png", @tiny_png_1) replacement_source_path = write_binary_fixture(project.data_path, "image-b.png", @tiny_png_2) assert {:ok, target_post} = BDS.Posts.create_post(%{ project_id: project.id, title: "Target Post", content: "Target body", language: "en", tags: ["target"], categories: ["reference"] }) assert {:ok, _published_target} = BDS.Posts.publish_post(target_post.id) assert {:ok, source_post} = BDS.Posts.create_post(%{ project_id: project.id, title: "Source Post", excerpt: "Draft excerpt", content: "See [Target](/target-post) for more.", language: "en", tags: ["source", "featured"], categories: ["guide"] }) assert {:ok, _published_source} = BDS.Posts.publish_post(source_post.id) assert {:ok, _source_translation} = BDS.Posts.upsert_post_translation(source_post.id, "de", %{ title: "Quellbeitrag", excerpt: "Deutscher Auszug", content: "Siehe [Target](/target-post) fur mehr." }) assert {:ok, _draft_source} = BDS.Posts.update_post(source_post.id, %{ title: "Source Post Draft", excerpt: "Changed excerpt", content: "Changed body with [Target](/target-post)" }) source = [ "function main()", " local function step(name, fn)", " local ok, value = pcall(fn)", " if not ok then error(name .. ': ' .. tostring(value)) end", " return value", " end", " local function count(name, value)", " local ok, length = pcall(function() return #value end)", " if not ok then error(name .. ': ' .. tostring(length)) end", " return length", " end", " local imported = step('media.import', function() return bds.media.import({ source_path = '" <> escape_lua_string(media_source_path) <> "', title = 'Imported Image', alt = 'Alt text', caption = 'Caption', tags = { 'gallery', 'cover' }, language = 'en' }) end)", " local translation = step('media.upsert_translation', function() return bds.media.upsert_translation(imported.id, 'de', { title = 'Bild', alt = 'Alt de', caption = 'Beschriftung' }) end)", " local fetched_translation = step('media.get_translation', function() return bds.media.get_translation(imported.id, 'de') end)", " local translation_count = count('media.get_translations.count', step('media.get_translations', function() return bds.media.get_translations(imported.id) end))", " local media_filter = step('media.filter', function() return bds.media.filter({ year = " <> Integer.to_string(Date.utc_today().year) <> ", tags = { 'gallery' } }) end)", " local media_search = step('media.search', function() return bds.media.search('Imported') end)", " local media_counts = step('media.get_by_year_month', function() return bds.media.get_by_year_month() end)", " local media_tags = step('media.get_tags', function() return bds.media.get_tags() end)", " local media_tag_counts = step('media.get_tags_with_counts', function() return bds.media.get_tags_with_counts() end)", " local media_url = step('media.get_url', function() return bds.media.get_url(imported.id) end)", " local media_file_path = step('media.get_file_path', function() return bds.media.get_file_path(imported.id) end)", " local thumbnail = step('media.get_thumbnail', function() return bds.media.get_thumbnail(imported.id, 'small') end)", " local regenerated = step('media.regenerate_thumbnails', function() return bds.media.regenerate_thumbnails(imported.id) end)", " local missing = step('media.regenerate_missing_thumbnails', function() return bds.media.regenerate_missing_thumbnails() end)", " local replaced = step('media.replace_file', function() return bds.media.replace_file(imported.id, '" <> escape_lua_string(replacement_source_path) <> "') end)", " local rebuilt_media = step('media.rebuild_from_files', function() return bds.media.rebuild_from_files() end)", " local media_reindexed = step('media.reindex_text', function() return bds.media.reindex_text() end)", " local deleted_translation = step('media.delete_translation', function() return bds.media.delete_translation(imported.id, 'de') end)", " local slug_available = step('posts.is_slug_available', function() return bds.posts.is_slug_available('brand-new-slug') end)", " local unique_slug = step('posts.generate_unique_slug', function() return bds.posts.generate_unique_slug('Target Post') end)", " local published = step('posts.get_by_status', function() return bds.posts.get_by_status('published') end)", " local by_month = step('posts.get_by_year_month', function() return bds.posts.get_by_year_month() end)", " local dashboard = step('posts.get_dashboard_stats', function() return bds.posts.get_dashboard_stats() end)", " local filtered = step('posts.filter', function() return bds.posts.filter({ status = 'draft', tags = { 'source' } }) end)", " local rebuilt_links_before = step('posts.get_links_to.before', function() return bds.posts.get_links_to('" <> source_post.id <> "') end)", " step('posts.rebuild_links', function() return bds.posts.rebuild_links() end)", " local links_to = step('posts.get_links_to.after', function() return bds.posts.get_links_to('" <> source_post.id <> "') end)", " local linked_by = step('posts.get_linked_by', function() return bds.posts.get_linked_by('" <> target_post.id <> "') end)", " local preview_url = step('posts.get_preview_url', function() return bds.posts.get_preview_url('" <> source_post.id <> "', { draft = true, lang = 'de' }) end)", " local published_translation = step('posts.publish_translation', function() return bds.posts.publish_translation('" <> source_post.id <> "', 'de') end)", " local discarded = step('posts.discard', function() return bds.posts.discard('" <> source_post.id <> "') end)", " return {", " translation_title = translation and translation.title or nil,", " fetched_translation_title = fetched_translation and fetched_translation.title or nil,", " translation_count = translation_count,", " media_filter_count = count('media.filter.count', media_filter),", " media_search_count = count('media.search.count', media_search),", " media_counts_count = count('media.get_by_year_month.count', media_counts),", " media_tags_count = count('media.get_tags.count', media_tags),", " media_tag_row_count = count('media.get_tags_with_counts.count', media_tag_counts),", " media_url = media_url,", " media_file_path = media_file_path,", " thumbnail_prefix = string.sub(thumbnail or '', 1, 22),", " regenerated_small = regenerated and regenerated.small or nil,", " regenerated_missing_processed = missing and missing.processed or nil,", " replaced_title = replaced and replaced.title or nil,", " rebuilt_media_count = count('media.rebuild_from_files.count', rebuilt_media),", " media_reindexed = media_reindexed,", " deleted_translation = deleted_translation,", " slug_available = slug_available,", " unique_slug = unique_slug,", " published_count = count('posts.get_by_status.count', published),", " by_month_count = count('posts.get_by_year_month.count', by_month),", " dashboard_total = dashboard and dashboard.total_posts or nil,", " filtered_count = count('posts.filter.count', filtered),", " rebuilt_links_before_count = count('posts.get_links_to.before.count', rebuilt_links_before),", " links_to_count = count('posts.get_links_to.after.count', links_to),", " linked_by_count = count('posts.get_linked_by.count', linked_by),", " preview_url = preview_url,", " published_translation_language = published_translation and published_translation.language or nil,", " discarded_title = discarded and discarded.title or nil,", " discarded_status = discarded and discarded.status or nil", " }", "end" ] |> Enum.join("\n") assert {:ok, result} = BDS.Scripting.execute_project_script(project.id, source, "main") assert result["translation_title"] == "Bild" assert result["fetched_translation_title"] == "Bild" assert result["translation_count"] == 1 assert result["media_filter_count"] >= 1 assert result["media_search_count"] >= 1 assert result["media_counts_count"] >= 1 assert result["media_tags_count"] >= 2 assert result["media_tag_row_count"] >= 2 assert String.starts_with?(result["media_url"], "/media/") assert String.ends_with?(result["media_file_path"], ".png") assert String.starts_with?(result["thumbnail_prefix"], "data:image/webp;base64") assert is_binary(result["regenerated_small"]) assert result["regenerated_missing_processed"] >= 1 assert result["replaced_title"] == "Imported Image" assert result["rebuilt_media_count"] >= 1 assert result["media_reindexed"] == true assert result["deleted_translation"] == true assert result["slug_available"] == true assert result["unique_slug"] == "target-post-2" assert result["published_count"] >= 1 assert result["by_month_count"] >= 1 assert result["dashboard_total"] >= 2 assert result["filtered_count"] >= 1 assert result["rebuilt_links_before_count"] >= 1 assert result["links_to_count"] >= 1 assert result["linked_by_count"] >= 1 assert String.contains?(result["preview_url"], "draft=true") assert String.contains?(result["preview_url"], "lang=de") assert result["published_translation_language"] == "de" assert result["discarded_title"] == "Source Post Draft" assert result["discarded_status"] == "published" end test "project scripting exposes remaining app and metadata parity helpers", %{project: project} do sample_file_path = write_binary_fixture(project.data_path, "show-me.txt", "hello") source = [ "function main()", " local bookmarklet = bds.app.get_blogmark_bookmarklet()", " local copied = bds.app.copy_to_clipboard('copied from lua')", " local metrics = bds.app.get_title_bar_metrics()", " local ready = bds.app.notify_renderer_ready()", " bds.app.set_preview_post_target(nil)", " local open_result = bds.app.open_folder('" <> escape_lua_string(project.data_path) <> "')", " bds.app.show_item_in_folder('" <> escape_lua_string(sample_file_path) <> "')", " bds.app.trigger_menu_action('new_post')", " local startup = bds.meta.sync_on_startup()", " return {", " bookmarklet_prefix = string.sub(bookmarklet, 1, 19),", " copied = copied,", " metrics_type = metrics == nil and 'nil' or 'table',", " ready = ready,", " open_result = open_result,", " startup_tags = #startup.tags,", " startup_categories = #startup.categories,", " startup_project_name = startup.project_metadata.name", " }", "end" ] |> Enum.join("\n") assert {:ok, result} = BDS.Scripting.execute_project_script(project.id, source, "main") assert String.starts_with?(result["bookmarklet_prefix"], "javascript:(()=>{") assert result["copied"] == true assert result["metrics_type"] in ["nil", "table"] assert result["ready"] == true assert result["open_result"] == "" assert result["startup_tags"] >= 0 assert result["startup_categories"] >= 1 assert result["startup_project_name"] == "Scripting API" end defp write_binary_fixture(base_dir, name, contents) do path = Path.join(base_dir, name) File.write!(path, contents) path end defp escape_lua_string(value) do value |> String.replace("\\", "\\\\") |> String.replace("'", "\\'") end end