Redesign Zed Gist sync workflow
This commit is contained in:
@@ -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";
|
||||
Reference in New Issue
Block a user