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";