From 666e9c905b5a85a8afd8096720743af0c878c7ea Mon Sep 17 00:00:00 2001 From: ayattara Date: Fri, 28 Aug 2026 06:12:43 +0000 Subject: [PATCH 1/2] test: gate notes version persistence --- .github/workflows/ci.yml | 2 + .github/workflows/release.yml | 2 + Makefile | 1 + README.md | 22 ++ .../host_upgrade_preserves_user_state_test.go | 50 +++ manifest.yaml | 8 +- scripts/test-host-upgrade-state.sh | 28 ++ server/manifest_test.go | 27 +- ui/persistence-compat.test.mjs | 359 ++++++++++++++++++ 9 files changed, 496 insertions(+), 3 deletions(-) create mode 100644 integration/host_upgrade_preserves_user_state_test.go create mode 100644 scripts/test-host-upgrade-state.sh create mode 100644 ui/persistence-compat.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25253dc..be01a6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,8 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: path: plugin + # ui/persistence-compat.test.mjs loads every published bundle. + fetch-depth: 0 - name: Check out kandev SDK uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f2a5dac..2825953 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -159,6 +159,8 @@ jobs: with: path: plugin ref: ${{ github.event_name == 'push' && github.ref || needs.prepare.outputs.tag }} + # Release verification replays every published Notes bundle. + fetch-depth: 0 - name: Checkout kandev SDK uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 diff --git a/Makefile b/Makefile index d8ba6d4..749e799 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,7 @@ run: build test: go test ./server/... node --test "ui/**/*.test.mjs" + sh scripts/test-host-upgrade-state.sh fmt: gofmt -l . diff --git a/README.md b/README.md index 3f67273..5b70303 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,28 @@ Sideloaded plugins register disabled/unverified; enable it in version in `manifest.yaml` (and `Makefile`) first. To use "Enhance with AI", select an agent profile as described in "Setting up Enhance with AI" above. +## Updating or downgrading without losing notes + +Install the different Notes version directly over the existing installation, +using Kandev's Update/version action or the install endpoint above. Kandev +replaces that plugin version in place, and Notes keeps addressing the same +per-user task and workspace rows. The automated release gate replays every +published Notes bundle in both directions and verifies the saved Markdown +byte-for-byte. + +Do **not** click Uninstall as a step in changing versions. Explicit uninstall +has different semantics: Kandev intentionally purges that plugin's user state, +including all Notes rows, before removing the package. Uninstall/reinstall is +therefore not an update or downgrade path. + +For a disposable manual smoke check: + +1. Save distinct task and workspace notes, including Markdown formatting. +2. Install a different Notes version directly over the active version. +3. Reopen both notes and compare their exact content. +4. Install the original version directly over it and verify both notes again. +5. Optionally repeat as a second user to confirm the two users remain isolated. + ## Development The Kandev plugin SDK (`pkg/pluginsdk`) is not yet published as a standalone Go diff --git a/integration/host_upgrade_preserves_user_state_test.go b/integration/host_upgrade_preserves_user_state_test.go new file mode 100644 index 0000000..b44b8ee --- /dev/null +++ b/integration/host_upgrade_preserves_user_state_test.go @@ -0,0 +1,50 @@ +package plugins + +// This file is applied to Kandev's internal/plugins package through a Go +// overlay by scripts/test-host-upgrade-state.sh. Keeping the test in this +// repository lets every Notes release gate the exact host lifecycle it +// depends on without modifying the sibling Kandev checkout. + +import ( + "context" + "testing" +) + +const notesPersistencePluginID = "kandev-plugin-notes" + +type notesUserStateCleanupSpy struct { + pluginIDs []string +} + +func (s *notesUserStateCleanupSpy) DeleteAllForPlugin(_ context.Context, pluginID string) error { + s.pluginIDs = append(s.pluginIDs, pluginID) + return nil +} + +func TestNotesPluginVersionReplacementPreservesUserState(t *testing.T) { + svc, _, _ := newTestService(t) + cleanup := ¬esUserStateCleanupSpy{} + svc.setUserStateCleanupStore(cleanup) + + installTestPlugin(t, svc, notesPersistencePluginID) + + for _, version := range []string{"1.1.0", "0.9.0"} { + rec, err := svc.Install( + t.Context(), + testPackage(t, notesPersistencePluginID, version, false), + ) + if err != nil { + t.Fatalf("Install(%s) over existing Notes version: %v", version, err) + } + if rec.Version != version { + t.Fatalf("Install(%s) recorded version %q", version, rec.Version) + } + if len(cleanup.pluginIDs) != 0 { + t.Fatalf( + "Install(%s) purged plugin user state for %v; version replacement must never call DeleteAllForPlugin", + version, + cleanup.pluginIDs, + ) + } + } +} diff --git a/manifest.yaml b/manifest.yaml index e82c9d9..bedbd01 100644 --- a/manifest.yaml +++ b/manifest.yaml @@ -2,7 +2,9 @@ # plugin and a kandev host. `id` MUST match the Go module name in go.mod and # the id passed to window.registerKandevPlugin(...) in ui/bundle.js, and it is # the prefix of every route kandev serves for this plugin -# (/api/plugins//...). Keep the three in sync when bumping version. +# (/api/plugins//...). It also namespaces every plugin_user_state row: +# changing it would strand every saved note. Keep it immutable across releases +# unless a host-backed data migration ships first. id: "kandev-plugin-notes" api_version: 1 version: "0.6.0" @@ -26,7 +28,9 @@ runtime: windows-amd64: "server/plugin-windows-amd64.exe" # capabilities gate what the host will do for this plugin. This plugin needs -# per-user browser-reachable storage (host.storage) for the note itself, plus +# per-user browser-reachable storage (host.storage) for the note itself. This +# capability is part of the persistence contract: removing it makes all saved +# notes inaccessible. The plugin also needs # agent_invoke so the "Enhance with AI" button can run a one-shot proofreading # completion via Host.InvokeUtilityAgent (see server/plugin.go and the # `agent_profile` config property below). diff --git a/scripts/test-host-upgrade-state.sh b/scripts/test-host-upgrade-state.sh new file mode 100644 index 0000000..1e1fbd3 --- /dev/null +++ b/scripts/test-host-upgrade-state.sh @@ -0,0 +1,28 @@ +#!/bin/sh +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH= cd -- "$script_dir/.." && pwd) +kandev_backend=${KANDEV_NOTES_KANDEV_BACKEND:-"$repo_root/../kandev/apps/backend"} +contract_test="$repo_root/integration/host_upgrade_preserves_user_state_test.go" + +if [ ! -f "$kandev_backend/internal/plugins/service_install.go" ]; then + echo "Notes host persistence gate requires a Kandev backend checkout at: $kandev_backend" >&2 + echo "Set KANDEV_NOTES_KANDEV_BACKEND to apps/backend when using another sibling layout." >&2 + exit 1 +fi + +overlay=$(mktemp "${TMPDIR:-/tmp}/notes-host-overlay.XXXXXX") +trap 'rm -f "$overlay"' EXIT HUP INT TERM + +target="$kandev_backend/internal/plugins/notes_persistence_contract_test.go" +printf '{"Replace":{"%s":"%s"}}\n' "$target" "$contract_test" >"$overlay" + +( + cd "$kandev_backend" + go test \ + -overlay "$overlay" \ + ./internal/plugins \ + -run '^TestNotesPluginVersionReplacementPreservesUserState$' \ + -count=1 +) diff --git a/server/manifest_test.go b/server/manifest_test.go index 48946fe..25c7c76 100644 --- a/server/manifest_test.go +++ b/server/manifest_test.go @@ -17,7 +17,10 @@ import ( "gopkg.in/yaml.v3" ) -const repoRoot = ".." +const ( + repoRoot = ".." + releasedPluginID = "kandev-plugin-notes" +) type manifestYAML struct { ID string `yaml:"id"` @@ -108,6 +111,28 @@ func TestManifestIdentity_MatchesBundleRegistrationID(t *testing.T) { require.Equal(t, m.ID, bundleRegistrationID(t)) } +func TestManifestPersistenceContract_PreservesReleasedUserStateNamespace(t *testing.T) { + m := loadManifest(t) + require.Equal( + t, + releasedPluginID, + m.ID, + "the plugin id namespaces every saved note; changing it requires an explicit data migration", + ) + require.Equal( + t, + releasedPluginID, + bundleRegistrationID(t), + "the UI registration id must keep addressing the released note namespace", + ) + require.Equal( + t, + true, + m.Capabilities["user_state"], + "removing user_state makes existing notes inaccessible", + ) +} + func TestManifestVersion_MatchesMakefileVersion(t *testing.T) { m := loadManifest(t) require.Equal(t, m.Version, makefileVersion(t)) diff --git a/ui/persistence-compat.test.mjs b/ui/persistence-compat.test.mjs new file mode 100644 index 0000000..46c9166 --- /dev/null +++ b/ui/persistence-compat.test.mjs @@ -0,0 +1,359 @@ +// Cross-version persistence contract for released Notes bundles. +// +// The real bundle from every published SemVer tag is loaded from Git. The +// shared storage double includes the registered plugin id in its namespace, +// just as Kandev plugin_user_state does, so changing the plugin id, scope, +// scope id, or key makes the round trip fail. Complete tag history is a +// required test input rather than an optional compatibility fixture. + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const RELEASED_PLUGIN_ID = "kandev-plugin-notes"; +const FIRST_TASK_NOTES_TAG = "v0.1.0"; +const FIRST_WORKSPACE_NOTES_TAG = "v0.4.0"; +const TEST_USER_ID = "notes-compat-user"; +const NOTE_KEY = "note"; + +function git(...args) { + try { + return execFileSync("git", args, { encoding: "utf8" }); + } catch (error) { + const detail = error && error.stderr ? String(error.stderr).trim() : String(error); + throw new Error( + "Notes persistence compatibility requires complete Git history: git " + + args.join(" ") + + " failed: " + + detail, + ); + } +} + +function releaseTags() { + return git("tag", "--list", "v*", "--sort=version:refname") + .split("\n") + .map((tag) => tag.trim()) + .filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag)); +} + +function versionAtLeast(tag, baseline) { + const left = tag.slice(1).split(".").map(Number); + const right = baseline.slice(1).split(".").map(Number); + for (let i = 0; i < 3; i += 1) { + if (left[i] !== right[i]) return left[i] > right[i]; + } + return true; +} + +async function loadBundle(label, source) { + let registration = null; + const previousWindow = globalThis.window; + const previousRegister = globalThis.registerKandevPlugin; + globalThis.window = globalThis; + globalThis.registerKandevPlugin = (id, plugin) => { + registration = { id, plugin }; + }; + + try { + // The label comment makes the URL unique even when HEAD is byte-identical + // to the newest release. + const encoded = Buffer.from( + source + "\n// persistence-compat-source: " + label + "\n", + ).toString("base64"); + const module = await import("data:text/javascript;base64," + encoded); + assert.ok(registration, label + " did not register a Kandev plugin"); + assert.equal( + typeof module.createNoteStore, + "function", + label + " does not export createNoteStore", + ); + return { label, id: registration.id, plugin: registration.plugin, module }; + } finally { + globalThis.window = previousWindow; + globalThis.registerKandevPlugin = previousRegister; + } +} + +class PluginStorageConflictError extends Error { + constructor() { + super("plugin storage: value was modified since ifUnmodifiedSince"); + this.name = "PluginStorageConflictError"; + } +} + +class PersistentUserState { + #entries = new Map(); + #revision = 0; + #subscribers = new Set(); + + operations = []; + + #address(pluginId, userId, scope, scopeId, key) { + return JSON.stringify([pluginId, userId, scope, scopeId, key]); + } + + #record(method, pluginId, userId, scope, scopeId, key, extra = {}) { + this.operations.push({ method, pluginId, userId, scope, scopeId, key, ...extra }); + } + + #notify(pluginId, userId, change, writerId) { + for (const subscriber of this.#subscribers) { + if (subscriber.pluginId !== pluginId || subscriber.userId !== userId) continue; + const { filter } = subscriber; + if (filter.scope && filter.scope !== change.scope) continue; + if (filter.scopeId && filter.scopeId !== change.scopeId) continue; + if (filter.key && filter.key !== change.key) continue; + if (filter.writerId && filter.writerId === writerId) continue; + subscriber.handler(change); + } + } + + host(pluginId, userId = TEST_USER_ID) { + const state = this; + return { + storage: { + async get(scope, scopeId, key) { + state.#record("get", pluginId, userId, scope, scopeId, key); + const address = state.#address(pluginId, userId, scope, scopeId, key); + const entry = state.#entries.get(address); + return entry ? { ...entry } : undefined; + }, + async set(scope, scopeId, key, value, options = {}) { + state.#record("set", pluginId, userId, scope, scopeId, key, { value, options }); + const address = state.#address(pluginId, userId, scope, scopeId, key); + const existing = state.#entries.get(address); + if ( + options.ifUnmodifiedSince !== undefined && + options.ifUnmodifiedSince !== (existing && existing.updatedAt) + ) { + throw new PluginStorageConflictError(); + } + state.#revision += 1; + const updatedAt = "compat-r" + state.#revision; + state.#entries.set(address, { value, updatedAt }); + state.#notify( + pluginId, + userId, + { scope, scopeId, key, updatedAt, deleted: false }, + options.writerId, + ); + return { updatedAt }; + }, + async delete(scope, scopeId, key, options = {}) { + state.#record("delete", pluginId, userId, scope, scopeId, key, { options }); + const address = state.#address(pluginId, userId, scope, scopeId, key); + state.#entries.delete(address); + state.#revision += 1; + state.#notify( + pluginId, + userId, + { + scope, + scopeId, + key, + updatedAt: "compat-r" + state.#revision, + deleted: true, + }, + options.writerId, + ); + }, + async list() { + return []; + }, + subscribe(filter, handler) { + const subscriber = { pluginId, userId, filter, handler }; + state.#subscribers.add(subscriber); + return () => state.#subscribers.delete(subscriber); + }, + }, + api: { + async fetch(path) { + throw new Error( + "unexpected API probe during persistence compatibility test: " + path, + ); + }, + }, + }; + } + + entry(pluginId, userId, scope, scopeId, key) { + return this.#entries.get(this.#address(pluginId, userId, scope, scopeId, key)); + } +} + +async function waitFor(predicate, message, timeoutMs = 2_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.fail(message); +} + +function storeOptions(bundle, scope, scopeId) { + return { + scope, + scopeId, + // v0.1.x/v0.2.x accepted taskId before scopeId was generalized. + taskId: scope === "task" ? scopeId : undefined, + surfaceId: "persistence-compat-" + bundle.label, + }; +} + +async function openStore(bundle, state, scope, scopeId) { + const store = bundle.module.createNoteStore( + state.host(bundle.id), + storeOptions(bundle, scope, scopeId), + ); + await waitFor( + () => store.getSnapshot().loaded, + bundle.label + " did not finish loading " + scope + "/" + scopeId, + ); + return store; +} + +async function readNote(bundle, state, scope, scopeId) { + const store = await openStore(bundle, state, scope, scopeId); + const value = store.getSnapshot().value; + store.dispose(); + bundle.plugin.destroy?.(); + return value; +} + +async function writeNote(bundle, state, scope, scopeId, value) { + const store = await openStore(bundle, state, scope, scopeId); + const priorUpdatedAt = state.entry( + bundle.id, + TEST_USER_ID, + scope, + scopeId, + NOTE_KEY, + )?.updatedAt; + store.setValue(value); + await waitFor(() => { + const entry = state.entry(bundle.id, TEST_USER_ID, scope, scopeId, NOTE_KEY); + return entry && entry.value === value && entry.updatedAt !== priorUpdatedAt; + }, bundle.label + " did not persist " + scope + "/" + scopeId); + store.dispose(); + bundle.plugin.destroy?.(); +} + +function assertStorageContract(state, scope, scopeId) { + const noteOperations = state.operations.filter((operation) => operation.method !== "list"); + assert.ok(noteOperations.length > 0, "compatibility scenario issued no storage operations"); + for (const operation of noteOperations) { + assert.equal( + operation.pluginId, + RELEASED_PLUGIN_ID, + "plugin id is part of the permanent storage namespace", + ); + assert.equal(operation.userId, TEST_USER_ID); + assert.equal(operation.scope, scope); + assert.equal(operation.scopeId, scopeId); + assert.equal(operation.key, NOTE_KEY); + } + assert.equal( + noteOperations.filter((operation) => operation.method === "delete").length, + 0, + "a version transition must never delete a note", + ); +} + +const tags = releaseTags(); +assert.ok( + tags.includes(FIRST_TASK_NOTES_TAG), + "required task-note baseline tag " + FIRST_TASK_NOTES_TAG + " is missing", +); +assert.ok( + tags.includes(FIRST_WORKSPACE_NOTES_TAG), + "required workspace-note baseline tag " + FIRST_WORKSPACE_NOTES_TAG + " is missing", +); + +const releasedBundles = new Map(); +for (const tag of tags) { + releasedBundles.set(tag, await loadBundle(tag, git("show", tag + ":ui/bundle.js"))); +} +const candidateBundle = await loadBundle( + "working-tree", + readFileSync(new URL("./bundle.js", import.meta.url), "utf8"), +); + +test("all published and candidate bundles keep the released plugin identity", () => { + assert.equal(candidateBundle.id, RELEASED_PLUGIN_ID); + for (const [tag, bundle] of releasedBundles) { + assert.equal(bundle.id, RELEASED_PLUGIN_ID, tag + " registered under an unexpected plugin id"); + } +}); + +for (const tag of tags) { + test(tag + ": task notes survive upgrade to the candidate and downgrade back", async () => { + const state = new PersistentUserState(); + const scopeId = "task-" + tag; + const released = releasedBundles.get(tag); + const historical = + "# Historical " + + tag + + "\n\n- [x] keep **all** markdown\n- owner: " + + TEST_USER_ID; + const candidate = + "# Candidate edit for " + tag + "\n\nInline code and Markdown stay byte-for-byte."; + + await writeNote(released, state, "task", scopeId, historical); + assert.equal(await readNote(candidateBundle, state, "task", scopeId), historical); + + await writeNote(candidateBundle, state, "task", scopeId, candidate); + assert.equal(await readNote(released, state, "task", scopeId), candidate); + + assertStorageContract(state, "task", scopeId); + }); +} + +for (const tag of tags.filter((candidate) => versionAtLeast(candidate, FIRST_WORKSPACE_NOTES_TAG))) { + test(tag + ": workspace notes survive upgrade to the candidate and downgrade back", async () => { + const state = new PersistentUserState(); + const scopeId = "workspace-" + tag; + const released = releasedBundles.get(tag); + const historical = "## Workspace " + tag + "\n\nPersistent workspace idea."; + const candidate = "## Candidate workspace edit\n\nStill present after " + tag + "."; + + await writeNote(released, state, "workspace", scopeId, historical); + assert.equal(await readNote(candidateBundle, state, "workspace", scopeId), historical); + + await writeNote(candidateBundle, state, "workspace", scopeId, candidate); + assert.equal(await readNote(released, state, "workspace", scopeId), candidate); + + assertStorageContract(state, "workspace", scopeId); + }); +} + +test("a task-only downgrade hides but never deletes a workspace note", async () => { + const state = new PersistentUserState(); + const scopeId = "workspace-through-task-only-release"; + const taskId = "task-opened-during-old-release"; + const workspaceBundle = releasedBundles.get(FIRST_WORKSPACE_NOTES_TAG); + const taskOnlyBundle = releasedBundles.get(FIRST_TASK_NOTES_TAG); + const content = "# Workspace note\n\nRecover me after the old release."; + + await writeNote(workspaceBundle, state, "workspace", scopeId, content); + + // v0.1.0 has no workspace surface. Opening and unloading its task store + // simulates the old plugin running without mutating the workspace row it + // cannot display. + assert.equal(await readNote(taskOnlyBundle, state, "task", taskId), ""); + assert.equal(await readNote(candidateBundle, state, "workspace", scopeId), content); + + assert.deepEqual( + state.operations.filter((operation) => operation.method === "delete"), + [], + ); + const workspaceEntry = state.entry( + RELEASED_PLUGIN_ID, + TEST_USER_ID, + "workspace", + scopeId, + NOTE_KEY, + ); + assert.equal(workspaceEntry.value, content); +}); From 3e2e0aaebcbb94e334cc2cc0ced0b5717ff65eca Mon Sep 17 00:00:00 2001 From: ayattara Date: Fri, 28 Aug 2026 14:39:35 +0000 Subject: [PATCH 2/2] qa: cover exact note text preservation --- ui/persistence-compat.test.mjs | 41 +++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/ui/persistence-compat.test.mjs b/ui/persistence-compat.test.mjs index 46c9166..296d2ee 100644 --- a/ui/persistence-compat.test.mjs +++ b/ui/persistence-compat.test.mjs @@ -292,13 +292,23 @@ for (const tag of tags) { const state = new PersistentUserState(); const scopeId = "task-" + tag; const released = releasedBundles.get(tag); - const historical = - "# Historical " + - tag + - "\n\n- [x] keep **all** markdown\n- owner: " + - TEST_USER_ID; - const candidate = - "# Candidate edit for " + tag + "\n\nInline code and Markdown stay byte-for-byte."; + const historical = [ + "# Historical " + tag, + "", + "- [x] keep **all** markdown ", + "- owner: " + TEST_USER_ID, + "- unicode: Café 東京 🚀", + "", + ].join("\r\n"); + const candidate = [ + "# Candidate edit for " + tag, + "", + "Inline code and Markdown stay byte-for-byte.", + "", + " indented code", + "trailing spaces ", + "unicode: naïve Привет 🌍", + ].join("\n"); await writeNote(released, state, "task", scopeId, historical); assert.equal(await readNote(candidateBundle, state, "task", scopeId), historical); @@ -315,8 +325,19 @@ for (const tag of tags.filter((candidate) => versionAtLeast(candidate, FIRST_WOR const state = new PersistentUserState(); const scopeId = "workspace-" + tag; const released = releasedBundles.get(tag); - const historical = "## Workspace " + tag + "\n\nPersistent workspace idea."; - const candidate = "## Candidate workspace edit\n\nStill present after " + tag + "."; + const historical = [ + "## Workspace " + tag, + "", + "Persistent workspace idea. ", + "日本語 workspace note", + "", + ].join("\r\n"); + const candidate = [ + "## Candidate workspace edit", + "", + "Still present after " + tag + ".", + "emoji: 📝", + ].join("\n"); await writeNote(released, state, "workspace", scopeId, historical); assert.equal(await readNote(candidateBundle, state, "workspace", scopeId), historical); @@ -334,7 +355,7 @@ test("a task-only downgrade hides but never deletes a workspace note", async () const taskId = "task-opened-during-old-release"; const workspaceBundle = releasedBundles.get(FIRST_WORKSPACE_NOTES_TAG); const taskOnlyBundle = releasedBundles.get(FIRST_TASK_NOTES_TAG); - const content = "# Workspace note\n\nRecover me after the old release."; + const content = "# Workspace note\r\n\r\nRecover me after the old release. \r\n復元してください"; await writeNote(workspaceBundle, state, "workspace", scopeId, content);