From 91d6c7de579b9ae727d240ef9ec7815db0cc64ab Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:54:21 +0800 Subject: [PATCH] test(plugins): keep one snapshot of the released plugin API and require it at release (#1512) The snapshot of a released API was a manual step, and v0.1.37 shipped API 1.1.0 without one, so nothing checked the APIs added in 1.1. Each version also added a full copy of wit/. - tests/api/released/ holds the latest released WIT (now 1.1.0) and replaces the per-version folders: each snapshot passed the check against the one before, so the latest covers every release. - api_frozen.rs: under the released version wit/ must match the snapshot exactly; additions need a version bump. Before, a function added to a released version passed, and components built from it didn't link in that release's IDE. - release.yml: create-release fails when the wit/ version differs from the snapshot's, naming the folder to copy wit/ into. --- .github/workflows/release.yml | 10 ++ plugins/README.md | 11 +- .../tests/api/{1.0.0 => released}/events.wit | 2 +- .../tests/api/{1.0.0 => released}/exports.wit | 2 +- .../tests/api/{1.0.0 => released}/host.wit | 8 +- .../tests/api/{1.0.0 => released}/io.wit | 2 +- .../tests/api/{1.0.0 => released}/plugin.wit | 2 +- .../api/{1.0.0 => released}/providers.wit | 2 +- .../tests/api/{1.0.0 => released}/types.wit | 2 +- .../tests/api/{1.0.0 => released}/views.wit | 6 +- .../api/{1.0.0 => released}/workspace.wit | 4 +- sdk/rust/openidea-plugin/tests/api_frozen.rs | 130 ++++++++++-------- 12 files changed, 107 insertions(+), 74 deletions(-) rename sdk/rust/openidea-plugin/tests/api/{1.0.0 => released}/events.wit (98%) rename sdk/rust/openidea-plugin/tests/api/{1.0.0 => released}/exports.wit (98%) rename sdk/rust/openidea-plugin/tests/api/{1.0.0 => released}/host.wit (78%) rename sdk/rust/openidea-plugin/tests/api/{1.0.0 => released}/io.wit (98%) rename sdk/rust/openidea-plugin/tests/api/{1.0.0 => released}/plugin.wit (98%) rename sdk/rust/openidea-plugin/tests/api/{1.0.0 => released}/providers.wit (99%) rename sdk/rust/openidea-plugin/tests/api/{1.0.0 => released}/types.wit (98%) rename sdk/rust/openidea-plugin/tests/api/{1.0.0 => released}/views.wit (93%) rename sdk/rust/openidea-plugin/tests/api/{1.0.0 => released}/workspace.wit (94%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1758e67a..ed664413 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,6 +37,16 @@ jobs: echo "::error::tag $TAG does not match tauri.conf.json version $app" exit 1 fi + # A shipped plugin API is frozen: its WIT must be the snapshot api_frozen.rs checks later + # versions against (plugins/README.md, "Evolving the plugin API"). + - name: Plugin API snapshot matches wit/ + run: | + api() { sed -n 's/^package openidea:plugin@\(.*\);$/\1/p' "$1/plugin.wit"; } + snapshot=sdk/rust/openidea-plugin/tests/api/released + if [ "$(api wit)" != "$(api "$snapshot")" ]; then + echo "::error::plugin API $(api wit) ships without a snapshot ($snapshot is $(api "$snapshot")): copy wit/*.wit to $snapshot/" + exit 1 + fi # The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed # `openidea-plugin keygen` writes), built into the app: their packages # must carry its signature whatever the marketplace index lists. Without diff --git a/plugins/README.md b/plugins/README.md index 6fe01809..d2f1ab09 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -958,10 +958,15 @@ only grows: Checked automatically: -- SDK `tests/api_frozen.rs`: `tests/api//` holds the WIT of each +- SDK `tests/api_frozen.rs`: `tests/api/released/` holds the WIT of the latest released version; every interface, type, function and world import or - export in it must still exist unchanged in `wit/` (rules 1–3). Copy `wit/` - there when a version ships. + export in it must still exist unchanged in `wit/` (rules 1–3). Under the + released version `wit/` must match it exactly, so additions start with a + version bump. Each snapshot passed this check against the one before, so + one snapshot covers every release. +- Releasing: when the `wit/` version changes, copy `wit/*.wit` over + `tests/api/released/` in the release commit. The release workflow refuses a + tag whose `wit/` version differs from the snapshot's. - `crates/ide-plugin/tests/api_compat.rs`: the versions agree, the SDK's copy matches, the version check (rules 4, 5); a simulated next-minor host (every import stubbed at the newer version, plus extra functions and an extra diff --git a/sdk/rust/openidea-plugin/tests/api/1.0.0/events.wit b/sdk/rust/openidea-plugin/tests/api/released/events.wit similarity index 98% rename from sdk/rust/openidea-plugin/tests/api/1.0.0/events.wit rename to sdk/rust/openidea-plugin/tests/api/released/events.wit index 2d118d81..7da95eac 100644 --- a/sdk/rust/openidea-plugin/tests/api/1.0.0/events.wit +++ b/sdk/rust/openidea-plugin/tests/api/released/events.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.0.0; +package openidea:plugin@1.1.0; /// Workbench events, delivered in batches to `event-handler.handle`. interface events { diff --git a/sdk/rust/openidea-plugin/tests/api/1.0.0/exports.wit b/sdk/rust/openidea-plugin/tests/api/released/exports.wit similarity index 98% rename from sdk/rust/openidea-plugin/tests/api/1.0.0/exports.wit rename to sdk/rust/openidea-plugin/tests/api/released/exports.wit index 065ef9a5..80837743 100644 --- a/sdk/rust/openidea-plugin/tests/api/1.0.0/exports.wit +++ b/sdk/rust/openidea-plugin/tests/api/released/exports.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.0.0; +package openidea:plugin@1.1.0; interface lifecycle { use types.{context, error}; diff --git a/sdk/rust/openidea-plugin/tests/api/1.0.0/host.wit b/sdk/rust/openidea-plugin/tests/api/released/host.wit similarity index 78% rename from sdk/rust/openidea-plugin/tests/api/1.0.0/host.wit rename to sdk/rust/openidea-plugin/tests/api/released/host.wit index b6325cb7..c7ac7f91 100644 --- a/sdk/rust/openidea-plugin/tests/api/1.0.0/host.wit +++ b/sdk/rust/openidea-plugin/tests/api/released/host.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.0.0; +package openidea:plugin@1.1.0; /// Writes to the IDE log, tagged with the plugin id. interface log { @@ -8,7 +8,7 @@ interface log { } interface ui { - use types.{notification-level}; + use types.{error, notification-level}; record status-item { text: string, @@ -23,6 +23,10 @@ interface ui { /// Shows or updates the plugin's status bar item `id` in every window. set-status-item: func(id: string, item: status-item); remove-status-item: func(id: string); + /// Copies `text` to the system clipboard. Requires `clipboard`. + copy-to-clipboard: func(text: string) -> result<_, error>; + /// Opens an `http://` or `https://` URL in the user's default browser. + open-url: func(url: string) -> result<_, error>; } /// Persistent key-value storage of the plugin (survives restarts and idle diff --git a/sdk/rust/openidea-plugin/tests/api/1.0.0/io.wit b/sdk/rust/openidea-plugin/tests/api/released/io.wit similarity index 98% rename from sdk/rust/openidea-plugin/tests/api/1.0.0/io.wit rename to sdk/rust/openidea-plugin/tests/api/released/io.wit index f1fe634a..b93ea079 100644 --- a/sdk/rust/openidea-plugin/tests/api/1.0.0/io.wit +++ b/sdk/rust/openidea-plugin/tests/api/released/io.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.0.0; +package openidea:plugin@1.1.0; /// Problems in files, shown in the editor and the Problems view. interface diagnostics { diff --git a/sdk/rust/openidea-plugin/tests/api/1.0.0/plugin.wit b/sdk/rust/openidea-plugin/tests/api/released/plugin.wit similarity index 98% rename from sdk/rust/openidea-plugin/tests/api/1.0.0/plugin.wit rename to sdk/rust/openidea-plugin/tests/api/released/plugin.wit index 938ee084..e9fc0ac8 100644 --- a/sdk/rust/openidea-plugin/tests/api/1.0.0/plugin.wit +++ b/sdk/rust/openidea-plugin/tests/api/released/plugin.wit @@ -7,7 +7,7 @@ /// or processes beyond WASI's clocks and randomness; everything else goes /// through the imports below, gated by the capabilities granted in /// `plugin.toml` (a missing one returns `error::denied`). -package openidea:plugin@1.0.0; +package openidea:plugin@1.1.0; world plugin { import log; diff --git a/sdk/rust/openidea-plugin/tests/api/1.0.0/providers.wit b/sdk/rust/openidea-plugin/tests/api/released/providers.wit similarity index 99% rename from sdk/rust/openidea-plugin/tests/api/1.0.0/providers.wit rename to sdk/rust/openidea-plugin/tests/api/released/providers.wit index 97dd4c7a..17c1123a 100644 --- a/sdk/rust/openidea-plugin/tests/api/1.0.0/providers.wit +++ b/sdk/rust/openidea-plugin/tests/api/released/providers.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.0.0; +package openidea:plugin@1.1.0; /// Editor features a plugin provides for the languages it declares under /// `[[contributes.providers]]` (the host calls only declared kinds). They run diff --git a/sdk/rust/openidea-plugin/tests/api/1.0.0/types.wit b/sdk/rust/openidea-plugin/tests/api/released/types.wit similarity index 98% rename from sdk/rust/openidea-plugin/tests/api/1.0.0/types.wit rename to sdk/rust/openidea-plugin/tests/api/released/types.wit index 2e2b48f7..de35cadf 100644 --- a/sdk/rust/openidea-plugin/tests/api/1.0.0/types.wit +++ b/sdk/rust/openidea-plugin/tests/api/released/types.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.0.0; +package openidea:plugin@1.1.0; /// Shared types. Positions are 0-based lines and UTF-16 code unit columns, /// as in the editor and LSP. diff --git a/sdk/rust/openidea-plugin/tests/api/1.0.0/views.wit b/sdk/rust/openidea-plugin/tests/api/released/views.wit similarity index 93% rename from sdk/rust/openidea-plugin/tests/api/1.0.0/views.wit rename to sdk/rust/openidea-plugin/tests/api/released/views.wit index dcc806dc..8a67e222 100644 --- a/sdk/rust/openidea-plugin/tests/api/1.0.0/views.wit +++ b/sdk/rust/openidea-plugin/tests/api/released/views.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.0.0; +package openidea:plugin@1.1.0; /// Declarative views: tool windows declared /// under `[[contributes.views]]` and custom editors under @@ -133,6 +133,10 @@ interface views { /// `program` (pinned by a `process:` grant) with `args` in the /// project root: long-running output (logs) and interactive sessions. open-terminal: func(title: string, program: string, args: list) -> result<_, error>; + /// Opens `path` (project-relative or absolute, inside the project) in an + /// editor of the window the call came from, the caret at `line` and + /// `column` (0-based; UTF-16 column): a view's links to code. + open-location: func(path: string, line: u32, column: u32) -> result<_, error>; } /// The plugin's own settings (`[contributes.settings]`), as the user set them diff --git a/sdk/rust/openidea-plugin/tests/api/1.0.0/workspace.wit b/sdk/rust/openidea-plugin/tests/api/released/workspace.wit similarity index 94% rename from sdk/rust/openidea-plugin/tests/api/1.0.0/workspace.wit rename to sdk/rust/openidea-plugin/tests/api/released/workspace.wit index febca7c6..18004047 100644 --- a/sdk/rust/openidea-plugin/tests/api/1.0.0/workspace.wit +++ b/sdk/rust/openidea-plugin/tests/api/released/workspace.wit @@ -1,4 +1,4 @@ -package openidea:plugin@1.0.0; +package openidea:plugin@1.1.0; interface workspace { use types.{error}; @@ -84,6 +84,8 @@ interface vcs { /// `none` when the project is not in a repository. Requires `vcs.read`. status: func(project: borrow) -> result, error>; + /// Configured remotes (`name`, fetch `url`), in order. Requires `vcs.read`. + remotes: func(project: borrow) -> result>, error>; /// Fetches all remotes (pruning deleted branches). Requires `vcs.write`. /// Long-running: call it inside a task. fetch: func(project: borrow) -> result<_, error>; diff --git a/sdk/rust/openidea-plugin/tests/api_frozen.rs b/sdk/rust/openidea-plugin/tests/api_frozen.rs index b5a0e08c..79ca3e07 100644 --- a/sdk/rust/openidea-plugin/tests/api_frozen.rs +++ b/sdk/rust/openidea-plugin/tests/api_frozen.rs @@ -3,11 +3,13 @@ //! a field added to a record or a case added to a variant breaks every //! component built for the release. //! -//! `tests/api//` is the WIT of each release, copied when it was -//! frozen. Every interface, type and function in it must still exist in -//! `wit/` with the same definition; additions are fine. When a version is -//! released, copy `wit/` to `tests/api//`. Until the first -//! release there is no snapshot and the API may still change freely. +//! `tests/api/released/` is the WIT of the latest released version. Every +//! interface, type and function in it must still exist in `wit/` with the same +//! definition. Additions need a newer version than the snapshot's: under the +//! released version `wit/` must match it exactly. When a version ships, copy +//! `wit/` over the snapshot (the release workflow refuses a tag whose `wit/` +//! version differs from it). Each snapshot passed this check against the one +//! before, so the latest one covers every earlier release. use std::collections::BTreeMap; use std::path::Path; @@ -110,77 +112,83 @@ fn anonymous(r: &Resolve, id: TypeId) -> String { } } -/// The frozen releases (`tests/api//`), none before the first release. -fn releases(root: &Path) -> Vec { - match std::fs::read_dir(root.join("tests/api")) { - Ok(dir) => dir.map(|e| e.unwrap().path()).filter(|p| p.is_dir()).collect(), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(), - Err(e) => panic!("tests/api: {e}"), - } +fn root() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")) +} + +/// The latest released version's WIT. +fn released() -> std::path::PathBuf { + root().join("tests/api/released") +} + +/// The `package openidea:plugin@x.y.z` version of a WIT directory. +fn version(dir: &Path) -> Vec { + let src = std::fs::read_to_string(dir.join("plugin.wit")).unwrap_or_else(|e| panic!("{}: {e}", dir.display())); + let v = src.lines().find_map(|l| l.trim().strip_prefix("package openidea:plugin@")).unwrap(); + v.trim_end_matches(';').split('.').map(|p| p.parse().unwrap()).collect() } #[test] fn the_surface_covers_the_whole_api() { - // Keeps the check below meaningful while there is no release to compare. - let current = surface(&Path::new(env!("CARGO_MANIFEST_DIR")).join("wit")); - assert!(current.len() > 50, "only {} items: did wit/ load?", current.len()); - assert!(current.keys().any(|k| k.starts_with("world "))); - assert!(current.keys().any(|k| k.contains("/func "))); - assert!(current.keys().any(|k| k.contains("/type "))); + for dir in [root().join("wit"), released()] { + let items = surface(&dir); + assert!(items.len() > 50, "{}: only {} items: did it load?", dir.display(), items.len()); + assert!(items.keys().any(|k| k.starts_with("world "))); + assert!(items.keys().any(|k| k.contains("/func "))); + assert!(items.keys().any(|k| k.contains("/type "))); + } } #[test] -fn released_apis_are_unchanged() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")); - let current = surface(&root.join("wit")); - for dir in releases(root) { - let release = surface(&dir); - let version = dir.file_name().unwrap().to_string_lossy().into_owned(); - let mut broken = Vec::new(); - for (key, was) in &release { - match current.get(key) { - None => broken.push(format!("{key}: removed (was {was})")), - // A world may gain imports and exports; everything else stays as it was. Items are - // compared without their `@version`: a later minor names them `@1.1.0`, and wasmtime - // resolves the release's `@1.0.0` to it. - Some(now) if key.starts_with("world ") => { - let had = |s: &str, part: &str| -> Vec { - let start = s.find(part).unwrap() + part.len(); - let end = s[start..].find(']').unwrap() + start; - s[start..end] - .split(", ") - .filter(|x| !x.is_empty()) - .map(|x| x.split('@').next().unwrap_or(x).to_owned()) - .collect() - }; - for part in ["imports [", "exports ["] { - let now = had(now, part); - for item in had(was, part) { - if !now.contains(&item) { - broken.push(format!("{key}: no longer has {item}")); - } +fn the_released_api_is_unchanged() { + let current = surface(&root().join("wit")); + let release = surface(&released()); + let mut broken = Vec::new(); + for (key, was) in &release { + match current.get(key) { + None => broken.push(format!("{key}: removed (was {was})")), + // A world may gain imports and exports; everything else stays as it was. Items are + // compared without their `@version`: a later minor names them `@1.2.0`, and wasmtime + // resolves the release's `@1.1.0` to it. + Some(now) if key.starts_with("world ") => { + let had = |s: &str, part: &str| -> Vec { + let start = s.find(part).unwrap() + part.len(); + let end = s[start..].find(']').unwrap() + start; + s[start..end] + .split(", ") + .filter(|x| !x.is_empty()) + .map(|x| x.split('@').next().unwrap_or(x).to_owned()) + .collect() + }; + for part in ["imports [", "exports ["] { + let now = had(now, part); + for item in had(was, part) { + if !now.contains(&item) { + broken.push(format!("{key}: no longer has {item}")); } } } - Some(now) if now != was => broken.push(format!("{key}:\n was {was}\n now {now}")), - Some(_) => {} } + Some(now) if now != was => broken.push(format!("{key}:\n was {was}\n now {now}")), + Some(_) => {} } - assert!(broken.is_empty(), "plugin API {version} changed incompatibly:\n {}", broken.join("\n ")); - assert!(release.len() > 50, "{version}: only {} items: did the snapshot load?", release.len()); } + assert!(broken.is_empty(), "the released plugin API changed incompatibly:\n {}", broken.join("\n ")); } #[test] -fn the_current_api_is_a_release_or_newer_than_all() { - // `wit/` is either a frozen release or the next one; never older. - let root = Path::new(env!("CARGO_MANIFEST_DIR")); - let src = std::fs::read_to_string(root.join("wit/plugin.wit")).unwrap(); - let current = src.lines().find_map(|l| l.trim().strip_prefix("package openidea:plugin@")).unwrap(); - let current: Vec = current.trim_end_matches(';').split('.').map(|p| p.parse().unwrap()).collect(); - for dir in releases(root) { - let name = dir.file_name().unwrap().to_string_lossy().into_owned(); - let release: Vec = name.split('.').map(|p| p.parse().unwrap()).collect(); - assert!(release <= current, "tests/api/{name} is newer than wit/ ({current:?})"); +fn additions_need_a_newer_version() { + // A component built from `wit/` imports what `wit/` declares; under a released version an + // IDE of that release would lack the additions and refuse it. + let (current, release) = (version(&root().join("wit")), version(&released())); + assert!(current >= release, "wit/ ({current:?}) is older than the released API ({release:?})"); + if current == release { + let (now, was) = (surface(&root().join("wit")), surface(&released())); + let added: Vec<_> = now.keys().filter(|k| now.get(*k) != was.get(*k)).collect(); + assert!( + added.is_empty(), + "wit/ adds to the released version {release:?}; bump the package version in wit/ \ + (and the SDK's copy, ENGINE_VERSION, API_VERSION) first:\n {added:?}" + ); } }