feat: 发布热更 1.5.94,修复中转历史会话并支持一键清空
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const globalDictation = require("../electron/lib/global_dictation");
|
||||
|
||||
function fixture(state = {}) {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "td-global-dictation-"));
|
||||
const file = path.join(home, ".codex-global-state.json");
|
||||
fs.writeFileSync(file, JSON.stringify(state, null, 2) + "\n");
|
||||
return { home, file };
|
||||
}
|
||||
|
||||
test("disables persistent dictation bar without changing unrelated state", { concurrency: false }, () => {
|
||||
const fx = fixture({
|
||||
globalDictationHotkey: "LeftCommand",
|
||||
"global-dictation-keep-visible": true,
|
||||
"project-order": ["/work/a"],
|
||||
nested: { enabled: true },
|
||||
});
|
||||
|
||||
try {
|
||||
const result = globalDictation.disableKeepVisible({ codexHome: fx.home });
|
||||
const saved = JSON.parse(fs.readFileSync(fx.file, "utf8"));
|
||||
const backup = JSON.parse(fs.readFileSync(fx.file + ".td-before-global-dictation.bak", "utf8"));
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(saved["global-dictation-keep-visible"], false);
|
||||
assert.equal(saved.globalDictationHotkey, "LeftCommand");
|
||||
assert.deepEqual(saved["project-order"], ["/work/a"]);
|
||||
assert.deepEqual(saved.nested, { enabled: true });
|
||||
assert.equal(backup["global-dictation-keep-visible"], true);
|
||||
assert.equal(fs.readdirSync(fx.home).some((name) => name.endsWith(".td-dictation.tmp")), false);
|
||||
} finally {
|
||||
fs.rmSync(fx.home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("changes only the keep-visible token instead of rewriting the state file", { concurrency: false }, () => {
|
||||
const fx = fixture({
|
||||
before: "保留",
|
||||
"global-dictation-keep-visible": true,
|
||||
after: { nested: 1 },
|
||||
});
|
||||
|
||||
try {
|
||||
const original = fs.readFileSync(fx.file);
|
||||
const tokenOffset = original.indexOf(Buffer.from(" true"));
|
||||
const result = globalDictation.disableKeepVisible({ codexHome: fx.home });
|
||||
const saved = fs.readFileSync(fx.file);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(saved.subarray(0, tokenOffset).equals(original.subarray(0, tokenOffset)), true);
|
||||
assert.equal(saved.subarray(tokenOffset, tokenOffset + 5).toString("ascii"), "false");
|
||||
assert.equal(saved.subarray(tokenOffset + 5).equals(original.subarray(tokenOffset + 5)), true);
|
||||
} finally {
|
||||
fs.rmSync(fx.home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("atomically rewrites minified state while preserving unrelated state", { concurrency: false }, () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "td-global-dictation-minified-"));
|
||||
const file = path.join(home, ".codex-global-state.json");
|
||||
const original = Buffer.from('{"global-dictation-keep-visible":true,"untouched":1,"nested":{"enabled":true}}');
|
||||
fs.writeFileSync(file, original);
|
||||
|
||||
try {
|
||||
const result = globalDictation.disableKeepVisible({ codexHome: home });
|
||||
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.reason, "disabled-atomic");
|
||||
assert.equal(saved["global-dictation-keep-visible"], false);
|
||||
assert.equal(saved.untouched, 1);
|
||||
assert.deepEqual(saved.nested, { enabled: true });
|
||||
assert.equal(fs.readFileSync(file).equals(original), false);
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("persistent lock prevents an unsafe concurrent write", { concurrency: false }, () => {
|
||||
const fx = fixture({ "global-dictation-keep-visible": true, untouched: 1 });
|
||||
const lock = fx.file + ".td-dictation.lock";
|
||||
fs.writeFileSync(lock, "busy");
|
||||
|
||||
try {
|
||||
const result = globalDictation.disableKeepVisible({ codexHome: fx.home, lockWaitMs: 0 });
|
||||
const saved = JSON.parse(fs.readFileSync(fx.file, "utf8"));
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, "locked");
|
||||
assert.equal(saved["global-dictation-keep-visible"], true);
|
||||
assert.equal(saved.untouched, 1);
|
||||
} finally {
|
||||
fs.rmSync(fx.home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("creates an explicit disabled state when the state file is absent", { concurrency: false }, () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "td-global-dictation-missing-"));
|
||||
const file = path.join(home, ".codex-global-state.json");
|
||||
|
||||
try {
|
||||
const result = globalDictation.disableKeepVisible({ codexHome: home });
|
||||
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.reason, "created-disabled");
|
||||
assert.deepEqual(saved, { "global-dictation-keep-visible": false });
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("recognizes only the exact global dictation page route", () => {
|
||||
assert.equal(globalDictation.isGlobalDictationTarget({
|
||||
type: "page",
|
||||
url: "app://-/index.html?initialRoute=/global-dictation",
|
||||
}), true);
|
||||
assert.equal(globalDictation.isGlobalDictationTarget({
|
||||
type: "page",
|
||||
url: "app://-/index.html?initialRoute=%2Fglobal-dictation",
|
||||
}), true);
|
||||
assert.equal(globalDictation.isGlobalDictationTarget({
|
||||
type: "page",
|
||||
url: "app://-/index.html?initialRoute=global-dictation",
|
||||
}), true);
|
||||
assert.equal(globalDictation.isGlobalDictationTarget({
|
||||
type: "page",
|
||||
url: "app://-/index.html",
|
||||
}), false);
|
||||
assert.equal(globalDictation.isGlobalDictationTarget({
|
||||
type: "page",
|
||||
url: "app://-/index.html?initialRoute=/avatar-overlay",
|
||||
}), false);
|
||||
assert.equal(globalDictation.isGlobalDictationTarget({
|
||||
type: "worker",
|
||||
url: "app://-/index.html?initialRoute=/global-dictation",
|
||||
}), false);
|
||||
assert.equal(globalDictation.isGlobalDictationTarget({
|
||||
type: "page",
|
||||
url: "https://example.com/index.html?initialRoute=/global-dictation",
|
||||
}), false);
|
||||
assert.equal(globalDictation.isGlobalDictationTarget({
|
||||
type: "page",
|
||||
url: "app://other/index.html?initialRoute=/global-dictation",
|
||||
}), false);
|
||||
assert.equal(globalDictation.isGlobalDictationTarget({
|
||||
type: "page",
|
||||
url: "app://-/other.html?initialRoute=/global-dictation",
|
||||
}), false);
|
||||
assert.equal(globalDictation.isCodexMainTarget({
|
||||
type: "page",
|
||||
url: "app://-/index.html?theme=light",
|
||||
}), true);
|
||||
assert.equal(globalDictation.isCodexMainTarget({
|
||||
type: "page",
|
||||
url: "app://-/index.html?initialRoute=/avatar-overlay",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("closes only global dictation targets through CDP HTTP", async () => {
|
||||
const calls = [];
|
||||
const initialTargets = [
|
||||
{ id: "main", type: "page", url: "app://-/index.html" },
|
||||
{ id: "avatar", type: "page", url: "app://-/index.html?initialRoute=/avatar-overlay" },
|
||||
{ id: "dictation", type: "page", url: "app://-/index.html?initialRoute=%2Fglobal-dictation" },
|
||||
];
|
||||
const remainingTargets = initialTargets.filter((target) => target.id !== "dictation");
|
||||
let listCount = 0;
|
||||
const fetchImpl = async (url) => {
|
||||
calls.push(url);
|
||||
if (url.endsWith("/json")) {
|
||||
const targets = listCount++ === 0 ? initialTargets : remainingTargets;
|
||||
return { ok: true, json: async () => targets };
|
||||
}
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
const result = await globalDictation.closeGlobalDictationTargets(19222, { fetchImpl });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.available, true);
|
||||
assert.equal(result.matched, 1);
|
||||
assert.equal(result.closed, 1);
|
||||
assert.equal(result.remaining, 0);
|
||||
assert.equal(result.status, "closed");
|
||||
assert.deepEqual(calls, [
|
||||
"http://127.0.0.1:19222/json",
|
||||
"http://127.0.0.1:19222/json/close/dictation",
|
||||
"http://127.0.0.1:19222/json",
|
||||
]);
|
||||
});
|
||||
|
||||
test("CDP unavailable is reported as unavailable instead of success", async () => {
|
||||
const result = await globalDictation.closeGlobalDictationTargets(19222, {
|
||||
fetchImpl: async () => {
|
||||
throw new Error("connection refused");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.available, false);
|
||||
assert.equal(result.status, "port-unavailable");
|
||||
assert.equal(result.matched, 0);
|
||||
assert.equal(result.closed, 0);
|
||||
assert.equal(result.remaining, null);
|
||||
});
|
||||
|
||||
test("does not close a matching route on a foreign debug endpoint", async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url) => {
|
||||
calls.push(url);
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [{
|
||||
id: "foreign-dictation",
|
||||
type: "page",
|
||||
url: "app://-/index.html?initialRoute=/global-dictation",
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const result = await globalDictation.closeGlobalDictationTargets(19222, {
|
||||
fetchImpl,
|
||||
endpointOwnerVerifier: async () => ({ verified: true, owned: false, reason: "other app" }),
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.status, "foreign-endpoint");
|
||||
assert.equal(result.closed, 0);
|
||||
assert.deepEqual(calls, ["http://127.0.0.1:19222/json"]);
|
||||
});
|
||||
|
||||
test("closes an orphaned dictation target only when the port owner is Codex", async () => {
|
||||
const calls = [];
|
||||
let listCount = 0;
|
||||
const fetchImpl = async (url) => {
|
||||
calls.push(url);
|
||||
if (url.endsWith("/json")) {
|
||||
const targets = listCount++ === 0
|
||||
? [{ id: "orphan", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" }]
|
||||
: [];
|
||||
return { ok: true, json: async () => targets };
|
||||
}
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
const result = await globalDictation.closeGlobalDictationTargets(19222, {
|
||||
fetchImpl,
|
||||
endpointOwnerVerifier: async () => ({ verified: true, owned: true, reason: "Codex" }),
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.status, "closed");
|
||||
assert.equal(result.closed, 1);
|
||||
assert.deepEqual(calls, [
|
||||
"http://127.0.0.1:19222/json",
|
||||
"http://127.0.0.1:19222/json/close/orphan",
|
||||
"http://127.0.0.1:19222/json",
|
||||
]);
|
||||
});
|
||||
|
||||
test("aborts a slow orphan owner check at the shared deadline", async () => {
|
||||
let verifierSignal;
|
||||
const fetchImpl = async (url) => {
|
||||
assert.equal(url, "http://127.0.0.1:19222/json");
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [{
|
||||
id: "orphan",
|
||||
type: "page",
|
||||
url: "app://-/index.html?initialRoute=/global-dictation",
|
||||
}],
|
||||
};
|
||||
};
|
||||
const endpointOwnerVerifier = async (_port, options) => {
|
||||
verifierSignal = options.signal;
|
||||
await new Promise((resolve) => {
|
||||
if (options.signal.aborted) resolve();
|
||||
else options.signal.addEventListener("abort", resolve, { once: true });
|
||||
});
|
||||
return { verified: false, owned: false, reason: "timed out" };
|
||||
};
|
||||
|
||||
const result = await globalDictation.closeGlobalDictationTargets(19222, {
|
||||
fetchImpl,
|
||||
endpointOwnerVerifier,
|
||||
ownerTimeoutMs: 20,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.status, "unverified-endpoint");
|
||||
assert.equal(verifierSignal.aborted, true);
|
||||
});
|
||||
|
||||
test("reports when keep-visible immediately recreates the target", async () => {
|
||||
const initialTargets = [
|
||||
{ id: "main", type: "page", url: "app://-/index.html" },
|
||||
{ id: "dictation-a", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" },
|
||||
];
|
||||
const recreatedTargets = [
|
||||
initialTargets[0],
|
||||
{ id: "dictation-b", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" },
|
||||
];
|
||||
let listCount = 0;
|
||||
const fetchImpl = async (url) => {
|
||||
if (url.endsWith("/json")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => listCount++ === 0 ? initialTargets : recreatedTargets,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
const result = await globalDictation.closeGlobalDictationTargets(19222, {
|
||||
fetchImpl,
|
||||
retries: 0,
|
||||
settleMs: 0,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.status, "still-present");
|
||||
assert.equal(result.closed, 1);
|
||||
assert.equal(result.remaining, 1);
|
||||
assert.deepEqual(result.targetIds, ["dictation-b"]);
|
||||
});
|
||||
|
||||
test("retries when Codex immediately recreates the dictation target", async () => {
|
||||
const main = { id: "main", type: "page", url: "app://-/index.html" };
|
||||
const targetA = { id: "dictation-a", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" };
|
||||
const targetB = { id: "dictation-b", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" };
|
||||
const targetLists = [
|
||||
[main, targetA],
|
||||
[main, targetB],
|
||||
[main, targetB],
|
||||
[main],
|
||||
];
|
||||
let listCount = 0;
|
||||
const closed = [];
|
||||
const fetchImpl = async (url) => {
|
||||
if (url.endsWith("/json")) {
|
||||
const targets = targetLists[Math.min(listCount, targetLists.length - 1)];
|
||||
listCount += 1;
|
||||
return { ok: true, json: async () => targets };
|
||||
}
|
||||
closed.push(url.split("/").pop());
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
const result = await globalDictation.closeGlobalDictationTargets(19222, {
|
||||
fetchImpl,
|
||||
settleMs: 0,
|
||||
retryDelayMs: 0,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.status, "closed");
|
||||
assert.equal(result.closed, 2);
|
||||
assert.equal(result.attempts, 2);
|
||||
assert.deepEqual(closed, ["dictation-a", "dictation-b"]);
|
||||
});
|
||||
|
||||
test("target response body parsing cannot outlive the configured timeout", async () => {
|
||||
const result = await globalDictation.closeGlobalDictationTargets(19222, {
|
||||
timeoutMs: 20,
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
json: async () => new Promise(() => {}),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.available, false);
|
||||
assert.equal(result.status, "timeout");
|
||||
assert.equal(result.closed, 0);
|
||||
});
|
||||
|
||||
test("failed post-close verification is not reported as success", async () => {
|
||||
const initialTargets = [
|
||||
{ id: "main", type: "page", url: "app://-/index.html" },
|
||||
{ id: "dictation", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" },
|
||||
];
|
||||
let listCount = 0;
|
||||
const fetchImpl = async (url) => {
|
||||
if (url.endsWith("/json")) {
|
||||
listCount += 1;
|
||||
if (listCount > 1) throw new Error("verification endpoint disconnected");
|
||||
return { ok: true, json: async () => initialTargets };
|
||||
}
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
const result = await globalDictation.closeGlobalDictationTargets(19222, { fetchImpl });
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.status, "verification-failed");
|
||||
assert.equal(result.closed, 1);
|
||||
assert.equal(result.remaining, null);
|
||||
assert.match(result.errors.join(" "), /verification endpoint disconnected/);
|
||||
});
|
||||
|
||||
test("custom cleanup calls do not share stale targets or abort signals", async () => {
|
||||
const firstTargets = [
|
||||
{ id: "main-a", type: "page", url: "app://-/index.html" },
|
||||
{ id: "dictation-a", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" },
|
||||
];
|
||||
const secondTargets = [
|
||||
{ id: "main-b", type: "page", url: "app://-/index.html" },
|
||||
{ id: "dictation-b", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" },
|
||||
];
|
||||
const closed = [];
|
||||
const fetchImpl = async (url) => {
|
||||
if (url.includes("/json/close/")) {
|
||||
closed.push(url.split("/").pop());
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: true, json: async () => [] };
|
||||
};
|
||||
|
||||
const first = globalDictation.closeGlobalDictationTargets(19222, {
|
||||
fetchImpl,
|
||||
targets: firstTargets,
|
||||
});
|
||||
const second = globalDictation.closeGlobalDictationTargets(19222, {
|
||||
fetchImpl,
|
||||
targets: secondTargets,
|
||||
});
|
||||
|
||||
assert.notEqual(first, second);
|
||||
await Promise.all([first, second]);
|
||||
assert.deepEqual(closed.sort(), ["dictation-a", "dictation-b"]);
|
||||
});
|
||||
Reference in New Issue
Block a user