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.
This commit is contained in:
l0ng-ai authored and GitHub committed 2026-11-05 17:54:21 +08:00
1 parent abb7b1d962
commit 91d6c7de57
12 files changed
+107 -74

No files matched your search

+10
View File
@@ -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
+8 -3
View File
@@ -958,10 +958,15 @@ only grows:
Checked automatically:
- SDK `tests/api_frozen.rs`: `tests/api/<version>/` 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
@@ -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 {
@@ -1,4 +1,4 @@
package openidea:plugin@1.0.0;
package openidea:plugin@1.1.0;
interface lifecycle {
use types.{context, error};
@@ -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
@@ -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 {
@@ -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;
@@ -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
@@ -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.
@@ -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:<program>` grant) with `args` in the
/// project root: long-running output (logs) and interactive sessions.
open-terminal: func(title: string, program: string, args: list<string>) -> 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
@@ -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<project>) -> result<option<repository-status>, error>;
/// Configured remotes (`name`, fetch `url`), in order. Requires `vcs.read`.
remotes: func(project: borrow<project>) -> result<list<tuple<string, string>>, error>;
/// Fetches all remotes (pruning deleted branches). Requires `vcs.write`.
/// Long-running: call it inside a task.
fetch: func(project: borrow<project>) -> result<_, error>;
+69 -61
View File
@@ -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/<version>/` 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/<that version>/`. 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/<version>/`), none before the first release.
fn releases(root: &Path) -> Vec<std::path::PathBuf> {
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:[email protected]` version of a WIT directory.
fn version(dir: &Path) -> Vec<u64> {
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<String> {
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<String> {
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<u64> = 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<u64> = 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:?}"
);
}
}