Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
513 lines
17 KiB
JavaScript
513 lines
17 KiB
JavaScript
"use strict";
|
|
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const test = require("node:test");
|
|
|
|
const { PluginDatabase } = require("./database.cjs");
|
|
const { PluginSyncSidecarService } = require("./pluginSyncSidecarService.cjs");
|
|
|
|
function tempDb(context) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-sidecar-"));
|
|
context.after(() => {
|
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
});
|
|
return new PluginDatabase(path.join(dir, "plugins.sqlite"));
|
|
}
|
|
|
|
test("sidecar table has no package cascade and survives removePlugin", (context) => {
|
|
const database = tempDb(context);
|
|
database.installVersion({
|
|
pluginId: "com.example.sync",
|
|
version: "1.0.0",
|
|
manifest: { id: "com.example.sync", version: "1.0.0" },
|
|
archiveSha256: "a".repeat(64),
|
|
packageRelativePath: "packages/com.example.sync/1.0.0",
|
|
}, { enable: true });
|
|
database.setSyncSidecar("com.example.sync", "account_baseline", "account", { id: "acct" }, 10);
|
|
database.setSetting("com.example.sync", "com.example.sync.theme", "application", "application", "dark");
|
|
|
|
assert.deepEqual(database.db.prepare("PRAGMA foreign_key_list(plugin_sync_sidecars)").all(), []);
|
|
database.removePlugin("com.example.sync");
|
|
|
|
assert.deepEqual(database.getSyncSidecar("com.example.sync", "account_baseline", "account"), {
|
|
pluginId: "com.example.sync",
|
|
kind: "account_baseline",
|
|
key: "account",
|
|
value: { id: "acct" },
|
|
updatedAt: 10,
|
|
});
|
|
assert.equal(
|
|
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
|
|
"dark",
|
|
);
|
|
});
|
|
|
|
test("collectForSync excludes secrets and preserves missing-plugin baselines", (context) => {
|
|
const database = tempDb(context);
|
|
database.setSyncSidecar("com.missing.plugin", "crdt_baseline", "replica", { clock: 2 }, 5);
|
|
database.setSetting("com.example.sync", "com.example.sync.theme", "application", "application", "dark");
|
|
database.setSetting("com.example.sync", "com.example.sync.token", "application", "application", "secret");
|
|
|
|
const contributionService = {
|
|
snapshot() {
|
|
return {
|
|
plugins: [{
|
|
id: "com.example.sync",
|
|
settings: [
|
|
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
|
|
{ id: "com.example.sync.token", secret: true, sync: true, scope: "application" },
|
|
],
|
|
}],
|
|
};
|
|
},
|
|
};
|
|
|
|
const service = new PluginSyncSidecarService({ database, contributionService });
|
|
const bundle = service.collectForSync();
|
|
assert.equal(bundle.version, 1);
|
|
assert.ok(bundle.entries.some((entry) => entry.kind === "crdt_baseline" && entry.pluginId === "com.missing.plugin"));
|
|
assert.ok(bundle.entries.some((entry) => entry.kind === "settings" && entry.value === "dark"));
|
|
assert.equal(bundle.entries.some((entry) => entry.value === "secret"), false);
|
|
// Collected settings must land in the non-cascade table for post-uninstall collect.
|
|
const persisted = database.listAllSyncSidecars();
|
|
assert.ok(persisted.some((entry) => entry.kind === "settings" && entry.value === "dark"));
|
|
assert.ok(persisted.some((entry) => entry.kind === "crdt_baseline"));
|
|
});
|
|
|
|
test("collectForSync re-emits retained sidecars after plugin reinstall without writing settings", (context) => {
|
|
const database = tempDb(context);
|
|
// Sidecar applied while plugin was missing (no plugin_settings row).
|
|
database.setSyncSidecar(
|
|
"com.example.sync",
|
|
"settings",
|
|
"com.example.sync.theme\0application\0application",
|
|
"from-cloud",
|
|
9,
|
|
);
|
|
const service = new PluginSyncSidecarService({
|
|
database,
|
|
contributionService: {
|
|
snapshot() {
|
|
return {
|
|
plugins: [{
|
|
id: "com.example.sync",
|
|
settings: [
|
|
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
|
|
],
|
|
}],
|
|
};
|
|
},
|
|
},
|
|
});
|
|
const bundle = service.collectForSync();
|
|
assert.ok(bundle.entries.some((e) => e.value === "from-cloud"));
|
|
// Collect must not bypass schema validation by writing plugin_settings.
|
|
assert.equal(
|
|
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
test("collectForSync re-emits settings after plugin uninstall via non-cascade table", (context) => {
|
|
const database = tempDb(context);
|
|
database.installVersion({
|
|
pluginId: "com.example.sync",
|
|
version: "1.0.0",
|
|
manifest: { id: "com.example.sync", version: "1.0.0" },
|
|
archiveSha256: "b".repeat(64),
|
|
packageRelativePath: "packages/com.example.sync/1.0.0",
|
|
}, { enable: true });
|
|
database.setSetting("com.example.sync", "com.example.sync.theme", "application", "application", "dark");
|
|
|
|
const contributionService = {
|
|
snapshot() {
|
|
return {
|
|
plugins: [{
|
|
id: "com.example.sync",
|
|
settings: [
|
|
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
|
|
],
|
|
}],
|
|
};
|
|
},
|
|
};
|
|
const service = new PluginSyncSidecarService({ database, contributionService });
|
|
service.collectForSync();
|
|
|
|
database.removePlugin("com.example.sync");
|
|
const missingService = new PluginSyncSidecarService({
|
|
database,
|
|
contributionService: { snapshot: () => ({ plugins: [] }) },
|
|
});
|
|
const afterUninstall = missingService.collectForSync();
|
|
assert.ok(
|
|
afterUninstall.entries.some((entry) => entry.kind === "settings" && entry.value === "dark"),
|
|
"settings sidecar must survive uninstall via non-cascade persistence",
|
|
);
|
|
});
|
|
|
|
test("collectForSync omits deleted settings for installed plugins but keeps missing-plugin rows", (context) => {
|
|
const database = tempDb(context);
|
|
// User reset clears both plugin_settings and the matching sidecar row.
|
|
database.setSyncSidecar(
|
|
"com.example.sync",
|
|
"settings",
|
|
"com.example.sync.theme\0application\0application",
|
|
"stale",
|
|
5,
|
|
);
|
|
database.deleteSyncSidecar(
|
|
"com.example.sync",
|
|
"settings",
|
|
"com.example.sync.theme\0application\0application",
|
|
);
|
|
database.setSyncSidecar("com.missing.plugin", "settings", "com.missing.plugin.x\0application\0application", "keep", 5);
|
|
// Only store a different setting for the installed plugin.
|
|
database.setSetting("com.example.sync", "com.example.sync.other", "application", "application", "ok");
|
|
|
|
const service = new PluginSyncSidecarService({
|
|
database,
|
|
contributionService: {
|
|
snapshot() {
|
|
return {
|
|
plugins: [{
|
|
id: "com.example.sync",
|
|
settings: [
|
|
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
|
|
{ id: "com.example.sync.other", secret: false, sync: true, scope: "application" },
|
|
],
|
|
}],
|
|
};
|
|
},
|
|
},
|
|
});
|
|
const bundle = service.collectForSync();
|
|
assert.equal(bundle.entries.some((e) => e.value === "stale"), false);
|
|
assert.ok(bundle.entries.some((e) => e.value === "ok"));
|
|
assert.ok(bundle.entries.some((e) => e.pluginId === "com.missing.plugin" && e.value === "keep"));
|
|
});
|
|
|
|
test("applyFromSync preserves remote updatedAt when writing settings", async (context) => {
|
|
const database = tempDb(context);
|
|
const service = new PluginSyncSidecarService({
|
|
database,
|
|
contributionService: {
|
|
snapshot() {
|
|
return {
|
|
plugins: [{
|
|
id: "com.example.sync",
|
|
settings: [
|
|
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
|
|
],
|
|
}],
|
|
};
|
|
},
|
|
},
|
|
});
|
|
await service.applyFromSync({
|
|
version: 1,
|
|
entries: [{
|
|
pluginId: "com.example.sync",
|
|
kind: "settings",
|
|
key: "com.example.sync.theme\0application\0application",
|
|
value: "dark",
|
|
updatedAt: 42,
|
|
}],
|
|
});
|
|
const rows = database.listAllSettings();
|
|
const theme = rows.find((r) => r.settingId === "com.example.sync.theme");
|
|
assert.equal(theme?.value, "dark");
|
|
assert.equal(theme?.updatedAt, 42);
|
|
});
|
|
|
|
test("applyFromSync does not drop local baselines for plugins absent remotely", async (context) => {
|
|
const database = tempDb(context);
|
|
database.setSyncSidecar("com.local.only", "account_baseline", "account", { id: "local" }, 1);
|
|
|
|
const service = new PluginSyncSidecarService({
|
|
database,
|
|
contributionService: { snapshot: () => ({ plugins: [] }) },
|
|
});
|
|
await service.applyFromSync({
|
|
version: 1,
|
|
entries: [{
|
|
pluginId: "com.remote.plugin",
|
|
kind: "settings",
|
|
key: "com.remote.plugin.theme\0application\0application",
|
|
value: "light",
|
|
updatedAt: 2,
|
|
}],
|
|
});
|
|
|
|
const all = database.listAllSyncSidecars();
|
|
assert.ok(all.some((entry) => entry.pluginId === "com.local.only"));
|
|
assert.ok(all.some((entry) => entry.pluginId === "com.remote.plugin" && entry.value === "light"));
|
|
});
|
|
|
|
test("hydrateInstalledPluginSettings applies retained sidecars through contribution service", async (context) => {
|
|
const database = tempDb(context);
|
|
database.setSyncSidecar(
|
|
"com.example.sync",
|
|
"settings",
|
|
"com.example.sync.theme\0application\0application",
|
|
"from-cloud",
|
|
9,
|
|
);
|
|
const updates = [];
|
|
const service = new PluginSyncSidecarService({
|
|
database,
|
|
contributionService: {
|
|
snapshot() {
|
|
return {
|
|
plugins: [{
|
|
id: "com.example.sync",
|
|
settings: [
|
|
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
|
|
],
|
|
}],
|
|
};
|
|
},
|
|
async updateSetting(pluginId, settingId, value, scopeId) {
|
|
updates.push({ pluginId, settingId, value, scopeId });
|
|
},
|
|
},
|
|
});
|
|
await service.hydrateInstalledPluginSettings("com.example.sync");
|
|
assert.equal(updates.length, 1);
|
|
assert.equal(updates[0].value, "from-cloud");
|
|
assert.equal(
|
|
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
|
|
"from-cloud",
|
|
);
|
|
});
|
|
|
|
test("hydrateInstalledPluginSettings skips sidecars older than local settings", async (context) => {
|
|
const database = tempDb(context);
|
|
database.setSetting(
|
|
"com.example.sync",
|
|
"com.example.sync.theme",
|
|
"application",
|
|
"application",
|
|
"local-newer",
|
|
20,
|
|
);
|
|
database.setSyncSidecar(
|
|
"com.example.sync",
|
|
"settings",
|
|
"com.example.sync.theme\0application\0application",
|
|
"from-cloud-stale",
|
|
9,
|
|
);
|
|
const updates = [];
|
|
const service = new PluginSyncSidecarService({
|
|
database,
|
|
contributionService: {
|
|
snapshot() {
|
|
return {
|
|
plugins: [{
|
|
id: "com.example.sync",
|
|
settings: [
|
|
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
|
|
],
|
|
}],
|
|
};
|
|
},
|
|
async updateSetting(pluginId, settingId, value, scopeId) {
|
|
updates.push({ pluginId, settingId, value, scopeId });
|
|
},
|
|
},
|
|
});
|
|
await service.hydrateInstalledPluginSettings("com.example.sync");
|
|
assert.equal(updates.length, 0);
|
|
assert.equal(
|
|
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
|
|
"local-newer",
|
|
);
|
|
});
|
|
|
|
test("applyFromSync empty remote clears installed settings without sidecar rows", async (context) => {
|
|
const database = tempDb(context);
|
|
// Pre-sidecar migration residue: setting exists, sidecar table empty.
|
|
database.setSetting(
|
|
"com.example.sync",
|
|
"com.example.sync.theme",
|
|
"application",
|
|
"application",
|
|
"stale-local",
|
|
3,
|
|
);
|
|
const resets = [];
|
|
const service = new PluginSyncSidecarService({
|
|
database,
|
|
contributionService: {
|
|
snapshot() {
|
|
return {
|
|
plugins: [{
|
|
id: "com.example.sync",
|
|
settings: [
|
|
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
|
|
],
|
|
}],
|
|
};
|
|
},
|
|
async resetSetting(pluginId, settingId, scopeId) {
|
|
resets.push({ pluginId, settingId, scopeId });
|
|
database.deleteSetting(pluginId, settingId, "application", scopeId);
|
|
},
|
|
},
|
|
});
|
|
await service.applyFromSync({ version: 1, entries: [] });
|
|
assert.equal(resets.length, 1);
|
|
assert.equal(
|
|
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
test("applyFromSync deletes stale-scope settings under stored coordinates", async (context) => {
|
|
const database = tempDb(context);
|
|
// Sidecar/key still encodes application scope, but declaration moved to device.
|
|
database.setSetting(
|
|
"com.example.sync",
|
|
"com.example.sync.theme",
|
|
"application",
|
|
"application",
|
|
"stale-scope",
|
|
3,
|
|
);
|
|
database.setSyncSidecar(
|
|
"com.example.sync",
|
|
"settings",
|
|
"com.example.sync.theme\0application\0application",
|
|
"stale-scope",
|
|
3,
|
|
);
|
|
const resets = [];
|
|
const service = new PluginSyncSidecarService({
|
|
database,
|
|
contributionService: {
|
|
snapshot() {
|
|
return {
|
|
plugins: [{
|
|
id: "com.example.sync",
|
|
settings: [
|
|
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "device" },
|
|
],
|
|
}],
|
|
};
|
|
},
|
|
async resetSetting(pluginId, settingId, scopeId) {
|
|
// Simulates normalizeScopeId against the *current* device scope.
|
|
resets.push({ pluginId, settingId, scopeId });
|
|
database.deleteSetting(pluginId, settingId, "device", scopeId === "application" ? "device" : scopeId);
|
|
},
|
|
},
|
|
});
|
|
await service.applyFromSync({ version: 1, entries: [] });
|
|
assert.equal(
|
|
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
|
|
undefined,
|
|
"stale application-scope row must be deleted by stored-coordinate fallback",
|
|
);
|
|
});
|
|
|
|
test("applyFromSync empty remote bundle wipes missing-plugin retained rows", async (context) => {
|
|
const database = tempDb(context);
|
|
database.setSyncSidecar("com.missing.plugin", "settings", "com.missing.plugin.x\0application\0application", "old", 1);
|
|
const service = new PluginSyncSidecarService({
|
|
database,
|
|
contributionService: { snapshot: () => ({ plugins: [] }) },
|
|
});
|
|
await service.applyFromSync({ version: 1, entries: [] });
|
|
assert.equal(database.listAllSyncSidecars().length, 0);
|
|
});
|
|
|
|
test("applyFromSync does not resurrect missing-plugin keys deleted on another device", async (context) => {
|
|
const database = tempDb(context);
|
|
// Local still has a setting that remote deleted, plus we keep another remote key.
|
|
database.setSyncSidecar(
|
|
"com.missing.plugin",
|
|
"settings",
|
|
"com.missing.plugin.deleted\0application\0application",
|
|
"stale",
|
|
1,
|
|
);
|
|
database.setSyncSidecar(
|
|
"com.missing.plugin",
|
|
"settings",
|
|
"com.missing.plugin.kept\0application\0application",
|
|
"local-old",
|
|
1,
|
|
);
|
|
const service = new PluginSyncSidecarService({
|
|
database,
|
|
contributionService: { snapshot: () => ({ plugins: [] }) },
|
|
});
|
|
await service.applyFromSync({
|
|
version: 1,
|
|
entries: [{
|
|
pluginId: "com.missing.plugin",
|
|
kind: "settings",
|
|
key: "com.missing.plugin.kept\0application\0application",
|
|
value: "remote-new",
|
|
updatedAt: 5,
|
|
}],
|
|
});
|
|
const all = database.listAllSyncSidecars();
|
|
assert.equal(
|
|
all.some((entry) => String(entry.key).includes("deleted")),
|
|
false,
|
|
"must not resurrect remote-deleted missing-plugin setting",
|
|
);
|
|
assert.ok(all.some((entry) => entry.value === "remote-new"));
|
|
});
|
|
|
|
test("applyFromSync empty remote bundle preserves local baselines", async (context) => {
|
|
const database = tempDb(context);
|
|
database.setSyncSidecar("com.missing.plugin", "settings", "com.missing.plugin.x\0application\0application", "old", 1);
|
|
database.setSyncSidecar("com.missing.plugin", "account_baseline", "account", { id: "a1" }, 2);
|
|
database.setSyncSidecar("com.missing.plugin", "crdt_baseline", "crdt", { clock: 9 }, 3);
|
|
const service = new PluginSyncSidecarService({
|
|
database,
|
|
contributionService: { snapshot: () => ({ plugins: [] }) },
|
|
});
|
|
await service.applyFromSync({ version: 1, entries: [] });
|
|
const all = database.listAllSyncSidecars();
|
|
assert.equal(all.some((entry) => entry.kind === "settings"), false);
|
|
assert.equal(all.some((entry) => entry.kind === "account_baseline"), true);
|
|
assert.equal(all.some((entry) => entry.kind === "crdt_baseline"), true);
|
|
});
|
|
|
|
test("applyFromSync keeps newer local baseline over older remote (LWW)", async (context) => {
|
|
const database = tempDb(context);
|
|
database.setSyncSidecar("com.example.plugin", "crdt_baseline", "crdt", { clock: 100 }, 100);
|
|
const service = new PluginSyncSidecarService({
|
|
database,
|
|
contributionService: {
|
|
snapshot: () => ({
|
|
plugins: [{
|
|
id: "com.example.plugin",
|
|
settings: [],
|
|
}],
|
|
}),
|
|
},
|
|
});
|
|
await service.applyFromSync({
|
|
version: 1,
|
|
entries: [{
|
|
pluginId: "com.example.plugin",
|
|
kind: "crdt_baseline",
|
|
key: "crdt",
|
|
value: { clock: 50 },
|
|
updatedAt: 50,
|
|
}],
|
|
});
|
|
const row = database.listAllSyncSidecars().find((entry) => entry.kind === "crdt_baseline");
|
|
assert.ok(row);
|
|
assert.equal(row.updatedAt, 100);
|
|
assert.deepEqual(row.value, { clock: 100 });
|
|
});
|