mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
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:
1 parent
abb7b1d962
commit
91d6c7de57
12 files changed
+107
-74
No files matched your search
@@ -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
@@ -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
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.0.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
interface lifecycle {
|
||||
use types.{context, error};
|
||||
+6
-2
@@ -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
-1
@@ -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 {
|
||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -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.
|
||||
+5
-1
@@ -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
|
||||
+3
-1
@@ -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>;
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user