Redesign Zed Gist sync workflow

This commit is contained in:
2026-07-30 12:53:31 +08:00
parent f19466a5df
commit 4263d6a25d
14 changed files with 1490 additions and 792 deletions
+388
View File
@@ -0,0 +1,388 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { chmod, cp, mkdir, mkdtemp, readFile, readdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const repository = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const syncScript = join(repository, "scripts", "zed-settings-sync.mjs");
async function makeFixture() {
const root = await mkdtemp(join(tmpdir(), "zed-sync-test-"));
const config = join(root, "zed");
const bin = join(root, "bin");
await mkdir(join(config, "scripts"), { recursive: true });
await mkdir(bin, { recursive: true });
for (const name of ["settings.json", "keymap.json", "tasks.json"]) {
await cp(join(repository, name), join(config, name));
}
for (const name of ["zed-settings-sync.mjs", "toggle-file-scan-exclusions.mjs", "file-exclusions.json", "settings-sync.json"]) {
await cp(join(repository, "scripts", name), join(config, "scripts", name));
}
const fakeGh = join(root, "fake-gh.mjs");
await writeFile(fakeGh, `
import { readFileSync, writeFileSync } from "node:fs";
const args = process.argv.slice(2);
const statePath = process.env.FAKE_GH_STATE;
const state = JSON.parse(readFileSync(statePath, "utf8"));
const save = () => writeFileSync(statePath, JSON.stringify(state), "utf8");
if (args[0] === "auth" && ["login", "refresh"].includes(args[1])) process.exit(0);
if (args[0] !== "api") process.exit(2);
if (args.includes("user")) {
if (process.env.FAKE_GH_REJECT_TOKEN === "1" && process.env.GH_TOKEN) {
process.stderr.write("bad token");
process.exit(1);
}
process.stdout.write("test-user\\n");
process.exit(0);
}
if (args.includes("gists?per_page=1")) {
process.exit(0);
}
if (args.includes("gists?per_page=100")) {
process.stdout.write(JSON.stringify([[state.gist].filter(Boolean)]));
process.exit(0);
}
const methodIndex = args.indexOf("--method");
const method = methodIndex >= 0 ? args[methodIndex + 1] : "GET";
if (process.env.FAKE_GH_NETWORK_FAIL === "1") {
process.stderr.write("network unavailable");
process.exit(1);
}
if (method === "POST") {
const payload = JSON.parse(readFileSync(0, "utf8"));
state.gist = { id: "abcde12345", description: payload.description, public: false, html_url: "https://gist.github.com/abcde12345", updated_at: new Date().toISOString(), files: payload.files };
save();
process.stdout.write(JSON.stringify(state.gist));
process.exit(0);
}
const endpoint = args.find((arg) => arg.startsWith("gists/"));
if (endpoint && method === "PATCH") {
const payload = JSON.parse(readFileSync(0, "utf8"));
state.gist.files = { ...state.gist.files, ...payload.files };
state.gist.updated_at = new Date().toISOString();
state.patches = (state.patches || 0) + 1;
save();
process.stdout.write(JSON.stringify(state.gist));
process.exit(0);
}
if (endpoint && state.gist) {
process.stdout.write(JSON.stringify(state.gist));
process.exit(0);
}
process.stderr.write("not found");
process.exit(1);
`, "utf8");
if (process.platform === "win32") {
await writeFile(join(bin, "gh.cmd"), `@echo off\r\n"${process.execPath}" "${fakeGh}" %*\r\n`, "utf8");
} else {
const shim = join(bin, "gh");
await writeFile(shim, `#!/bin/sh\nexec "${process.execPath}" "${fakeGh}" "$@"\n`, "utf8");
await chmod(shim, 0o755);
}
const ghState = join(root, "gh-state.json");
await writeFile(ghState, JSON.stringify({ gist: null }), "utf8");
return { root, config, bin, fakeGh, ghState };
}
function environment(fixture, extra = {}) {
const env = {
...process.env,
PATH: `${fixture.bin}${process.platform === "win32" ? ";" : ":"}${process.env.PATH}`,
ZED_CONFIG_DIR: fixture.config,
ZED_SYNC_GH_SCRIPT: fixture.fakeGh,
FAKE_GH_STATE: fixture.ghState,
ZED_SYNC_NO_DIALOGS: "1",
ZED_SYNC_NO_NOTIFICATIONS: "1",
...extra,
};
delete env.GH_TOKEN;
delete env.GITHUB_TOKEN;
if (extra.GH_TOKEN) env.GH_TOKEN = extra.GH_TOKEN;
return env;
}
function run(fixture, args, extra = {}) {
return spawnSync(process.execPath, [syncScript, ...args], {
cwd: repository,
env: environment(fixture, extra),
encoding: "utf8",
});
}
function parseJsonc(source) {
let output = "";
let inString = false;
let escaped = false;
let lineComment = false;
let blockComment = false;
for (let index = 0; index < source.length; index += 1) {
const character = source[index];
const next = source[index + 1];
if (lineComment) {
if (character === "\n") {
lineComment = false;
output += character;
} else output += " ";
continue;
}
if (blockComment) {
if (character === "*" && next === "/") {
blockComment = false;
output += " ";
index += 1;
} else output += character === "\n" ? "\n" : " ";
continue;
}
if (inString) {
output += character;
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === '"') inString = false;
continue;
}
if (character === '"') {
inString = true;
output += character;
} else if (character === "/" && next === "/") {
lineComment = true;
output += " ";
index += 1;
} else if (character === "/" && next === "*") {
blockComment = true;
output += " ";
index += 1;
} else {
output += character;
}
}
return JSON.parse(output.replace(/,(\s*[}\]])/g, "$1"));
}
test("Zed JSONC files parse and expose the new sync Tasks", async () => {
const settings = parseJsonc(await readFile(join(repository, "settings.json"), "utf8"));
const keymap = parseJsonc(await readFile(join(repository, "keymap.json"), "utf8"));
const tasks = parseJsonc(await readFile(join(repository, "tasks.json"), "utf8"));
assert.equal(typeof settings.base_keymap, "string");
assert.equal(Array.isArray(keymap), true);
const labels = tasks.map((task) => task.label);
for (const label of [
"Zed Settings: Set Up / Reconfigure Sync...",
"Zed Settings: Show Sync Status",
"Zed Settings: Push Now",
"Zed Settings: Pull Now",
"Zed Settings: Enable Automatic Sync",
"Zed Settings: Disable Automatic Sync",
]) {
assert.equal(labels.includes(label), true, `Missing Task: ${label}`);
}
});
test("export remains a flat JSON bundle without local state or hash metadata", async () => {
const fixture = await makeFixture();
const output = join(fixture.root, "bundle.json");
const result = run(fixture, ["export", output]);
assert.equal(result.status, 0, result.stderr);
const bundle = JSON.parse(await readFile(output, "utf8"));
assert.equal(typeof bundle["settings.json"], "string");
assert.equal(typeof bundle["keymap.json"], "string");
assert.equal(bundle.files, undefined);
assert.equal(bundle.hash, undefined);
assert.equal(bundle["scripts/settings-sync.json"], undefined);
});
test("setup creates a Secret Gist and stores only machine-local sync state", async () => {
const fixture = await makeFixture();
const result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["n", "n"]) });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Created Secret Gist/);
const state = JSON.parse(await readFile(join(fixture.config, "scripts", "settings-sync.json"), "utf8"));
assert.equal(state.gist_id, "abcde12345");
assert.equal(state.auto_sync, false);
assert.equal(state.last_synced_hash.length, 64);
const remote = JSON.parse(await readFile(fixture.ghState, "utf8"));
assert.equal(remote.gist.public, false);
});
test("setup initializes a configured Gist that is missing the settings file", async () => {
const fixture = await makeFixture();
const gist = {
id: "missing1234",
description: "Empty sync Gist",
public: false,
html_url: "https://gist.github.com/missing1234",
updated_at: new Date().toISOString(),
files: { "notes.txt": { content: "empty" } },
};
await writeFile(fixture.ghState, JSON.stringify({ gist }), "utf8");
const statePath = join(fixture.config, "scripts", "settings-sync.json");
const state = JSON.parse(await readFile(statePath, "utf8"));
state.gist_id = gist.id;
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
const result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["1", "y", "n"]) });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /does not contain zed-settings-sync.json/);
const remote = JSON.parse(await readFile(fixture.ghState, "utf8"));
assert.equal(typeof remote.gist.files[BUNDLE_NAME].content, "string");
const updated = JSON.parse(await readFile(statePath, "utf8"));
assert.equal(updated.gist_id, gist.id);
assert.equal(updated.last_synced_hash.length, 64);
});
test("an invalid inherited token falls back to stored gh credentials", async () => {
const fixture = await makeFixture();
let result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["n", "n"]) });
assert.equal(result.status, 0, result.stderr);
result = run(fixture, ["status"], { GH_TOKEN: "invalid", FAKE_GH_REJECT_TOKEN: "1" });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /inherited GitHub token is invalid/);
assert.match(result.stdout, /Status: synchronized/);
});
test("automatic sync uploads one-sided local changes and stops on conflicts", async () => {
const fixture = await makeFixture();
let result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["n", "n"]) });
assert.equal(result.status, 0, result.stderr);
const statePath = join(fixture.config, "scripts", "settings-sync.json");
const state = JSON.parse(await readFile(statePath, "utf8"));
state.auto_sync = true;
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
await writeFile(join(fixture.config, "settings.json"), `${await readFile(join(fixture.config, "settings.json"), "utf8")}\n`, "utf8");
result = run(fixture, ["auto-sync"]);
assert.equal(result.status, 0, result.stderr);
let remote = JSON.parse(await readFile(fixture.ghState, "utf8"));
assert.equal(remote.patches, 1);
const baseline = JSON.parse(await readFile(statePath, "utf8")).last_synced_hash;
await writeFile(join(fixture.config, "keymap.json"), `${await readFile(join(fixture.config, "keymap.json"), "utf8")}\n`, "utf8");
const remoteBundle = JSON.parse(remote.gist.files[BUNDLE_NAME].content);
remoteBundle["settings.json"] += "\n// remote change\n";
remote.gist.files[BUNDLE_NAME] = { content: `${JSON.stringify(remoteBundle, null, 2)}\n` };
await writeFile(fixture.ghState, JSON.stringify(remote), "utf8");
result = run(fixture, ["auto-sync"]);
assert.equal(result.status, 0, result.stderr);
const conflicted = JSON.parse(await readFile(statePath, "utf8"));
assert.equal(conflicted.last_synced_hash, baseline);
assert.match(conflicted.last_result, /conflict/);
remote = JSON.parse(await readFile(fixture.ghState, "utf8"));
assert.equal(remote.patches, 1);
});
test("automatic sync downloads one-sided remote changes with a backup", async () => {
const fixture = await makeFixture();
let result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["n", "n"]) });
assert.equal(result.status, 0, result.stderr);
const statePath = join(fixture.config, "scripts", "settings-sync.json");
const state = JSON.parse(await readFile(statePath, "utf8"));
state.auto_sync = true;
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
const remote = JSON.parse(await readFile(fixture.ghState, "utf8"));
const remoteBundle = JSON.parse(remote.gist.files[BUNDLE_NAME].content);
remoteBundle["settings.json"] += "\n// downloaded remote change\n";
remote.gist.files[BUNDLE_NAME].content = `${JSON.stringify(remoteBundle, null, 2)}\n`;
await writeFile(fixture.ghState, JSON.stringify(remote), "utf8");
result = run(fixture, ["auto-sync"]);
assert.equal(result.status, 0, result.stderr);
assert.match(await readFile(join(fixture.config, "settings.json"), "utf8"), /downloaded remote change/);
assert.match(await readFile(statePath, "utf8"), /downloaded remote changes/);
assert.equal((await readdir(join(fixture.config, "backups"))).length, 1);
});
test("automatic sync does not overwrite without a baseline and records network errors", async () => {
const fixture = await makeFixture();
let result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["n", "n"]) });
assert.equal(result.status, 0, result.stderr);
const statePath = join(fixture.config, "scripts", "settings-sync.json");
const state = JSON.parse(await readFile(statePath, "utf8"));
state.auto_sync = true;
state.last_synced_hash = "";
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
await writeFile(join(fixture.config, "settings.json"), `${await readFile(join(fixture.config, "settings.json"), "utf8")}\n`, "utf8");
result = run(fixture, ["auto-sync"]);
assert.equal(result.status, 0, result.stderr);
let updated = JSON.parse(await readFile(statePath, "utf8"));
assert.match(updated.last_result, /no-baseline/);
let remote = JSON.parse(await readFile(fixture.ghState, "utf8"));
assert.equal(remote.patches, undefined);
result = run(fixture, ["auto-sync"], { FAKE_GH_NETWORK_FAIL: "1" });
assert.equal(result.status, 1);
updated = JSON.parse(await readFile(statePath, "utf8"));
assert.match(updated.last_result, /network unavailable/);
remote = JSON.parse(await readFile(fixture.ghState, "utf8"));
assert.equal(remote.patches, undefined);
});
test("scheduler generation supports Windows, macOS, and Linux without side effects", async () => {
for (const platform of ["win32", "darwin", "linux"]) {
const fixture = await makeFixture();
let result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["n", "n"]) });
assert.equal(result.status, 0, result.stderr);
result = run(fixture, ["auto-enable"], {
ZED_SYNC_PLATFORM: platform,
ZED_SYNC_SCHEDULER_DRY_RUN: "1",
});
assert.equal(result.status, 0, `${platform}: ${result.stderr}`);
assert.match(result.stdout, platform === "win32" ? /schtasks/ : platform === "darwin" ? /launchctl/ : /systemctl/);
}
});
test("Windows bootstrap installs without gh, authentication, or a Gist", { skip: process.platform !== "win32" }, async () => {
const fixture = await makeFixture();
const bundle = join(fixture.root, "bundle.json");
let result = run(fixture, ["export", bundle]);
assert.equal(result.status, 0, result.stderr);
const appData = join(fixture.root, "appdata");
await mkdir(join(appData, "Zed", "scripts"), { recursive: true });
await writeFile(join(appData, "Zed", "scripts", "settings-sync.json"), JSON.stringify({
gist_id: "legacy1234",
last_synced_hash: "abc",
}), "utf8");
const powershell = spawnSync("powershell.exe", ["-NoProfile", "-File", join(repository, "bootstrap.ps1")], {
cwd: repository,
encoding: "utf8",
env: {
...process.env,
APPDATA: appData,
ZED_SETTINGS_BUNDLE_PATH: bundle,
ZED_SYNC_SKIP_SETUP: "1",
},
});
assert.equal(powershell.status, 0, powershell.stderr);
assert.equal(existsSync(join(appData, "Zed", "settings.json")), true);
const installedState = JSON.parse(await readFile(join(appData, "Zed", "scripts", "settings-sync.json"), "utf8"));
assert.equal(installedState.gist_id, "legacy1234");
assert.equal(installedState.last_synced_hash, "abc");
assert.equal(installedState.auto_sync, false);
assert.equal(installedState.interval_minutes, 15);
});
test("Windows repository installer migrates old local state without losing Gist identity", { skip: process.platform !== "win32" }, async () => {
const fixture = await makeFixture();
const appData = join(fixture.root, "appdata-install");
const statePath = join(appData, "Zed", "scripts", "settings-sync.json");
await mkdir(dirname(statePath), { recursive: true });
await writeFile(statePath, JSON.stringify({ gist_id: "keep56789", last_synced_hash: "def" }), "utf8");
const powershell = spawnSync("powershell.exe", ["-NoProfile", "-File", join(repository, "install.ps1")], {
cwd: repository,
encoding: "utf8",
env: { ...process.env, APPDATA: appData },
});
assert.equal(powershell.status, 0, powershell.stderr);
const state = JSON.parse(await readFile(statePath, "utf8"));
assert.equal(state.gist_id, "keep56789");
assert.equal(state.last_synced_hash, "def");
assert.equal(state.auto_sync, false);
assert.equal(state.interval_minutes, 15);
});
const BUNDLE_NAME = "zed-settings-sync.json";