chore: update EdgeEver from v2.19.0

EdgeEver-Upstream-Commit: a80d9bfda1aad846e20882813758a59fe27bf8ca
This commit is contained in:
edgeever-updater[bot] committed 2026-11-04 09:13:22 +00:00
1 parent 67cf5297de
commit 507cb892ad
69 files changed
+9768 -587

No files matched your search

+721
View File
@@ -637,3 +637,724 @@ describe("video outline", () => {
const input=videoOutlineBody();input.blocks=Array.from({length:10},(_,i)=>({start:i,text:"x".repeat(6000)}));const res=await createApp().request("/api/v1/ai/video-outline/prepare",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(input)},environment);expect(res.status).toBe(413);
});
});
const jsonRequest = (method, body) => ({
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const createProvider = async (app, databaseEnvironment, overrides = {}) => {
const response = await app.request(
"/api/v1/ai/providers",
jsonRequest("POST", { ...validSettings, ...overrides }),
databaseEnvironment,
);
expect(response.status).toBe(201);
return response.json();
};
const transcriptionSettings = {
provider: "openai-compatible",
displayName: "Speech",
baseUrl: "https://speech.example.com/v1",
apiKey: "speech-secret",
isEnabled: true,
};
const createTranscriptionProvider = async (app, databaseEnvironment, overrides = {}) => {
const response = await app.request(
"/api/v1/ai/transcription-providers",
jsonRequest("POST", { ...transcriptionSettings, ...overrides }),
databaseEnvironment,
);
expect(response.status).toBe(201);
return response.json();
};
const auditActionsOf = (sqlite) =>
sqlite.query("SELECT action FROM audit_events ORDER BY rowid").all().map((row) => row.action);
const otherWorkspaceAuth = { ...auth, actorId: "usr_other", workspaceId: "ws_other" };
const agentAuth = { ...auth, kind: "agent", actorType: "agent", scopes: ["ai:generate"] };
describe("speech transcription settings routes", () => {
test("rejects invalid transcription settings with a dedicated error code", async () => {
const response = await createApp().request(
"/api/v1/ai/transcription-providers",
jsonRequest("POST", { ...transcriptionSettings, displayName: "" }),
environment,
);
expect(response.status).toBe(400);
expect(await response.json()).toEqual({
error: {
code: "ai_transcription_request_invalid",
message: "The speech transcription settings are invalid.",
},
});
});
test("requires an interactive user to read transcription settings", async () => {
const response = await createApp({ currentAuth: agentAuth }).request(
"/api/v1/ai/transcription-settings",
{},
environment,
);
expect(response.status).toBe(403);
});
test("refuses transcription credentials and targets in demo mode", async () => {
const app = createApp({ demoMode: true });
const target = await app.request("/api/v1/memos/m/resources/r/transcription-target", { method: "POST" }, environment);
const credential = await app.request(
"/api/v1/ai/transcription-providers/atp_x/direct-credential",
{ method: "POST" },
environment,
);
expect(target.status).toBe(403);
expect(credential.status).toBe(403);
});
test("returns 404 for an unknown speech provider credential", async () => {
const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment();
const response = await createApp().request(
"/api/v1/ai/transcription-providers/atp_missing/direct-credential",
{ method: "POST" },
databaseEnvironment,
);
expect(response.status).toBe(404);
expect(await response.json()).toMatchObject({ error: { message: "Speech service not found." } });
sqlite.close();
});
test("fails closed when no credential encryption key is configured", async () => {
const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment();
const { EDGE_EVER_AUTH_PASSWORD: _password, ...withoutKey } = databaseEnvironment;
const response = await createApp().request(
"/api/v1/ai/transcription-providers",
jsonRequest("POST", transcriptionSettings),
withoutKey,
);
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({ error: { code: "ai_encryption_key_missing" } });
expect(sqlite.query("SELECT COUNT(*) AS count FROM ai_transcription_providers").get().count).toBe(0);
sqlite.close();
});
test("manages providers, models and the default model inside one workspace", async () => {
const app = createApp();
const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment();
sqlite.query("INSERT INTO workspaces (id, name, is_personal) VALUES (?, ?, 1)").run("ws_other", "Other");
const created = await createTranscriptionProvider(app, databaseEnvironment);
expect(created.providers).toHaveLength(1);
expect(created.providers[0].models).toEqual([]);
expect(created.defaultModelId).toBeNull();
expect(JSON.stringify(created)).not.toContain("speech-secret");
const providerId = created.providers[0].id;
const addModel = await app.request(
`/api/v1/ai/transcription-providers/${providerId}/models`,
jsonRequest("POST", { modelId: "whisper-1" }),
databaseEnvironment,
);
expect(addModel.status).toBe(201);
const withModel = await addModel.json();
const model = withModel.providers[0].models[0];
expect(model).toMatchObject({ modelId: "whisper-1", displayName: "whisper-1" });
// The first model of an enabled service becomes the workspace default.
expect(withModel.defaultModelId).toBe(model.id);
expect(withModel.enabled).toBe(true);
const second = await app.request(
`/api/v1/ai/transcription-providers/${providerId}/models`,
jsonRequest("POST", { modelId: "whisper-large", displayName: "Large" }),
databaseEnvironment,
);
const withTwo = await second.json();
expect(withTwo.defaultModelId).toBe(model.id);
const secondModel = withTwo.providers[0].models.find((entry) => entry.modelId === "whisper-large");
expect(secondModel.displayName).toBe("Large");
const switchDefault = await app.request(
"/api/v1/ai/transcription-default-model",
jsonRequest("PUT", { modelConfigId: secondModel.id }),
databaseEnvironment,
);
expect(switchDefault.status).toBe(200);
expect((await switchDefault.json()).defaultModelId).toBe(secondModel.id);
const clearDefault = await app.request(
"/api/v1/ai/transcription-default-model",
jsonRequest("PUT", { modelConfigId: null }),
databaseEnvironment,
);
expect(clearDefault.status).toBe(200);
expect((await clearDefault.json()).defaultModelId).toBeNull();
// Another workspace can neither see nor select these models.
const otherApp = createApp({ currentAuth: otherWorkspaceAuth });
const foreignDefault = await otherApp.request(
"/api/v1/ai/transcription-default-model",
jsonRequest("PUT", { modelConfigId: secondModel.id }),
databaseEnvironment,
);
expect(foreignDefault.status).toBe(400);
expect(await foreignDefault.json()).toMatchObject({ error: { code: "ai_transcription_default_unavailable" } });
const foreignSettings = await (await otherApp.request("/api/v1/ai/transcription-settings", {}, databaseEnvironment)).json();
expect(foreignSettings.providers).toEqual([]);
for (const [path, init] of [
[`/api/v1/ai/transcription-providers/${providerId}`, jsonRequest("PUT", transcriptionSettings)],
[`/api/v1/ai/transcription-providers/${providerId}`, { method: "DELETE" }],
[`/api/v1/ai/transcription-providers/${providerId}/models`, jsonRequest("POST", { modelId: "x" })],
[`/api/v1/ai/transcription-providers/${providerId}/models/${model.id}`, { method: "DELETE" }],
[`/api/v1/ai/transcription-providers/${providerId}/direct-credential`, { method: "POST" }],
]) {
const response = await otherApp.request(path, init, databaseEnvironment);
expect(response.status, `${init.method} ${path}`).toBe(404);
}
const wrongProvider = await app.request(
`/api/v1/ai/transcription-providers/atp_other/models/${model.id}`,
{ method: "DELETE" },
databaseEnvironment,
);
expect(wrongProvider.status).toBe(404);
expect(await wrongProvider.json()).toMatchObject({ error: { message: "Speech model not found." } });
const removeModel = await app.request(
`/api/v1/ai/transcription-providers/${providerId}/models/${model.id}`,
{ method: "DELETE" },
databaseEnvironment,
);
expect(removeModel.status).toBe(200);
expect((await removeModel.json()).providers[0].models.map((entry) => entry.modelId)).toEqual(["whisper-large"]);
const removeProvider = await app.request(
`/api/v1/ai/transcription-providers/${providerId}`,
{ method: "DELETE" },
databaseEnvironment,
);
expect(removeProvider.status).toBe(200);
expect((await removeProvider.json()).providers).toEqual([]);
expect(auditActionsOf(sqlite)).toEqual([
"workspace.ai_transcription_provider.create",
"workspace.ai_transcription_model.create",
"workspace.ai_transcription_model.create",
"workspace.ai_transcription_default.update",
"workspace.ai_transcription_default.update",
"workspace.ai_transcription_model.delete",
"workspace.ai_transcription_provider.delete",
]);
sqlite.close();
});
test("updates a speech provider and keeps the stored key unless a new one is sent", async () => {
const app = createApp();
const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment();
const created = await createTranscriptionProvider(app, databaseEnvironment, { initialModelId: "whisper-1" });
const providerId = created.providers[0].id;
const credentialPath = `/api/v1/ai/transcription-providers/${providerId}/direct-credential`;
const renamed = await app.request(
`/api/v1/ai/transcription-providers/${providerId}`,
jsonRequest("PUT", { ...transcriptionSettings, apiKey: undefined, displayName: "Renamed", isEnabled: false }),
databaseEnvironment,
);
expect(renamed.status).toBe(200);
expect((await renamed.json()).providers[0]).toMatchObject({ displayName: "Renamed", isEnabled: false });
expect(await (await app.request(credentialPath, { method: "POST" }, databaseEnvironment)).json())
.toEqual({ apiKey: "speech-secret" });
// A model of a disabled service cannot become the default.
const disabledDefault = await app.request(
"/api/v1/ai/transcription-default-model",
jsonRequest("PUT", { modelConfigId: created.providers[0].models[0].id }),
databaseEnvironment,
);
expect(disabledDefault.status).toBe(400);
const rekeyed = await app.request(
`/api/v1/ai/transcription-providers/${providerId}`,
jsonRequest("PUT", { ...transcriptionSettings, apiKey: "rotated-secret" }),
databaseEnvironment,
);
expect(rekeyed.status).toBe(200);
expect(await (await app.request(credentialPath, { method: "POST" }, databaseEnvironment)).json())
.toEqual({ apiKey: "rotated-secret" });
const stored = sqlite.query("SELECT api_key_encrypted FROM ai_transcription_providers WHERE id = ?").get(providerId);
expect(stored.api_key_encrypted).not.toContain("rotated-secret");
const metadata = sqlite.query(
"SELECT metadata_json FROM audit_events WHERE action = 'workspace.ai_transcription_provider.update' ORDER BY rowid",
).all().map((row) => JSON.parse(row.metadata_json).apiKeyUpdated);
expect(metadata).toEqual([false, true]);
const missing = await app.request(
"/api/v1/ai/transcription-providers/atp_missing",
jsonRequest("PUT", transcriptionSettings),
databaseEnvironment,
);
expect(missing.status).toBe(404);
sqlite.close();
});
test("blocks speech setting changes in demo mode and for API tokens", async () => {
for (const app of [createApp({ demoMode: true }), createApp({ currentAuth: agentAuth })]) {
for (const [path, init] of [
["/api/v1/ai/transcription-providers", jsonRequest("POST", transcriptionSettings)],
["/api/v1/ai/transcription-providers/atp_x", jsonRequest("PUT", transcriptionSettings)],
["/api/v1/ai/transcription-providers/atp_x", { method: "DELETE" }],
["/api/v1/ai/transcription-providers/atp_x/models", jsonRequest("POST", { modelId: "m" })],
["/api/v1/ai/transcription-providers/atp_x/models/atm_x", { method: "DELETE" }],
["/api/v1/ai/transcription-default-model", jsonRequest("PUT", { modelConfigId: null })],
]) {
const response = await app.request(path, init, environment);
expect(response.status, `${init.method} ${path}`).toBe(403);
}
}
});
});
describe("AI model provider settings routes", () => {
test("blocks provider changes in demo mode and for API tokens", async () => {
for (const app of [createApp({ demoMode: true }), createApp({ currentAuth: agentAuth })]) {
for (const [path, init] of [
["/api/v1/ai/providers/aip_x", jsonRequest("PUT", validSettings)],
["/api/v1/ai/providers/aip_x", { method: "DELETE" }],
["/api/v1/ai/providers/aip_x/models", jsonRequest("POST", { modelId: "m" })],
["/api/v1/ai/providers/aip_x/models/aim_x", { method: "DELETE" }],
["/api/v1/ai/default-model", jsonRequest("PUT", { modelConfigId: null })],
]) {
const response = await app.request(path, init, environment);
expect(response.status, `${init.method} ${path}`).toBe(403);
}
}
});
test("keeps the saved API key on update unless a new one is provided", async () => {
const app = createApp();
const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment();
const created = await createProvider(app, databaseEnvironment);
const providerId = created.providers[0].id;
const credentialPath = `/api/v1/ai/providers/${providerId}/direct-credential`;
const { apiKey: _apiKey, initialModelId: _model, ...fields } = validSettings;
const renamed = await app.request(
`/api/v1/ai/providers/${providerId}`,
jsonRequest("PUT", { ...fields, displayName: "Renamed" }),
databaseEnvironment,
);
expect(renamed.status).toBe(200);
expect((await renamed.json()).providers[0].displayName).toBe("Renamed");
const kept = await app.request(credentialPath, jsonRequest("POST", {}), databaseEnvironment);
expect(await kept.json()).toEqual({
provider: "openai-compatible",
baseUrl: "https://models.example.com/v1",
apiKey: "secret",
modelId: "",
});
await app.request(
`/api/v1/ai/providers/${providerId}`,
jsonRequest("PUT", { ...fields, apiKey: "rotated" }),
databaseEnvironment,
);
expect((await (await app.request(credentialPath, jsonRequest("POST", {}), databaseEnvironment)).json()).apiKey)
.toBe("rotated");
const missing = await app.request(
"/api/v1/ai/providers/aip_missing",
jsonRequest("PUT", fields),
databaseEnvironment,
);
expect(missing.status).toBe(404);
expect(await missing.json()).toMatchObject({ error: { message: "AI provider not found." } });
sqlite.close();
});
test("rejects provider creation without an encryption key", async () => {
const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment();
const { EDGE_EVER_AUTH_PASSWORD: _password, ...withoutKey } = databaseEnvironment;
const response = await createApp().request("/api/v1/ai/providers", jsonRequest("POST", validSettings), withoutKey);
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({ error: { code: "ai_encryption_key_missing" } });
sqlite.close();
});
test("isolates providers, models and the default model per workspace", async () => {
const app = createApp();
const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment();
sqlite.query("INSERT INTO workspaces (id, name, is_personal) VALUES (?, ?, 1)").run("ws_other", "Other");
const created = await createProvider(app, databaseEnvironment);
const provider = created.providers[0];
const model = provider.models[0];
const otherApp = createApp({ currentAuth: otherWorkspaceAuth });
for (const [path, init] of [
[`/api/v1/ai/providers/${provider.id}`, jsonRequest("PUT", validSettings)],
[`/api/v1/ai/providers/${provider.id}`, { method: "DELETE" }],
[`/api/v1/ai/providers/${provider.id}/models`, jsonRequest("POST", { modelId: "x" })],
[`/api/v1/ai/providers/${provider.id}/models/${model.id}`, { method: "DELETE" }],
[`/api/v1/ai/providers/${provider.id}/direct-credential`, jsonRequest("POST", {})],
]) {
const response = await otherApp.request(path, init, databaseEnvironment);
expect(response.status, `${init.method} ${path}`).toBe(404);
}
const foreignDefault = await otherApp.request(
"/api/v1/ai/default-model",
jsonRequest("PUT", { modelConfigId: model.id }),
databaseEnvironment,
);
expect(foreignDefault.status).toBe(400);
expect(await foreignDefault.json()).toMatchObject({ error: { code: "ai_default_model_unavailable" } });
const ownSettings = await (await app.request("/api/v1/ai/settings", {}, databaseEnvironment)).json();
expect(ownSettings.providers[0].displayName).toBe(validSettings.displayName);
expect(ownSettings.defaultModelId).toBe(model.id);
sqlite.close();
});
test("adds, selects and removes models and deletes the provider", async () => {
const app = createApp();
const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment();
const created = await createProvider(app, databaseEnvironment, { initialModelId: undefined });
const providerId = created.providers[0].id;
expect(created.defaultModelId).toBeNull();
const added = await app.request(
`/api/v1/ai/providers/${providerId}/models`,
jsonRequest("POST", { modelId: "model-b" }),
databaseEnvironment,
);
expect(added.status).toBe(201);
const withModel = await added.json();
const model = withModel.providers[0].models[0];
expect(model).toMatchObject({ modelId: "model-b", displayName: "model-b" });
expect(withModel.defaultModelId).toBe(model.id);
const cleared = await app.request("/api/v1/ai/default-model", jsonRequest("PUT", { modelConfigId: null }), databaseEnvironment);
expect(cleared.status).toBe(200);
expect((await cleared.json()).defaultModelId).toBeNull();
const selected = await app.request("/api/v1/ai/default-model", jsonRequest("PUT", { modelConfigId: model.id }), databaseEnvironment);
expect(selected.status).toBe(200);
expect((await selected.json()).defaultModelId).toBe(model.id);
const unknown = await app.request("/api/v1/ai/default-model", jsonRequest("PUT", { modelConfigId: "aim_missing" }), databaseEnvironment);
expect(unknown.status).toBe(400);
const wrongProvider = await app.request(
`/api/v1/ai/providers/aip_other/models/${model.id}`,
{ method: "DELETE" },
databaseEnvironment,
);
expect(wrongProvider.status).toBe(404);
expect(await wrongProvider.json()).toMatchObject({ error: { message: "AI model not found." } });
const removedModel = await app.request(
`/api/v1/ai/providers/${providerId}/models/${model.id}`,
{ method: "DELETE" },
databaseEnvironment,
);
expect(removedModel.status).toBe(200);
expect((await removedModel.json()).providers[0].models).toEqual([]);
const missingProvider = await app.request(
"/api/v1/ai/providers/aip_missing/models",
jsonRequest("POST", { modelId: "x" }),
databaseEnvironment,
);
expect(missingProvider.status).toBe(404);
const removedProvider = await app.request(`/api/v1/ai/providers/${providerId}`, { method: "DELETE" }, databaseEnvironment);
expect(removedProvider.status).toBe(200);
expect((await removedProvider.json()).providers).toEqual([]);
expect((await app.request(`/api/v1/ai/providers/${providerId}`, { method: "DELETE" }, databaseEnvironment)).status).toBe(404);
expect(auditActionsOf(sqlite)).toEqual([
"workspace.ai_provider.create",
"workspace.ai_model.create",
"workspace.ai_default_model.update",
"workspace.ai_default_model.update",
"workspace.ai_model.delete",
"workspace.ai_provider.delete",
]);
sqlite.close();
});
test("does not auto-select a default model for a disabled provider", async () => {
const app = createApp();
const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment();
const created = await createProvider(app, databaseEnvironment, { initialModelId: undefined, isEnabled: false });
const providerId = created.providers[0].id;
const added = await app.request(
`/api/v1/ai/providers/${providerId}/models`,
jsonRequest("POST", { modelId: "model-c", displayName: "Model C" }),
databaseEnvironment,
);
const body = await added.json();
expect(body.providers[0].models[0].displayName).toBe("Model C");
expect(body.defaultModelId).toBeNull();
const selected = await app.request(
"/api/v1/ai/default-model",
jsonRequest("PUT", { modelConfigId: body.providers[0].models[0].id }),
databaseEnvironment,
);
expect(selected.status).toBe(400);
sqlite.close();
});
test("reports provider errors for malformed direct credential requests", async () => {
const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment();
const response = await createApp().request(
"/api/v1/ai/providers/aip_x/direct-credential",
{ method: "POST", headers: { "content-type": "application/json" }, body: "{not json" },
databaseEnvironment,
);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error.code).toBe("ai_credential_failed");
expect(typeof body.error.message).toBe("string");
const tokenResponse = await createApp({ currentAuth: agentAuth }).request(
"/api/v1/ai/providers/aip_x/direct-credential",
jsonRequest("POST", {}),
databaseEnvironment,
);
expect(tokenResponse.status).toBe(403);
sqlite.close();
});
});
describe("AI preparation routes without a configured model", () => {
const prepareRequests = [
["/api/v1/ai/direct-target", {}],
["/api/v1/ai/tag-suggestions/prepare", jsonRequest("POST", { title: "T", contentMarkdown: "Body", currentTags: [] })],
[
"/api/v1/ai/infographic-agent/prepare",
jsonRequest("POST", { prompt: "List", currentContent: "", candidates: ["list-grid-simple"], history: [] }),
],
["/api/v1/ai/generate/prepare", jsonRequest("POST", { action: "summarize", title: "T", contentMarkdown: "Body" })],
];
test("report that AI is not configured", async () => {
const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment();
for (const [path, init] of prepareRequests) {
const response = await createApp().request(path, init, databaseEnvironment);
expect(response.status, path).toBe(409);
expect(await response.json()).toMatchObject({ error: { code: "ai_not_configured" } });
}
sqlite.close();
});
test("require an interactive user session", async () => {
for (const [path, init] of prepareRequests) {
const response = await createApp({ currentAuth: agentAuth }).request(path, init, environment);
expect(response.status, path).toBe(403);
}
});
});
describe("AI generation preparation", () => {
test("includes only the parameters the action needs", async () => {
const app = createApp();
const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment();
await createProvider(app, databaseEnvironment);
const translate = await app.request(
"/api/v1/ai/generate/prepare",
jsonRequest("POST", { action: "translate", title: "Note", contentMarkdown: "Hallo", targetLanguage: "en", tone: "friendly" }),
databaseEnvironment,
);
expect(translate.status).toBe(200);
const tone = await app.request(
"/api/v1/ai/generate/prepare",
jsonRequest("POST", { action: "change-tone", title: "Note", contentMarkdown: "Body" }),
databaseEnvironment,
);
expect(tone.status).toBe(400);
expect(await tone.json()).toMatchObject({ error: { code: "ai_request_invalid" } });
const custom = await app.request(
"/api/v1/ai/generate/prepare",
jsonRequest("POST", { action: "custom", title: "Note", contentMarkdown: "Body", instruction: " Keep every date unique-marker. " }),
databaseEnvironment,
);
expect(custom.status).toBe(200);
expect(JSON.stringify(await custom.json())).toContain("Keep every date unique-marker.");
sqlite.close();
});
test("uses a saved workspace prompt's instruction", async () => {
const app = createApp();
const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment();
await createProvider(app, databaseEnvironment);
const now = "2026-08-12T01:00:00.000Z";
sqlite.query(
`INSERT INTO ai_prompt_templates (
id, workspace_id, seed_key, action, parameter_kind, result_mode,
name, description, instruction,
name_customized, description_customized, instruction_customized,
created_at, updated_at
) VALUES (?, ?, NULL, 'custom', 'none', 'replace', ?, NULL, ?, 1, 1, 1, ?, ?)`,
).run("aiprompt_saved", auth.workspaceId, "Saved", "Saved-prompt-marker instruction.", now, now);
const response = await app.request(
"/api/v1/ai/generate/prepare",
jsonRequest("POST", { action: "translate", promptId: "aiprompt_saved", title: "Note", contentMarkdown: "Body" }),
databaseEnvironment,
);
expect(response.status).toBe(200);
expect(JSON.stringify(await response.json())).toContain("Saved-prompt-marker instruction.");
sqlite.query(
`INSERT INTO ai_prompt_templates (
id, workspace_id, seed_key, action, parameter_kind, result_mode,
name, description, instruction,
name_customized, description_customized, instruction_customized,
created_at, updated_at
) VALUES (?, ?, NULL, 'custom', 'target-language', 'replace', ?, NULL, ?, 1, 1, 1, ?, ?)`,
).run("aiprompt_language", auth.workspaceId, "Language", "Translate it.", now, now);
const missingLanguage = await app.request(
"/api/v1/ai/generate/prepare",
jsonRequest("POST", { action: "custom", promptId: "aiprompt_language", title: "Note", contentMarkdown: "Body" }),
databaseEnvironment,
);
expect(missingLanguage.status).toBe(400);
expect(await missingLanguage.json()).toMatchObject({ error: { code: "ai_target_language_required" } });
const withLanguage = await app.request(
"/api/v1/ai/generate/prepare",
jsonRequest("POST", {
action: "custom",
promptId: "aiprompt_language",
title: "Note",
contentMarkdown: "Body",
targetLanguage: "en",
}),
databaseEnvironment,
);
expect(withLanguage.status).toBe(200);
sqlite.close();
});
test("maps existing tag spellings onto the canonical workspace tags", async () => {
const app = createApp();
const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment();
await createProvider(app, databaseEnvironment);
const response = await app.request(
"/api/v1/ai/tag-suggestions/prepare",
jsonRequest("POST", { title: "Note", contentMarkdown: "Body", currentTags: ["draft"], locale: "en-US" }),
databaseEnvironment,
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.currentTags).toEqual(["draft"]);
expect(body.canonicalTags).toEqual({});
sqlite.close();
});
});
describe("video outline preparation", () => {
test("requires the ai:generate scope for API tokens", async () => {
const response = await createApp({ currentAuth: { ...agentAuth, scopes: ["read:memos"] } }).request(
"/api/v1/ai/video-outline/prepare",
jsonRequest("POST", videoOutlineBody()),
environment,
);
expect(response.status).toBe(403);
});
test("returns model credentials with the outline prompt for a configured workspace", async () => {
const app = createApp();
const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment();
await createProvider(app, databaseEnvironment);
const response = await createApp({ currentAuth: agentAuth }).request(
"/api/v1/ai/video-outline/prepare",
jsonRequest("POST", videoOutlineBody()),
databaseEnvironment,
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toMatchObject({ apiKey: "secret", modelId: "model-a", maxOutputTokens: 1200 });
expect(body.prompt).toContain("Hello world");
expect(typeof body.system).toBe("string");
sqlite.close();
});
});
describe("AI settings storage failures", () => {
test("rejects duplicate model ids without changing the stored models", async () => {
const app = createApp();
const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment();
const provider = (await createProvider(app, databaseEnvironment)).providers[0];
const duplicate = await app.request(
`/api/v1/ai/providers/${provider.id}/models`,
jsonRequest("POST", { modelId: "model-a" }),
databaseEnvironment,
);
expect(duplicate.status).toBe(400);
expect(await duplicate.json()).toMatchObject({ error: { code: "ai_model_create_failed" } });
const speech = (await createTranscriptionProvider(app, databaseEnvironment, { initialModelId: "whisper-1" })).providers[0];
const duplicateSpeech = await app.request(
`/api/v1/ai/transcription-providers/${speech.id}/models`,
jsonRequest("POST", { modelId: "whisper-1" }),
databaseEnvironment,
);
expect(duplicateSpeech.status).toBe(400);
expect(await duplicateSpeech.json()).toMatchObject({ error: { code: "ai_transcription_model_create_failed" } });
expect(sqlite.query("SELECT COUNT(*) AS count FROM ai_models").get().count).toBe(1);
expect(sqlite.query("SELECT COUNT(*) AS count FROM ai_transcription_models").get().count).toBe(1);
expect(auditActionsOf(sqlite).filter((action) => action.endsWith("model.create"))).toEqual([]);
sqlite.close();
});
test("refuses to rotate keys when the encryption key is no longer configured", async () => {
const app = createApp();
const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment();
const provider = (await createProvider(app, databaseEnvironment)).providers[0];
const speech = (await createTranscriptionProvider(app, databaseEnvironment)).providers[0];
const { EDGE_EVER_AUTH_PASSWORD: _password, ...withoutKey } = databaseEnvironment;
const { initialModelId: _model, ...providerFields } = validSettings;
const providerUpdate = await app.request(
`/api/v1/ai/providers/${provider.id}`,
jsonRequest("PUT", { ...providerFields, apiKey: "rotated" }),
withoutKey,
);
expect(providerUpdate.status).toBe(400);
expect(await providerUpdate.json()).toMatchObject({ error: { code: "ai_encryption_key_missing" } });
const speechUpdate = await app.request(
`/api/v1/ai/transcription-providers/${speech.id}`,
jsonRequest("PUT", { ...transcriptionSettings, apiKey: "rotated" }),
withoutKey,
);
expect(speechUpdate.status).toBe(400);
expect(await speechUpdate.json()).toMatchObject({ error: { code: "ai_encryption_key_missing" } });
sqlite.close();
});
test("reports an undecryptable speech credential instead of returning it", async () => {
const app = createApp();
const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment();
const speech = (await createTranscriptionProvider(app, databaseEnvironment)).providers[0];
sqlite.query("UPDATE ai_transcription_providers SET api_key_encrypted = ? WHERE id = ?").run("corrupted", speech.id);
const response = await app.request(
`/api/v1/ai/transcription-providers/${speech.id}/direct-credential`,
{ method: "POST" },
databaseEnvironment,
);
expect(response.status).toBe(503);
expect(await response.json()).toMatchObject({ error: { code: "ai_credentials_unavailable" } });
sqlite.close();
});
});
+385
View File
@@ -1,4 +1,6 @@
import { describe, expect, test } from "bun:test";
import { globSync, readFileSync } from "node:fs";
import { Database } from "bun:sqlite";
import { Hono } from "hono";
import { listMemoTemplateGallery } from "@edgeever/shared/memo-template-gallery";
import { mapMemoTemplateRow, registerTemplateRoutes } from "./template-routes.ts";
@@ -50,3 +52,386 @@ describe("template gallery route", () => {
expect(body.templates).toEqual(listMemoTemplateGallery("pl"));
});
});
class SqliteD1PreparedStatement {
constructor(db, sql, bindings = []) {
this.db = db;
this.sql = sql;
this.bindings = bindings;
}
bind(...bindings) {
return new SqliteD1PreparedStatement(this.db, this.sql, bindings);
}
async all() {
return { results: this.db.query(this.sql).all(...this.bindings), success: true, meta: {} };
}
async first() {
return this.db.query(this.sql).get(...this.bindings) ?? null;
}
async run() {
this.db.query(this.sql).run(...this.bindings);
return { success: true, meta: {} };
}
}
class SqliteD1Database {
constructor(db) {
this.db = db;
}
prepare(sql) {
return new SqliteD1PreparedStatement(this.db, sql);
}
async batch(statements) {
return this.db.transaction(() => statements.map((statement) =>
this.db.query(statement.sql).run(...statement.bindings)))();
}
}
const ownerAuth = {
kind: "user",
actorType: "user",
actorId: "usr_owner",
username: "owner",
scopes: [],
workspaceId: "ws_owner",
role: "owner",
};
const otherAuth = { ...ownerAuth, actorId: "usr_other", username: "other", workspaceId: "ws_other" };
const createTemplateHarness = ({ memos = {} } = {}) => {
const sqlite = new Database(":memory:");
for (const migration of globSync("migrations/*.sql").sort()) {
sqlite.exec(readFileSync(migration, "utf8"));
}
for (const id of ["ws_owner", "ws_other"]) {
sqlite.query("INSERT INTO workspaces (id, name, is_personal) VALUES (?, ?, 1)").run(id, id);
}
// Start from an empty template table so assertions only see rows created here.
sqlite.query("DELETE FROM memo_templates").run();
const memoDetailCalls = [];
const createdMemos = [];
const dependencies = {
getMemoDetail: async (_db, workspaceId, id) => {
memoDetailCalls.push({ workspaceId, id });
return memos[`${workspaceId}/${id}`] ?? null;
},
createMemoRecord: async (_db, workspaceId, input, actor, actorLabel) => {
const memo = { id: `memo_${createdMemos.length + 1}`, workspaceId, ...input };
createdMemos.push({ workspaceId, input, actor, actorLabel });
return memo;
},
};
const createApp = (currentAuth = ownerAuth) => {
const app = new Hono();
app.use("/api/v1/*", async (context, next) => {
context.set("auth", currentAuth);
await next();
});
registerTemplateRoutes(app, dependencies);
return app;
};
const environment = { storage: { db: new SqliteD1Database(sqlite), resources: {} } };
const request = (path, init = {}, currentAuth = ownerAuth) => {
const { json, ...rest } = init;
return createApp(currentAuth).request(
path,
json === undefined
? rest
: { ...rest, headers: { "content-type": "application/json" }, body: JSON.stringify(json) },
environment,
);
};
const auditActions = () =>
sqlite.query("SELECT action, entity_id, actor_id, metadata_json FROM audit_events ORDER BY rowid").all();
return { sqlite, request, memoDetailCalls, createdMemos, auditActions };
};
describe("template CRUD routes", () => {
test("creates a template from markdown, trims fields and records an audit event", async () => {
const harness = createTemplateHarness();
const response = await harness.request("/api/v1/templates", {
method: "POST",
json: {
name: " Standup ",
description: " Daily notes ",
title: " Today ",
contentMarkdown: "# Standup",
tags: ["daily"],
},
});
const body = await response.json();
expect(response.status).toBe(201);
expect(body.template).toMatchObject({
name: "Standup",
description: "Daily notes",
title: "Today",
contentMarkdown: "# Standup",
tags: ["daily"],
});
expect(body.template.id).toStartWith("template");
expect(body.template.contentJson.type).toBe("doc");
expect(harness.memoDetailCalls).toEqual([]);
const stored = harness.sqlite.query("SELECT workspace_id FROM memo_templates WHERE id = ?").get(body.template.id);
expect(stored.workspace_id).toBe("ws_owner");
expect(harness.auditActions()).toEqual([{
action: "template.create",
entity_id: body.template.id,
actor_id: "usr_owner",
metadata_json: JSON.stringify({ memoId: null }),
}]);
});
test("stores blank optional text as null and defaults tags to an empty list", async () => {
const harness = createTemplateHarness();
const response = await harness.request("/api/v1/templates", {
method: "POST",
json: { name: "Blank", description: " ", title: " ", contentMarkdown: "" },
});
const body = await response.json();
expect(response.status).toBe(201);
expect(body.template).toMatchObject({ description: null, title: null, contentMarkdown: "", tags: [] });
});
test("copies title, content and tags from a memo in the caller's workspace", async () => {
const memoDoc = { type: "doc", content: [{ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "Source" }] }] };
const harness = createTemplateHarness({
memos: {
"ws_owner/memo_source": {
id: "memo_source",
title: "Source",
contentMarkdown: "# Source",
contentJson: memoDoc,
tags: ["from-memo"],
},
},
});
const response = await harness.request("/api/v1/templates", {
method: "POST",
json: { name: "From memo", memoId: "memo_source", title: "ignored", contentMarkdown: "ignored", tags: ["ignored"] },
});
const body = await response.json();
expect(response.status).toBe(201);
expect(body.template).toMatchObject({
title: "Source",
contentMarkdown: "# Source",
contentJson: memoDoc,
tags: ["from-memo"],
});
expect(harness.memoDetailCalls).toEqual([{ workspaceId: "ws_owner", id: "memo_source" }]);
expect(JSON.parse(harness.auditActions()[0].metadata_json)).toEqual({ memoId: "memo_source" });
});
test("rejects a memo source that is missing or belongs to another workspace", async () => {
const harness = createTemplateHarness({
memos: { "ws_other/memo_foreign": { id: "memo_foreign", title: "Foreign", contentMarkdown: "x", contentJson: {}, tags: [] } },
});
const response = await harness.request("/api/v1/templates", {
method: "POST",
json: { name: "Steal", memoId: "memo_foreign" },
});
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ error: { code: "not_found", message: "Memo not found" } });
expect(harness.memoDetailCalls).toEqual([{ workspaceId: "ws_owner", id: "memo_foreign" }]);
expect(harness.sqlite.query("SELECT COUNT(*) AS count FROM memo_templates").get().count).toBe(0);
expect(harness.auditActions()).toEqual([]);
});
test("rejects invalid create payloads before touching storage", async () => {
const harness = createTemplateHarness();
for (const json of [
{ name: "No content" },
{ name: " ", contentMarkdown: "x" },
{ name: "x".repeat(161), contentMarkdown: "x" },
{ name: "Bad tags", contentMarkdown: "x", tags: "daily" },
]) {
const response = await harness.request("/api/v1/templates", { method: "POST", json });
expect(response.status).toBe(400);
}
expect(harness.sqlite.query("SELECT COUNT(*) AS count FROM memo_templates").get().count).toBe(0);
});
test("lists only the caller's workspace templates, most recently updated first", async () => {
const harness = createTemplateHarness();
const insert = harness.sqlite.query(
`INSERT INTO memo_templates (id, workspace_id, name, content_json, content_markdown, tags_json, updated_at)
VALUES (?, ?, ?, '{"type":"doc","content":[]}', '', '[]', ?)`,
);
insert.run("template_old", "ws_owner", "Old", "2026-01-01T00:00:00.000Z");
insert.run("template_new", "ws_owner", "New", "2026-02-01T00:00:00.000Z");
insert.run("template_foreign", "ws_other", "Foreign", "2026-03-01T00:00:00.000Z");
const response = await harness.request("/api/v1/templates");
const body = await response.json();
expect(response.status).toBe(200);
expect(body.templates.map((template) => template.id)).toEqual(["template_new", "template_old"]);
});
test("updates only the provided fields and re-renders content from markdown", async () => {
const harness = createTemplateHarness();
const created = await (await harness.request("/api/v1/templates", {
method: "POST",
json: { name: "Original", description: "Desc", title: "Title", contentMarkdown: "Old body", tags: ["a"] },
})).json();
const id = created.template.id;
const renamed = await harness.request(`/api/v1/templates/${id}`, { method: "PATCH", json: { name: "Renamed" } });
const renamedBody = await renamed.json();
expect(renamed.status).toBe(200);
expect(renamedBody.template).toMatchObject({
name: "Renamed",
description: "Desc",
title: "Title",
contentMarkdown: "Old body",
contentJson: created.template.contentJson,
tags: ["a"],
});
const rewritten = await harness.request(`/api/v1/templates/${id}`, {
method: "PATCH",
json: { description: null, title: " ", contentMarkdown: "# New body", tags: [] },
});
const rewrittenBody = await rewritten.json();
expect(rewritten.status).toBe(200);
expect(rewrittenBody.template).toMatchObject({
name: "Renamed",
description: null,
title: null,
contentMarkdown: "# New body",
tags: [],
});
expect(rewrittenBody.template.contentJson.content[0].type).toBe("heading");
expect(harness.auditActions().map((event) => event.action)).toEqual([
"template.create",
"template.update",
"template.update",
]);
});
test("does not let another workspace update, use or delete a template", async () => {
const harness = createTemplateHarness();
const created = await (await harness.request("/api/v1/templates", {
method: "POST",
json: { name: "Private", contentMarkdown: "secret" },
})).json();
const id = created.template.id;
const update = await harness.request(`/api/v1/templates/${id}`, { method: "PATCH", json: { name: "Hijacked" } }, otherAuth);
const use = await harness.request(`/api/v1/templates/${id}/use`, { method: "POST", json: { notebookId: "nb_1" } }, otherAuth);
const remove = await harness.request(`/api/v1/templates/${id}`, { method: "DELETE" }, otherAuth);
for (const response of [update, use, remove]) {
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ error: { code: "not_found", message: "Template not found" } });
}
expect(harness.createdMemos).toEqual([]);
expect(harness.sqlite.query("SELECT name FROM memo_templates WHERE id = ?").get(id)).toEqual({ name: "Private" });
expect(harness.auditActions().map((event) => event.action)).toEqual(["template.create"]);
});
test("returns 404 for unknown template ids", async () => {
const harness = createTemplateHarness();
expect((await harness.request("/api/v1/templates/template_missing", { method: "PATCH", json: {} })).status).toBe(404);
expect((await harness.request("/api/v1/templates/template_missing", { method: "DELETE" })).status).toBe(404);
expect((await harness.request("/api/v1/templates/template_missing/use", {
method: "POST",
json: { notebookId: "nb_1" },
})).status).toBe(404);
});
test("rejects invalid update and use payloads", async () => {
const harness = createTemplateHarness();
expect((await harness.request("/api/v1/templates/template_1", { method: "PATCH", json: { name: "" } })).status).toBe(400);
expect((await harness.request("/api/v1/templates/template_1", { method: "PATCH", json: { tags: [1] } })).status).toBe(400);
expect((await harness.request("/api/v1/templates/template_1/use", { method: "POST", json: {} })).status).toBe(400);
expect((await harness.request("/api/v1/templates/template_1/use", {
method: "POST",
json: { notebookId: " " },
})).status).toBe(400);
});
test("creates a memo from a template in the caller's notebook and audits the use", async () => {
const harness = createTemplateHarness();
const created = await (await harness.request("/api/v1/templates", {
method: "POST",
json: { name: "Meeting", title: "Meeting notes", contentMarkdown: "## Agenda", tags: ["meeting"] },
})).json();
const id = created.template.id;
const response = await harness.request(`/api/v1/templates/${id}/use`, {
method: "POST",
json: { notebookId: "nb_1", locale: "pl-PL" },
});
const body = await response.json();
expect(response.status).toBe(200);
expect(body.memo.id).toBe("memo_1");
expect(harness.createdMemos).toEqual([{
workspaceId: "ws_owner",
input: { notebookId: "nb_1", title: "Meeting notes", contentMarkdown: "## Agenda", tags: ["meeting"] },
actor: { actorType: "user", actorId: "usr_owner" },
actorLabel: "user:usr_owner",
}]);
const useEvent = harness.auditActions().at(-1);
expect(useEvent.action).toBe("template.use");
expect(useEvent.entity_id).toBe(id);
expect(JSON.parse(useEvent.metadata_json)).toEqual({ memoId: "memo_1" });
});
test("passes an untitled template to memo creation without a title", async () => {
const harness = createTemplateHarness();
const created = await (await harness.request("/api/v1/templates", {
method: "POST",
json: { name: "Untitled", contentMarkdown: "Body" },
})).json();
await harness.request(`/api/v1/templates/${created.template.id}/use`, { method: "POST", json: { notebookId: "nb_2" } });
expect(harness.createdMemos[0].input).toEqual({
notebookId: "nb_2",
title: undefined,
contentMarkdown: "Body",
tags: [],
});
});
test("deletes a template from the caller's workspace", async () => {
const harness = createTemplateHarness();
const created = await (await harness.request("/api/v1/templates", {
method: "POST",
json: { name: "Disposable", contentMarkdown: "x" },
})).json();
const id = created.template.id;
const response = await harness.request(`/api/v1/templates/${id}`, { method: "DELETE" });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
expect(harness.sqlite.query("SELECT COUNT(*) AS count FROM memo_templates").get().count).toBe(0);
expect(harness.auditActions().at(-1)).toMatchObject({ action: "template.delete", entity_id: id });
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@edgeever/desktop",
"version": "2.18.0",
"version": "2.19.0",
"desktopName": "EdgeEver",
"private": true,
"description": "Native EdgeEver desktop client",
@@ -0,0 +1,251 @@
import { isDeepStrictEqual } from "node:util";
import { prepareMacUpdate } from "./mac-update-install.mjs";
// electron-updater owns one mutable download/install target. Keep checks,
// downloads and package verification serialized around that target.
export const createDesktopUpdateController = ({
updater, platform, nativeUpdater, fetchWindowsMetadata, verifyWindowsPackage,
quitApplication = () => {},
onStatus, onError, onDownloaded, beforeInstall,
onStage = () => {}, onCheckStarted = () => {}, onSettled = () => {},
log = () => {},
}) => {
let status = { state: "idle", version: null, progress: null };
let target = null;
let ready = null;
let checkInFlight = null;
let downloadInFlight = null;
let verificationInFlight = null;
let installInFlight = null;
let installing = false;
let quittingForUpdate = false;
let quitRequested = false;
let skipQuitInstall = false;
let nativePreparationStarted = false;
let nativePreparationFailed = false;
const windows = platform === "win32";
const mac = platform === "darwin";
const downloadedPath = () => mac ? updater.downloadedUpdateHelper?.file : updater.installerPath;
const publish = (next) => { status = { ...status, ...next }; onStatus({ ...status }); };
const packageMatches = (candidate) => Boolean(candidate && (
(!windows || candidate.manifest?.version === candidate.info.version) &&
candidate.path && downloadedPath() === candidate.path
));
const refreshQuitInstall = () => {
// Squirrel must not stage an intermediate background download. The shared
// before-quit hook refreshes the target before normal quit installation.
updater.autoInstallOnAppQuit = !mac && !checkInFlight && !downloadInFlight &&
!verificationInFlight && ready === target && packageMatches(ready);
if (updater.autoInstallOnAppQuit) {
// BaseUpdater normally registers this during update-downloaded, before
// our async hash verification completes. Register only after trust is
// established; its quit callback reads autoInstallOnAppQuit again.
updater.addQuitHandler();
}
};
const invalidate = () => {
updater.autoInstallOnAppQuit = false;
ready = null;
target = null;
};
const fail = (error, stage) => {
invalidate();
publish({ state: "idle", version: null, progress: null });
onError(error, stage);
};
// Downloads are still automatic, but start only after metadata selection
// (and Windows signature verification), never inside checkForUpdates().
updater.autoDownload = false;
updater.autoInstallOnAppQuit = false;
updater.autoRunAppAfterInstall = true;
updater.on("download-progress", (progress) => {
if (downloadInFlight) {
publish({ progress });
log("update.download-progress", { percent: progress.percent });
}
});
updater.on("error", (error) => {
// check/download promises report their own errors with the correct stage.
if (!checkInFlight && !downloadInFlight) {
installing = false;
fail(error, "install");
}
});
updater.on("update-downloaded", (info) => {
const candidate = target;
if (!downloadInFlight || verificationInFlight) {
fail(new Error("Unexpected update download completion"), windows ? "verify-windows-package" : "download");
return;
}
verificationInFlight = Promise.resolve().then(async () => {
if (!candidate || candidate.info.version !== info?.version) {
throw new Error("Downloaded update does not match the selected target");
}
if (windows) {
onStage("verify-windows-package");
if (!candidate.manifest || candidate.manifest.version !== info.version ||
!info.downloadedFile || updater.installerPath !== info.downloadedFile) {
throw new Error("Downloaded Windows update has no matching trusted installer");
}
await verifyWindowsPackage({ path: info.downloadedFile, manifest: candidate.manifest });
}
if (target !== candidate) throw new Error("Update target changed during package verification");
candidate.path = info.downloadedFile;
candidate.verified = true;
});
// The event may precede downloadUpdate()'s promise settlement.
void verificationInFlight.catch(() => {});
});
const download = (reason) => {
if (downloadInFlight) return downloadInFlight;
const candidate = target;
if (!candidate || (windows && !candidate.manifest)) {
return Promise.reject(new Error("Update metadata has not passed the signature gate"));
}
if (ready === candidate && packageMatches(ready)) return Promise.resolve();
updater.autoInstallOnAppQuit = false;
downloadInFlight = Promise.resolve().then(async () => {
onStage("download");
await updater.downloadUpdate();
await verificationInFlight;
if (target !== candidate || !candidate.verified || !packageMatches(candidate)) {
throw new Error("Downloaded package does not match the selected update target");
}
ready = candidate;
publish({ state: "downloaded", version: candidate.info.version, progress: null });
log("update.downloaded", { version: candidate.info.version });
}).catch((error) => {
fail(error, windows && verificationInFlight ? "verify-windows-package" : "download");
log(windows && verificationInFlight ? "update.windows-package-blocked" : "update.download-failed", {
reason, message: error.message,
});
}).finally(() => {
downloadInFlight = null;
verificationInFlight = null;
refreshQuitInstall();
if (!checkInFlight) onSettled();
if (ready === candidate && !quitRequested) onDownloaded(candidate.info.version);
});
return downloadInFlight;
};
const check = (reason) => {
if (nativePreparationFailed) {
return Promise.reject(new Error("Restart EdgeEver before retrying native macOS update preparation"));
}
if (installing) return Promise.resolve(null);
if (checkInFlight) return checkInFlight;
// In particular, do not change updater.updateInfoAndProvider while a
// Windows package is still being hashed after its download has finished.
if (downloadInFlight) return Promise.resolve(null);
updater.autoInstallOnAppQuit = false;
let stage = "check";
checkInFlight = Promise.resolve().then(async () => {
onCheckStarted(reason);
const result = await updater.checkForUpdates();
if (!result?.isUpdateAvailable) {
invalidate();
publish({ state: "idle", version: null, progress: null });
log("update.not-available");
return result;
}
const info = result.updateInfo;
if (ready && isDeepStrictEqual(ready.info, info) && packageMatches(ready)) {
publish({ state: "downloaded", version: ready.info.version, progress: null });
return result;
}
// Revoke installation of the previous package before awaiting trust of
// a replacement. A failed replacement must never install the old file.
invalidate();
target = { info, manifest: null, path: null, verified: false };
publish({ state: "available", version: info.version, progress: null });
log("update.available", { version: info.version });
if (windows) {
stage = "verify-windows-metadata";
onStage(stage);
target.manifest = await fetchWindowsMetadata({ version: info.version, updateInfo: info });
if (target.manifest.version !== info.version) throw new Error("Windows manifest target mismatch");
log("update.windows-manifest-verified", { version: info.version, keyId: target.manifest.keyId });
}
void download(reason);
return result;
}).catch((error) => {
// A failed check leaves the old package on disk, but does not authorize
// installation without a successful check and (on Windows) verification.
fail(error, stage);
log("update.check-failed", { reason, message: error.message });
throw error;
}).finally(() => {
checkInFlight = null;
refreshQuitInstall();
if (!downloadInFlight) onSettled();
});
return checkInFlight;
};
const install = (expectedVersion) => {
if (installInFlight) return installInFlight;
if (!ready || installing) return Promise.resolve({ started: false });
installInFlight = Promise.resolve().then(async () => {
await check("before-install");
if (downloadInFlight) await downloadInFlight;
if (!ready || ready !== target || !ready.verified || !packageMatches(ready) ||
checkInFlight || downloadInFlight || verificationInFlight ||
(expectedVersion && !quitRequested && expectedVersion !== ready.info.version)) {
return { started: false };
}
installing = true;
log("update.install-confirmed", { version: ready.info.version });
if (mac) {
onStage("install");
nativePreparationStarted = true;
await prepareMacUpdate(nativeUpdater);
if (ready !== target || !packageMatches(ready)) {
throw new Error("macOS installation target changed during native preparation");
}
}
beforeInstall();
quittingForUpdate = true;
updater.autoRunAppAfterInstall = !quitRequested;
if (mac) {
if (quitRequested) quitApplication(); // Squirrel applies the staged ZIP without relaunching.
else nativeUpdater.quitAndInstall();
} else {
updater.quitAndInstall(windows, !quitRequested);
}
return { started: true };
}).catch((error) => {
installing = false;
quittingForUpdate = false;
if (mac && nativePreparationStarted) {
// Electron exposes no cancellation for Squirrel. A timed-out or failed
// operation may still complete later; never let it race a new target.
nativePreparationFailed = true;
fail(new Error(`macOS native update preparation failed; restart EdgeEver before retrying: ${error.message}`), "install");
} else if (status.state !== "idle") {
fail(error, "install");
}
return { started: false };
}).finally(() => {
installInFlight = null;
if (quitRequested && !quittingForUpdate) {
skipQuitInstall = true;
quitApplication();
}
});
return installInFlight;
};
const handleBeforeQuit = (event) => {
if (skipQuitInstall || quittingForUpdate || (!ready && !installInFlight)) return false;
event.preventDefault();
quitRequested = true;
// If a check or preparation fails, quit without installing the old package.
if (!installInFlight) void install();
return true;
};
return { check, download, install, handleBeforeQuit, snapshot: () => ({ ...status }) };
};
@@ -0,0 +1,325 @@
import { createRequire } from "node:module";
import { EventEmitter } from "node:events";
import { createHash } from "node:crypto";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "bun:test";
import { createDesktopUpdateController } from "./desktop-update-controller.mjs";
import { prepareMacUpdate } from "./mac-update-install.mjs";
const require = createRequire(new URL("../../package.json", import.meta.url));
const { BaseUpdater } = require("electron-updater/out/BaseUpdater.js");
const { MacUpdater } = require("electron-updater/out/MacUpdater.js");
const { AppImageUpdater } = require("electron-updater/out/AppImageUpdater.js");
const cleanup = [];
afterEach(async () => { for (const close of cleanup.splice(0)) await close(); });
const deferred = () => {
let resolve;
const promise = new Promise((yes) => { resolve = yes; });
return { promise, resolve };
};
const flush = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
// Use real AppUpdater selection/download bookkeeping and MacUpdater's real
// authenticated loopback proxy. The native Squirrel API and installer process
// are simulated; these tests do not claim OS signing or installation coverage.
const harness = async (platform) => {
const directory = await mkdtemp(join(tmpdir(), "edgeever-desktop-update-"));
cleanup.push(() => rm(directory, { recursive: true, force: true }));
const releases = new Map();
for (const version of ["2.17.0", "2.18.0", "2.19.0"]) {
const bytes = Buffer.from(`package ${version}`);
const name = platform === "darwin" ? `EdgeEver-${version}-mac-arm64.zip` : `EdgeEver-${version}-linux-x64.AppImage`;
const path = join(directory, name);
await writeFile(path, bytes);
releases.set(version, { path, bytes, info: { version, files: [{ url: name, size: bytes.length,
sha512: createHash("sha512").update(bytes).digest("base64") }] } });
}
const h = { latest: "2.17.0", checks: 0, downloads: [], installs: [], errors: [], notices: [],
prepared: [], quitFallbacks: 0, relaunches: [], checkError: null, downloadError: null, eventVersion: null,
downloadGate: null, checkGate: null, nativeGate: null, nativeError: null };
const app = { version: "2.16.1", isPackaged: true, onQuit: (handler) => { h.quitHandler = handler; } };
const updater = platform === "linux" ? new AppImageUpdater(null, app) : new BaseUpdater(null, app);
updater.logger = null;
if (platform === "linux") updater.forceDevUpdateConfig = true;
const native = new EventEmitter();
let feed;
let preparedVersion;
native.setFeedURL = (options) => { feed = options; };
native.getFeedURL = () => feed?.url;
native.checkForUpdates = () => {
void (async () => {
if (h.nativeGate) await h.nativeGate.promise;
if (h.nativeError) throw h.nativeError;
const expectedFeed = feed;
const response = await fetch(expectedFeed.url, { headers: expectedFeed.headers });
if (!response.ok) throw new Error(`Feed returned ${response.status}`);
const metadata = await response.json();
const zip = await fetch(metadata.url);
if (!zip.ok) throw new Error(`Archive returned ${zip.status}`);
const bytes = Buffer.from(await zip.arrayBuffer());
const release = [...releases].find(([, value]) => value.bytes.equals(bytes));
if (!release) throw new Error("Native updater received an unexpected ZIP");
preparedVersion = release[0];
h.prepared.push(preparedVersion);
native.emit("update-downloaded");
})().catch((error) => native.emit("error", error));
};
native.quitAndInstall = () => {
h.relaunches.push(true);
h.installs.push({ version: preparedVersion, path: releases.get(preparedVersion).path });
updater.closeServerIfExists();
};
if (platform === "darwin") {
// Initialize the real MacUpdater methods without loading Electron's native
// module into the test runner. Its global native updater is injected here.
Object.setPrototypeOf(updater, MacUpdater.prototype);
updater.nativeUpdater = native;
updater.squirrelDownloadedUpdate = false;
native.on("error", (error) => updater.emit("error", error));
native.on("update-downloaded", () => { updater.squirrelDownloadedUpdate = true; });
cleanup.push(() => { updater.closeServerIfExists(); });
}
updater.getUpdateInfoAndProvider = async () => {
h.checks++;
if (h.checkGate) await h.checkGate.promise;
if (h.checkError) throw h.checkError;
const info = h.latest ? structuredClone(releases.get(h.latest).info) : { version: "2.16.1" };
return { info, provider: {} };
};
updater.doDownloadUpdate = async ({ updateInfoAndProvider: { info } }) => {
const version = info.version;
h.downloads.push(version);
if (h.downloadGate) await h.downloadGate.promise;
if (h.downloadError) throw h.downloadError;
const path = releases.get(version).path;
updater.downloadedUpdateHelper = { file: path, downloadedFileInfo: { isAdminRightsRequired: false } };
const event = { ...info, version: h.eventVersion || version, downloadedFile: path };
if (platform === "darwin") {
return updater.updateDownloaded({ url: new URL(`https://example.invalid/${info.files[0].url}`), info: info.files[0] }, event);
}
updater.dispatchUpdateDownloaded(event);
updater.addQuitHandler();
return [path];
};
if (platform === "linux") {
updater.doInstall = ({ isForceRunAfter }) => {
h.relaunches.push(isForceRunAfter);
const path = updater.installerPath;
h.installs.push({ version: [...releases].find(([, value]) => value.path === path)[0], path });
return true;
};
updater.quitAndInstall = (silent, forceRun) => updater.install(silent, forceRun);
}
const controller = createDesktopUpdateController({
updater, platform, nativeUpdater: native,
fetchWindowsMetadata: () => { throw new Error("Windows trust must not run on another platform"); },
verifyWindowsPackage: () => { throw new Error("Windows trust must not run on another platform"); },
onStatus: () => {}, onError: (error, stage) => h.errors.push({ error, stage }),
onDownloaded: (version) => h.notices.push(version), beforeInstall: () => {},
quitApplication: () => {
if (platform === "darwin" && preparedVersion) {
h.relaunches.push(false);
h.installs.push({ version: preparedVersion, path: releases.get(preparedVersion).path });
updater.closeServerIfExists();
} else {
h.quitFallbacks++;
}
},
});
const settle = () => controller.download("wait").catch(() => {});
const ready = async () => { await controller.check("startup"); await settle(); };
return Object.assign(h, { updater, native, controller, releases, settle, ready });
};
for (const platform of ["darwin", "linux"]) {
describe(`${platform} update target lifecycle (#776)`, () => {
test("replaces a downloaded intermediate release and installs only the latest package", async () => {
const h = await harness(platform);
await h.ready();
h.latest = "2.19.0";
await h.controller.check("interval");
await h.settle();
expect(h.downloads).toEqual(["2.17.0", "2.19.0"]);
expect(h.prepared).toEqual([]);
expect(await h.controller.install()).toEqual({ started: true });
expect(h.installs).toEqual([{ version: "2.19.0", path: h.releases.get("2.19.0").path }]);
});
test("refreshes the latest target at installation time", async () => {
const h = await harness(platform);
await h.ready();
h.latest = "2.18.0";
expect(await h.controller.install()).toEqual({ started: true });
expect(h.installs.map((entry) => entry.version)).toEqual(["2.18.0"]);
});
test("normal quit refreshes the target and installs without relaunching", async () => {
const h = await harness(platform);
await h.ready();
h.latest = "2.19.0";
expect(h.controller.handleBeforeQuit({ preventDefault() {} })).toBe(true);
await h.controller.install();
expect(h.installs.map((entry) => entry.version)).toEqual(["2.19.0"]);
expect(h.relaunches).toEqual([false]);
expect(h.notices).toEqual(["2.17.0"]);
expect(h.controller.handleBeforeQuit({ preventDefault() { throw new Error("quit loop"); } })).toBe(false);
});
test("unchanged metadata does not download or prompt twice", async () => {
const h = await harness(platform);
await h.ready();
await h.controller.check("manual");
expect(h.checks).toBe(2);
expect(h.downloads).toEqual(["2.17.0"]);
expect(h.notices).toEqual(["2.17.0"]);
});
test("a stale dialog approval cannot install a replacement", async () => {
const h = await harness(platform);
await h.ready();
h.latest = "2.18.0";
expect(await h.controller.install("2.17.0")).toEqual({ started: false });
expect(h.installs).toEqual([]);
expect(h.prepared).toEqual([]);
expect(await h.controller.install("2.18.0")).toEqual({ started: true });
});
test("does not change the target while another version is downloading", async () => {
const h = await harness(platform);
await h.ready();
h.latest = "2.18.0";
h.downloadGate = deferred();
await h.controller.check("manual");
h.latest = "2.19.0";
await h.controller.check("resume");
expect(h.checks).toBe(2);
expect(h.updater.autoInstallOnAppQuit).toBe(false);
h.downloadGate.resolve();
await h.settle();
expect(h.controller.snapshot().version).toBe("2.18.0");
});
for (const failure of ["check", "download", "event-version"]) {
test(`a ${failure} failure never installs the old package`, async () => {
const h = await harness(platform);
await h.ready();
h.latest = "2.18.0";
if (failure === "check") h.checkError = new Error("offline");
if (failure === "download") h.downloadError = new Error("download failed");
if (failure === "event-version") h.eventVersion = "2.17.0";
await h.controller.check("interval").catch(() => {});
await h.settle();
expect(await h.controller.install()).toEqual({ started: false });
expect(h.installs).toEqual([]);
expect(h.updater.autoInstallOnAppQuit).toBe(false);
});
}
});
}
describe("macOS native installation", () => {
test("never stages intermediate background downloads in Squirrel", async () => {
const h = await harness("darwin");
await h.ready();
h.latest = "2.18.0";
await h.controller.check("interval");
await h.settle();
expect(h.prepared).toEqual([]);
expect(h.updater.squirrelDownloadedUpdate).toBe(false);
// Even stale bookkeeping must not shortcut preparation of the current ZIP.
h.updater.squirrelDownloadedUpdate = true;
expect(await h.controller.install()).toEqual({ started: true });
expect(h.prepared).toEqual(["2.18.0"]);
expect(h.installs.map((entry) => entry.version)).toEqual(["2.18.0"]);
});
test("normal quit refreshes and prepares the latest version before installation", async () => {
const h = await harness("darwin");
await h.ready();
h.latest = "2.19.0";
let prevented = false;
expect(h.controller.handleBeforeQuit({ preventDefault() { prevented = true; } })).toBe(true);
expect(prevented).toBe(true);
await h.controller.install();
expect(h.prepared).toEqual(["2.19.0"]);
expect(h.installs.map((entry) => entry.version)).toEqual(["2.19.0"]);
expect(h.quitFallbacks).toBe(0);
expect(h.controller.handleBeforeQuit({ preventDefault() { throw new Error("blocked final quit"); } })).toBe(false);
});
test("native signature failure does not install or retain an installation listener", async () => {
const h = await harness("darwin");
await h.ready();
h.nativeError = new Error("native code signature rejected");
expect(await h.controller.install()).toEqual({ started: false });
expect(h.installs).toEqual([]);
expect(h.native.listenerCount("update-downloaded")).toBe(1); // MacUpdater bookkeeping only.
expect(h.errors[0].stage).toBe("install");
h.nativeError = null;
h.latest = "2.19.0";
await expect(h.controller.check("interval")).rejects.toThrow("Restart EdgeEver");
h.native.emit("update-downloaded"); // Late completion cannot authorize another target.
expect(h.installs).toEqual([]);
expect(h.downloads).toEqual(["2.17.0"]);
});
test("a failed check while quitting exits without staging an older package", async () => {
const h = await harness("darwin");
await h.ready();
h.checkError = new Error("offline");
h.controller.handleBeforeQuit({ preventDefault() {} });
await h.controller.install();
expect(h.quitFallbacks).toBe(1);
expect(h.prepared).toEqual([]);
expect(h.installs).toEqual([]);
expect(h.controller.handleBeforeQuit({ preventDefault() { throw new Error("quit loop"); } })).toBe(false);
});
test("quit waits for ongoing native preparation and does not start a second install", async () => {
const h = await harness("darwin");
await h.ready();
h.nativeGate = deferred();
const install = h.controller.install();
await flush();
expect(h.controller.handleBeforeQuit({ preventDefault() {} })).toBe(true);
expect(h.installs).toEqual([]);
h.nativeGate.resolve();
await install;
expect(h.prepared).toEqual(["2.17.0"]);
expect(h.installs).toHaveLength(1);
});
test("a changed native feed cannot complete preparation of another target", async () => {
const native = new EventEmitter();
let feed = "http://127.0.0.1:1/";
native.getFeedURL = () => feed;
native.checkForUpdates = () => {
feed = "http://127.0.0.1:2/";
native.emit("update-downloaded");
};
await expect(prepareMacUpdate(native)).rejects.toThrow("feed changed");
expect(native.listenerCount("update-downloaded")).toBe(0);
expect(native.listenerCount("error")).toBe(0);
});
test("native update-not-available fails immediately and removes temporary listeners", async () => {
const native = new EventEmitter();
native.getFeedURL = () => "http://127.0.0.1:1/";
native.checkForUpdates = () => native.emit("update-not-available");
await expect(prepareMacUpdate(native)).rejects.toThrow("not available");
expect(native.listenerCount("update-downloaded")).toBe(0);
expect(native.listenerCount("error")).toBe(0);
expect(native.listenerCount("update-not-available")).toBe(0);
});
test("native timeout removes temporary listeners", async () => {
const native = new EventEmitter();
native.getFeedURL = () => "http://127.0.0.1:1/";
native.checkForUpdates = () => {};
await expect(prepareMacUpdate(native, { timeoutMs: 1 })).rejects.toThrow("timed out");
expect(native.listenerCount("update-downloaded")).toBe(0);
expect(native.listenerCount("error")).toBe(0);
});
});
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import { describe, expect, test } from "bun:test";
const mainSource = readFileSync(new URL("./index.mjs", import.meta.url), "utf8");
const controllerSource = readFileSync(new URL("./desktop-update-controller.mjs", import.meta.url), "utf8");
const preloadSource = readFileSync(new URL("../preload/index.cjs", import.meta.url), "utf8");
const noticeSource = readFileSync(new URL("../../../web/src/components/DesktopUpdateNotice.tsx", import.meta.url), "utf8");
const systemInfoSource = readFileSync(new URL("../../../web/src/components/settings/SystemInfoPanel.tsx", import.meta.url), "utf8");
@@ -17,20 +18,23 @@ describe("desktop update flow", () => {
expect(systemInfoSource).toContain('t("systemInfo.desktopDownloadLatest")');
});
test("downloads updates in the background and relaunches after installation", () => {
expect(mainSource).toContain('autoUpdater.autoDownload = process.platform !== "win32"');
expect(mainSource).toContain("autoUpdater.autoRunAppAfterInstall = true");
expect(mainSource).toContain('autoUpdater.quitAndInstall(process.platform === "win32", true)');
expect(mainSource).toContain("result?.downloadPromise");
expect(mainSource).toContain("downloadTrustedDesktopUpdate(reason)");
test("routes every desktop platform through one update controller", () => {
expect(mainSource).toContain("desktopUpdateController = createDesktopUpdateController({");
expect(mainSource).toContain("platform: process.platform");
expect(mainSource).not.toContain("windowsUpdateController");
expect(mainSource).not.toContain('updateState === "downloaded") {');
expect(mainSource).toContain("desktopUpdateController.check(reason)");
expect(mainSource).toContain("desktopUpdateController?.install(expectedVersion)");
expect(mainSource).toContain("desktopUpdateController?.download(reason)");
expect(controllerSource).toContain("void download(reason)");
expect(controllerSource).toContain("updater.autoRunAppAfterInstall = true");
});
test("fails closed around unsigned Windows automatic updates", () => {
expect(mainSource).toContain("fetchTrustedWindowsUpdate({");
expect(mainSource).toContain("verifyDownloadedWindowsUpdate({");
expect(mainSource).toContain('autoUpdater.autoInstallOnAppQuit = process.platform !== "win32"');
expect(mainSource).toContain("windowsDownloadedUpdateVerified = true");
expect(mainSource).toContain('writeDiagnostic("update.windows-package-blocked"');
test("provides Windows trust gates and macOS native installation to the shared controller", () => {
expect(mainSource).toContain("fetchTrustedWindowsUpdate({ ...options, fetchImpl: net.fetch })");
expect(mainSource).toContain("verifyWindowsPackage: verifyDownloadedWindowsUpdate");
expect(mainSource).toContain("nativeUpdater: nativeAutoUpdater");
expect(mainSource).toContain("desktopUpdateController?.handleBeforeQuit(event)");
expect(mainSource).toContain('downloadTrustedDesktopUpdate("manual-download")');
});
@@ -60,8 +64,7 @@ describe("desktop update flow", () => {
expect(mainSource).toContain('checkForDesktopUpdate("interval", { force: true })');
expect(mainSource).toContain('powerMonitor.on("resume"');
expect(mainSource).toContain('checkForDesktopUpdate("activate")');
expect(mainSource).toContain("if (updateCheckInFlight) {");
expect(mainSource).toContain("return throwOnError ? updateCheckInFlight : updateCheckInFlight.catch(() => null)");
expect(controllerSource).toContain("if (checkInFlight) return checkInFlight;");
expect(mainSource).toContain("clearInterval(updateCheckTimer)");
});
@@ -73,7 +76,7 @@ describe("desktop update flow", () => {
test("pushes update status and shows a native restart prompt after download", () => {
expect(mainSource).toContain('mainWindow.webContents.send("desktop:update-status-changed"');
expect(mainSource).toContain("promptForDownloadedUpdate(downloadedUpdateVersion)");
expect(mainSource).toContain("promptForDownloadedUpdate(version)");
expect(mainSource).toContain("title: copy.updateReadyTitle");
expect(preloadSource).toContain('ipcRenderer.on("desktop:update-status-changed"');
expect(noticeSource).toContain("bridge.onUpdateStatus");
@@ -87,7 +90,7 @@ describe("desktop update flow", () => {
expect(mainSource).not.toContain("holdAutoRestartUpdate");
expect(mainSource).not.toContain("heldUpdateVersion");
expect(mainSource).not.toContain("update.held-for-instance");
expect(mainSource).toContain("promptForDownloadedUpdate(downloadedUpdateVersion)");
expect(mainSource).toContain("promptForDownloadedUpdate(version)");
expect(systemInfoSource).toContain("clientAheadOfInstanceByPlatform");
expect(systemInfoSource).toContain("isClientAheadOfInstance");
});
+50 -178
View File
@@ -1,4 +1,4 @@
import { app, Notification, BrowserWindow, Menu, Tray, globalShortcut, nativeImage, ipcMain, session, net, protocol, shell, dialog, safeStorage, clipboard, ClipboardItem, powerMonitor, desktopCapturer, screen, utilityProcess } from "electron";
import { app, autoUpdater as nativeAutoUpdater, Notification, BrowserWindow, Menu, Tray, globalShortcut, nativeImage, ipcMain, session, net, protocol, shell, dialog, safeStorage, clipboard, ClipboardItem, powerMonitor, desktopCapturer, screen, utilityProcess } from "electron";
import { createReadStream, existsSync } from "node:fs";
import { appendFile, mkdir, open, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
@@ -60,6 +60,7 @@ import {
verifyDownloadedWindowsUpdate,
} from "./windows-update-trust.mjs";
import electronUpdater from "electron-updater";
import { createDesktopUpdateController } from "./desktop-update-controller.mjs";
import { createUpdateDiagnostic } from "./update-diagnostics.mjs";
import { createUpdateNetworkDiagnostics } from "./update-network-diagnostics.mjs";
import { normalizeUpdateProgress } from "./update-progress.mjs";
@@ -154,14 +155,11 @@ let isQuitting = false;
let updateState = "idle";
let updateError = null;
let updateProgress = null;
let updateCheckInFlight = null;
let updateDownloadInFlight = null;
let desktopUpdateController = null;
let updateCheckTimer = null;
let lastUpdateCheckAt = 0;
let downloadedUpdateVersion = null;
let promptedUpdateVersion = null;
let trustedWindowsUpdate = null;
let windowsDownloadedUpdateVerified = false;
let sidecarScopeKey = "anonymous";
let activeAccountId = null;
let shutdownCleanupStarted = false;
@@ -1249,43 +1247,11 @@ const publishDesktopUpdateStatus = () => {
mainWindow.webContents.send("desktop:update-status-changed", desktopUpdateStatus());
};
const installDownloadedUpdate = () => {
if (
updateState !== "downloaded" ||
(process.platform === "win32" && !windowsDownloadedUpdateVerified)
) return { started: false };
// The normal window close handler hides the app. Mark this as a real quit
// before electron-updater closes windows so installation can proceed.
isQuitting = true;
// Keep Windows updates silent and reuse the registered installation directory.
autoUpdater.quitAndInstall(process.platform === "win32", true);
return { started: true };
};
const installDownloadedUpdate = (expectedVersion) =>
desktopUpdateController?.install(expectedVersion) ?? Promise.resolve({ started: false });
const trackDesktopUpdateDownload = (downloadPromise, reason) => {
updateDownloadInFlight = Promise.resolve(downloadPromise)
.catch(async (error) => {
updateState = "idle";
updateProgress = null;
downloadedUpdateVersion = null;
windowsDownloadedUpdateVerified = false;
refreshTrayMenu();
recordUpdateError(error, "download");
publishDesktopUpdateStatus();
await writeDiagnostic("update.download-failed", { reason, message: error.message });
})
.finally(() => { updateDownloadInFlight = null; updateNetworkDiagnostics.stop(); });
return updateDownloadInFlight;
};
const downloadTrustedDesktopUpdate = (reason) => {
if (updateDownloadInFlight) return updateDownloadInFlight;
if (process.platform === "win32" && !trustedWindowsUpdate) {
return Promise.reject(new Error("Windows update metadata has not passed the signature gate"));
}
updateNetworkDiagnostics.setStage("download");
return trackDesktopUpdateDownload(autoUpdater.downloadUpdate(), reason);
};
const downloadTrustedDesktopUpdate = (reason) =>
desktopUpdateController?.download(reason) ?? Promise.resolve();
const promptForDownloadedUpdate = async (version) => {
const promptKey = version || "unknown";
@@ -1304,69 +1270,17 @@ const promptForDownloadedUpdate = async (version) => {
noLink: true,
});
if (result.response === 0) {
void writeDiagnostic("update.install-confirmed", { version });
installDownloadedUpdate();
void installDownloadedUpdate(version);
} else {
void writeDiagnostic("update.install-deferred", { version });
}
};
const checkForDesktopUpdate = (reason, { force = false, throwOnError = false } = {}) => {
if (!app.isPackaged || process.env.EDGE_EVER_DISABLE_AUTO_UPDATE === "1" || updateState === "downloaded") {
return Promise.resolve(null);
}
if (updateCheckInFlight) {
return throwOnError ? updateCheckInFlight : updateCheckInFlight.catch(() => null);
}
if (updateDownloadInFlight) return Promise.resolve(null);
const now = Date.now();
if (!force && now - lastUpdateCheckAt < updateCheckFocusThrottleMs) return Promise.resolve(null);
lastUpdateCheckAt = now;
updateError = null;
updateNetworkDiagnostics.beginCheck();
publishDesktopUpdateStatus();
let diagnosticStage = "check";
void writeDiagnostic("update.check-started", { reason });
updateCheckInFlight = autoUpdater.checkForUpdates()
.then(async (result) => {
if (process.platform === "win32" && result?.isUpdateAvailable) {
diagnosticStage = "verify-windows-metadata";
updateNetworkDiagnostics.setStage(diagnosticStage);
trustedWindowsUpdate = await fetchTrustedWindowsUpdate({
version: result.updateInfo.version,
updateInfo: result.updateInfo,
fetchImpl: net.fetch,
});
windowsDownloadedUpdateVerified = false;
void writeDiagnostic("update.windows-manifest-verified", {
version: trustedWindowsUpdate.version,
keyId: trustedWindowsUpdate.keyId,
});
void downloadTrustedDesktopUpdate(reason);
}
if (result?.downloadPromise) {
updateNetworkDiagnostics.setStage("download");
trackDesktopUpdateDownload(result.downloadPromise, reason);
}
return result;
})
.catch(async (error) => {
updateState = "idle";
updateProgress = null;
downloadedUpdateVersion = null;
trustedWindowsUpdate = null;
windowsDownloadedUpdateVerified = false;
refreshTrayMenu();
recordUpdateError(error, diagnosticStage);
publishDesktopUpdateStatus();
await writeDiagnostic("update.check-failed", { reason, message: error.message });
throw error;
})
.finally(() => {
updateCheckInFlight = null;
if (!updateDownloadInFlight) updateNetworkDiagnostics.stop();
});
return throwOnError ? updateCheckInFlight : updateCheckInFlight.catch(() => null);
if (!desktopUpdateController) return Promise.resolve(null);
if (!force && Date.now() - lastUpdateCheckAt < updateCheckFocusThrottleMs) return Promise.resolve(null);
const result = desktopUpdateController.check(reason);
return throwOnError ? result : result.catch(() => null);
};
const configureAutoUpdater = () => {
@@ -1395,95 +1309,52 @@ const configureAutoUpdater = () => {
appImage: process.env.APPIMAGE || null,
});
}
autoUpdater.autoDownload = process.platform !== "win32";
autoUpdater.autoInstallOnAppQuit = process.platform !== "win32";
autoUpdater.autoRunAppAfterInstall = true;
autoUpdater.on("update-available", (info) => {
updateState = "available";
updateProgress = null;
downloadedUpdateVersion = info?.version || null;
if (process.platform === "win32") {
trustedWindowsUpdate = null;
windowsDownloadedUpdateVerified = false;
}
refreshTrayMenu();
publishDesktopUpdateStatus();
void writeDiagnostic("update.available", { version: info?.version });
});
autoUpdater.on("update-not-available", () => {
updateState = "idle";
updateProgress = null;
downloadedUpdateVersion = null;
trustedWindowsUpdate = null;
windowsDownloadedUpdateVerified = false;
refreshTrayMenu();
publishDesktopUpdateStatus();
const diagnosticWritten = writeDiagnostic("update.not-available");
if (linuxUpdateTestMode) {
void diagnosticWritten.finally(() => setTimeout(() => app.quit(), 100));
}
});
autoUpdater.on("download-progress", (progress) => {
updateProgress = normalizeUpdateProgress(progress);
publishDesktopUpdateStatus();
void writeDiagnostic("update.download-progress", { percent: progress.percent });
});
autoUpdater.on("update-downloaded", (info) => {
void (async () => {
if (process.platform === "win32") {
if (!trustedWindowsUpdate || trustedWindowsUpdate.version !== info?.version) {
throw new Error("Downloaded Windows update has no matching trusted manifest");
}
await verifyDownloadedWindowsUpdate({
path: info.downloadedFile,
manifest: trustedWindowsUpdate,
});
windowsDownloadedUpdateVerified = true;
autoUpdater.autoInstallOnAppQuit = true;
}
updateState = "downloaded";
updateProgress = null;
desktopUpdateController = createDesktopUpdateController({
updater: autoUpdater,
platform: process.platform,
nativeUpdater: nativeAutoUpdater,
quitApplication: () => app.quit(),
fetchWindowsMetadata: (options) => fetchTrustedWindowsUpdate({ ...options, fetchImpl: net.fetch }),
verifyWindowsPackage: verifyDownloadedWindowsUpdate,
onStatus: (status) => {
updateState = status.state;
downloadedUpdateVersion = status.version;
updateProgress = status.progress ? normalizeUpdateProgress(status.progress) : null;
updateError = null;
downloadedUpdateVersion = info?.version || downloadedUpdateVersion;
refreshTrayMenu();
publishDesktopUpdateStatus();
await writeDiagnostic("update.downloaded", { version: downloadedUpdateVersion });
},
onError: (error, stage) => {
isQuitting = false;
recordUpdateError(error, stage);
publishDesktopUpdateStatus();
},
onCheckStarted: (reason) => {
lastUpdateCheckAt = Date.now();
updateError = null;
updateNetworkDiagnostics.beginCheck();
publishDesktopUpdateStatus();
void writeDiagnostic("update.check-started", { reason });
},
onStage: (stage) => updateNetworkDiagnostics.setStage(stage),
onSettled: () => updateNetworkDiagnostics.stop(),
log: (event, details) => {
const written = writeDiagnostic(event, details);
if (linuxUpdateTestMode && event === "update.not-available") {
void written.finally(() => setTimeout(() => app.quit(), 100));
}
},
beforeInstall: () => { isQuitting = true; },
onDownloaded: (version) => {
if (linuxUpdateTestMode) {
installDownloadedUpdate();
void installDownloadedUpdate(version);
return;
}
await promptForDownloadedUpdate(downloadedUpdateVersion).catch((error) => {
void promptForDownloadedUpdate(version).catch((error) => {
promptedUpdateVersion = null;
void writeDiagnostic("update.prompt-failed", { message: error.message });
});
})().catch(async (error) => {
if (process.platform !== "win32") {
await writeDiagnostic("update.download-handler-failed", { message: error.message });
return;
}
autoUpdater.autoInstallOnAppQuit = false;
updateState = "idle";
updateProgress = null;
downloadedUpdateVersion = null;
windowsDownloadedUpdateVerified = false;
refreshTrayMenu();
recordUpdateError(error, "verify-windows-package");
publishDesktopUpdateStatus();
await writeDiagnostic("update.windows-package-blocked", { message: error.message });
});
});
autoUpdater.on("error", (error) => {
isQuitting = false;
if (updateState !== "downloaded") {
updateState = "idle";
updateProgress = null;
downloadedUpdateVersion = null;
windowsDownloadedUpdateVerified = false;
}
refreshTrayMenu();
recordUpdateError(error, updateDownloadInFlight ? "download" : "check");
publishDesktopUpdateStatus();
void writeDiagnostic("update.error", { message: error.message });
},
});
void checkForDesktopUpdate("startup", { force: true });
updateCheckTimer = setInterval(() => {
@@ -2598,6 +2469,7 @@ app.on("window-all-closed", () => {
});
app.on("before-quit", (event) => {
if (desktopUpdateController?.handleBeforeQuit(event)) return;
if (shutdownCleanupStarted) return;
event.preventDefault();
shutdownCleanupStarted = true;
@@ -0,0 +1,28 @@
// Keep Squirrel out of background downloads. Its staged update can otherwise
// survive a later ZIP download and be installed instead of the selected target.
// Only prepare the current loopback feed once the controller has frozen it.
export const prepareMacUpdate = (nativeUpdater, { timeoutMs = 120_000 } = {}) => new Promise((resolve, reject) => {
const feed = nativeUpdater.getFeedURL();
let timer;
const cleanup = () => {
clearTimeout(timer);
nativeUpdater.removeListener("update-downloaded", downloaded);
nativeUpdater.removeListener("error", failed);
nativeUpdater.removeListener("update-not-available", unavailable);
};
const failed = (error) => { cleanup(); reject(error); };
const unavailable = () => failed(new Error("Native macOS update is not available for the selected package"));
const downloaded = () => {
cleanup();
if (nativeUpdater.getFeedURL() !== feed) {
reject(new Error("Native macOS update feed changed during preparation"));
} else {
resolve();
}
};
nativeUpdater.once("update-downloaded", downloaded);
nativeUpdater.once("error", failed);
nativeUpdater.once("update-not-available", unavailable);
timer = setTimeout(() => failed(new Error("Native macOS update preparation timed out")), timeoutMs);
try { nativeUpdater.checkForUpdates(); } catch (error) { failed(error); }
});
@@ -1,9 +1,14 @@
import { describe, expect, test } from "bun:test";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { IDBFactory } from "fake-indexeddb";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runInThisContext } from "node:vm";
import {
RENDERER_STORAGE_MIGRATION_MARKER,
RENDERER_STORAGE_MIGRATION_VERSION,
exportLegacyRendererStorage,
importRendererStorage,
migrateRendererStorageOrigin,
rendererStorageMigrationDatabaseNames,
} from "./renderer-storage-migration.mjs";
@@ -98,3 +103,587 @@ describe("renderer storage origin migration", () => {
}
});
});
const requestDone = (request) => new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
const transactionComplete = (transaction) => new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error);
transaction.onerror = () => reject(transaction.error);
});
const createLocalStorage = (initial = []) => {
const entries = new Map(initial);
return {
get length() { return entries.size; },
key(index) { return Array.from(entries.keys())[index] ?? null; },
getItem(key) { return entries.has(key) ? entries.get(key) : null; },
setItem(key, value) { entries.set(key, String(value)); },
removeItem(key) { entries.delete(key); },
clear() { entries.clear(); },
entries() { return Array.from(entries.entries()); },
};
};
const createOrigin = (localStorageEntries = []) => ({
indexedDB: new IDBFactory(),
localStorage: createLocalStorage(localStorageEntries),
});
const originalGlobals = {
indexedDB: Object.getOwnPropertyDescriptor(globalThis, "indexedDB"),
localStorage: Object.getOwnPropertyDescriptor(globalThis, "localStorage"),
};
const activateOrigin = (origin) => {
Object.defineProperty(globalThis, "indexedDB", { value: origin.indexedDB, configurable: true, writable: true });
Object.defineProperty(globalThis, "localStorage", { value: origin.localStorage, configurable: true, writable: true });
};
const restoreGlobals = () => {
for (const [name, descriptor] of Object.entries(originalGlobals)) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else delete globalThis[name];
}
};
const openDatabase = (factory, name, version, upgrade) => {
const request = factory.open(name, version);
if (upgrade) request.onupgradeneeded = () => upgrade(request.result);
return requestDone(request);
};
const readStore = async (factory, databaseName, storeName) => {
const database = await openDatabase(factory, databaseName);
try {
const transaction = database.transaction(storeName, "readonly");
const store = transaction.objectStore(storeName);
const [keys, values] = await Promise.all([requestDone(store.getAllKeys()), requestDone(store.getAll())]);
await transactionComplete(transaction);
return keys.map((key, index) => ({ key, value: values[index] }));
} finally {
database.close();
}
};
const seedLegacyNotes = async (factory) => {
const database = await openDatabase(factory, "edgeever-local", 60, (db) => {
const memos = db.createObjectStore("memos", { keyPath: ["scope", "id"] });
memos.createIndex("notebookId", "notebookId");
memos.createIndex("tags", "tags", { multiEntry: true });
const syncQueue = db.createObjectStore("syncQueue", { keyPath: "id" });
syncQueue.createIndex("id", "id", { unique: true });
db.createObjectStore("counters", { autoIncrement: true });
db.createObjectStore("outOfLine");
});
const transaction = database.transaction(["memos", "syncQueue", "counters", "outOfLine"], "readwrite");
transaction.objectStore("memos").put({ scope: "local", id: "memo-1", notebookId: "nb-1", tags: ["a", "b"], title: "First" });
transaction.objectStore("memos").put({ scope: "local", id: "memo-2", notebookId: "nb-2", tags: [], title: "Second </script>" });
transaction.objectStore("syncQueue").put({ id: "op-1", memoId: "memo-1", status: "pending" });
transaction.objectStore("counters").put({ value: 1 });
transaction.objectStore("counters").put({ value: 2 });
transaction.objectStore("outOfLine").put({ note: "out of line" }, "custom-key");
await transactionComplete(transaction);
database.close();
};
const seedLegacyPackages = async (factory) => {
const database = await openDatabase(factory, "edgeever-plugin-packages-v1", 2, (db) => {
const store = db.createObjectStore("packageVersions", { keyPath: ["pluginId", "version"] });
store.createIndex("pluginId", "pluginId");
});
const transaction = database.transaction("packageVersions", "readwrite");
transaction.objectStore("packageVersions").put({ pluginId: "com.example.alpha", version: "1.0.0", mainJs: "main()", stylesCss: null });
await transactionComplete(transaction);
database.close();
};
const seedLegacySecrets = async (factory, secrets, { withKey = true } = {}) => {
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
const database = await openDatabase(factory, "edgeever-plugin-secrets-v1", 1, (db) => {
db.createObjectStore("keys");
db.createObjectStore("secrets", { keyPath: "id" });
});
const records = await Promise.all(secrets.map(async ([id, value]) => {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(value));
return { id, iv, ciphertext };
}));
const transaction = database.transaction(["keys", "secrets"], "readwrite");
if (withKey) transaction.objectStore("keys").put(key, "device-master-key");
for (const record of records) transaction.objectStore("secrets").put(record);
await transactionComplete(transaction);
database.close();
};
const readPluginSecrets = async (factory) => {
const database = await openDatabase(factory, "edgeever-plugin-secrets-v1");
try {
const transaction = database.transaction(["keys", "secrets"], "readonly");
const key = await requestDone(transaction.objectStore("keys").get("device-master-key"));
const records = await requestDone(transaction.objectStore("secrets").getAll());
await transactionComplete(transaction);
const values = {};
for (const record of records) {
values[record.id] = new TextDecoder().decode(await crypto.subtle.decrypt({ name: "AES-GCM", iv: record.iv }, key, record.ciphertext));
}
return { key, records, values };
} finally {
database.close();
}
};
const databaseNames = async (factory) => (await factory.databases()).map((entry) => entry.name).sort();
const seedFullLegacyOrigin = async () => {
const legacy = createOrigin([["theme", "dark"], ["edgeever:session", "{\"user\":\"u1\"}"]]);
await seedLegacyNotes(legacy.indexedDB);
await seedLegacyPackages(legacy.indexedDB);
await seedLegacySecrets(legacy.indexedDB, [["com.example.alpha:token", "alpha-secret"], ["com.example.beta:token", "beta-secret"]]);
return legacy;
};
// Mirrors how Electron moves the snapshot: the export result crosses IPC via
// structured clone and the import argument is embedded as JSON source text.
const transferSnapshot = (snapshot) => JSON.parse(JSON.stringify(snapshot));
describe("exportLegacyRendererStorage", () => {
afterEach(restoreGlobals);
test("returns an empty snapshot for an origin without stored data", async () => {
activateOrigin(createOrigin());
expect(await exportLegacyRendererStorage()).toEqual({
version: 1,
localStorageEntries: [],
databases: [],
pluginSecrets: [],
});
});
test("captures schemas, records, decrypted plugin secrets and localStorage", async () => {
const legacy = await seedFullLegacyOrigin();
await openDatabase(legacy.indexedDB, "unrelated-database", 1, (db) => db.createObjectStore("items")).then((db) => db.close());
activateOrigin(legacy);
const snapshot = await exportLegacyRendererStorage();
expect(snapshot.version).toBe(1);
expect(snapshot.localStorageEntries).toEqual([["theme", "dark"], ["edgeever:session", "{\"user\":\"u1\"}"]]);
expect(snapshot.databases.map((database) => database.name)).toEqual(["edgeever-local", "edgeever-plugin-packages-v1"]);
const notes = snapshot.databases[0];
expect(notes.version).toBe(60);
const memos = notes.stores.find((store) => store.name === "memos");
expect(memos.keyPath).toEqual(["scope", "id"]);
expect(memos.autoIncrement).toBe(false);
expect(memos.indexes).toEqual(expect.arrayContaining([
{ name: "notebookId", keyPath: "notebookId", multiEntry: false, unique: false },
{ name: "tags", keyPath: "tags", multiEntry: true, unique: false },
]));
expect(memos.records).toHaveLength(2);
expect(memos.records[0].key).toEqual(["local", "memo-1"]);
const counters = notes.stores.find((store) => store.name === "counters");
expect(counters.autoIncrement).toBe(true);
expect(counters.keyPath).toBeNull();
expect(counters.records).toEqual([{ key: 1, value: { value: 1 } }, { key: 2, value: { value: 2 } }]);
expect(notes.stores.find((store) => store.name === "outOfLine").records).toEqual([
{ key: "custom-key", value: { note: "out of line" } },
]);
expect(snapshot.pluginSecrets).toEqual([
{ id: "com.example.alpha:token", value: "alpha-secret" },
{ id: "com.example.beta:token", value: "beta-secret" },
]);
});
test("leaves the legacy origin untouched", async () => {
const legacy = await seedFullLegacyOrigin();
activateOrigin(legacy);
await exportLegacyRendererStorage();
expect(await databaseNames(legacy.indexedDB)).toEqual([
"edgeever-local",
"edgeever-plugin-packages-v1",
"edgeever-plugin-secrets-v1",
]);
expect(await readStore(legacy.indexedDB, "edgeever-local", "memos")).toHaveLength(2);
expect((await readPluginSecrets(legacy.indexedDB)).values["com.example.alpha:token"]).toBe("alpha-secret");
expect(legacy.localStorage.entries()).toHaveLength(2);
});
test("fails instead of dropping secrets when the device key is missing", async () => {
const legacy = createOrigin();
await seedLegacySecrets(legacy.indexedDB, [["com.example.alpha:token", "alpha-secret"]], { withKey: false });
activateOrigin(legacy);
await expect(exportLegacyRendererStorage()).rejects.toThrow("Plugin secret key is missing");
});
test("fails when a stored secret cannot be decrypted", async () => {
const legacy = createOrigin();
await seedLegacySecrets(legacy.indexedDB, [["com.example.alpha:token", "alpha-secret"]]);
const database = await openDatabase(legacy.indexedDB, "edgeever-plugin-secrets-v1");
const transaction = database.transaction("secrets", "readwrite");
transaction.objectStore("secrets").put({ id: "com.example.alpha:token", iv: new Uint8Array(12), ciphertext: new Uint8Array(32).buffer });
await transactionComplete(transaction);
database.close();
activateOrigin(legacy);
await expect(exportLegacyRendererStorage()).rejects.toThrow();
});
test("tolerates a secret key without secrets and a secret database without stores", async () => {
const withKeyOnly = createOrigin();
await seedLegacySecrets(withKeyOnly.indexedDB, []);
activateOrigin(withKeyOnly);
expect((await exportLegacyRendererStorage()).pluginSecrets).toEqual([]);
const withoutStores = createOrigin();
await openDatabase(withoutStores.indexedDB, "edgeever-plugin-secrets-v1", 1).then((db) => db.close());
activateOrigin(withoutStores);
expect((await exportLegacyRendererStorage()).pluginSecrets).toEqual([]);
});
});
describe("importRendererStorage", () => {
afterEach(restoreGlobals);
test("rejects missing or unsupported snapshots before touching storage", async () => {
const target = createOrigin([["keep", "me"]]);
activateOrigin(target);
await expect(importRendererStorage(null)).rejects.toThrow("Unsupported renderer storage migration snapshot");
await expect(importRendererStorage({ version: 2, localStorageEntries: [], databases: [], pluginSecrets: [] }))
.rejects.toThrow("Unsupported renderer storage migration snapshot");
expect(target.localStorage.entries()).toEqual([["keep", "me"]]);
});
test("restores every record, schema and secret into the new origin", async () => {
const legacy = await seedFullLegacyOrigin();
activateOrigin(legacy);
const snapshot = transferSnapshot(await exportLegacyRendererStorage());
const target = createOrigin([["stale", "value"]]);
activateOrigin(target);
const counts = await importRendererStorage(snapshot);
expect(counts).toEqual({ localStorageEntries: 2, databases: 2, pluginSecrets: 2 });
expect(target.localStorage.entries()).toEqual([["theme", "dark"], ["edgeever:session", "{\"user\":\"u1\"}"]]);
expect(await readStore(target.indexedDB, "edgeever-local", "memos")).toEqual(await readStore(legacy.indexedDB, "edgeever-local", "memos"));
expect(await readStore(target.indexedDB, "edgeever-local", "syncQueue")).toEqual(await readStore(legacy.indexedDB, "edgeever-local", "syncQueue"));
expect(await readStore(target.indexedDB, "edgeever-local", "counters")).toEqual(await readStore(legacy.indexedDB, "edgeever-local", "counters"));
expect(await readStore(target.indexedDB, "edgeever-local", "outOfLine")).toEqual([{ key: "custom-key", value: { note: "out of line" } }]);
expect(await readStore(target.indexedDB, "edgeever-plugin-packages-v1", "packageVersions")).toHaveLength(1);
const database = await openDatabase(target.indexedDB, "edgeever-local");
try {
expect(database.version).toBe(60);
const transaction = database.transaction(["memos", "syncQueue", "counters"], "readwrite");
const memos = transaction.objectStore("memos");
expect(memos.index("tags").multiEntry).toBe(true);
expect(transaction.objectStore("syncQueue").index("id").unique).toBe(true);
expect(await requestDone(memos.index("tags").getAll("b"))).toHaveLength(1);
expect(await requestDone(transaction.objectStore("counters").add({ value: 3 }))).toBe(3);
await transactionComplete(transaction);
} finally {
database.close();
}
const secrets = await readPluginSecrets(target.indexedDB);
expect(secrets.values).toEqual({
"com.example.alpha:token": "alpha-secret",
"com.example.beta:token": "beta-secret",
});
expect(secrets.key.extractable).toBe(false);
for (const record of secrets.records) {
expect(record.iv).toHaveLength(12);
expect(new TextDecoder().decode(record.ciphertext)).not.toContain("secret");
}
});
test("is idempotent: importing the same snapshot twice does not duplicate records", async () => {
const legacy = await seedFullLegacyOrigin();
activateOrigin(legacy);
const snapshot = transferSnapshot(await exportLegacyRendererStorage());
const target = createOrigin();
activateOrigin(target);
await importRendererStorage(snapshot);
await importRendererStorage(snapshot);
expect(await readStore(target.indexedDB, "edgeever-local", "memos")).toHaveLength(2);
expect(await readStore(target.indexedDB, "edgeever-local", "counters")).toEqual([
{ key: 1, value: { value: 1 } },
{ key: 2, value: { value: 2 } },
]);
expect(Object.keys((await readPluginSecrets(target.indexedDB)).values)).toHaveLength(2);
expect(target.localStorage.entries()).toHaveLength(2);
});
test("replaces stale target data and removes stale plugin secrets when none are migrated", async () => {
const target = createOrigin();
await seedLegacyNotes(target.indexedDB);
await seedLegacySecrets(target.indexedDB, [["com.example.stale:token", "stale"]]);
activateOrigin(target);
const counts = await importRendererStorage({
version: 1,
localStorageEntries: [["valid", "yes"], ["number", 1], [2, "key"], ["null", null]],
databases: [{
name: "edgeever-local",
version: 60,
stores: [{ name: "memos", keyPath: ["scope", "id"], autoIncrement: false, indexes: [], records: [
{ key: ["local", "memo-9"], value: { scope: "local", id: "memo-9" } },
] }],
}, {
name: "edgeever-plugin-packages-v1",
version: 2,
stores: [],
}],
pluginSecrets: [],
});
expect(counts).toEqual({ localStorageEntries: 4, databases: 2, pluginSecrets: 0 });
expect(target.localStorage.entries()).toEqual([["valid", "yes"]]);
expect(await readStore(target.indexedDB, "edgeever-local", "memos")).toEqual([
{ key: ["local", "memo-9"], value: { scope: "local", id: "memo-9" } },
]);
const database = await openDatabase(target.indexedDB, "edgeever-local");
expect(Array.from(database.objectStoreNames)).toEqual(["memos"]);
database.close();
const packages = await openDatabase(target.indexedDB, "edgeever-plugin-packages-v1");
expect(packages.version).toBe(2);
expect(Array.from(packages.objectStoreNames)).toEqual([]);
packages.close();
expect(await databaseNames(target.indexedDB)).toEqual(["edgeever-local", "edgeever-plugin-packages-v1"]);
});
test("rejects when an open connection blocks the reset and succeeds once it is released", async () => {
const legacy = await seedFullLegacyOrigin();
activateOrigin(legacy);
const snapshot = transferSnapshot(await exportLegacyRendererStorage());
const target = createOrigin();
await seedLegacyNotes(target.indexedDB);
const blocker = await openDatabase(target.indexedDB, "edgeever-local");
activateOrigin(target);
await expect(importRendererStorage(snapshot)).rejects.toThrow("Reset of edgeever-local was blocked");
blocker.close();
expect(await importRendererStorage(snapshot)).toEqual({ localStorageEntries: 2, databases: 2, pluginSecrets: 2 });
expect(await readStore(target.indexedDB, "edgeever-local", "memos")).toEqual(await readStore(legacy.indexedDB, "edgeever-local", "memos"));
expect(Object.keys((await readPluginSecrets(target.indexedDB)).values)).toHaveLength(2);
});
});
const createEvaluatingWindow = (origins, log) => () => {
let destroyed = false;
return {
async loadURL(url) {
log.push(["load", url]);
if (!origins[url]) throw new Error(`Cannot load ${url}`);
activateOrigin(origins[url]);
},
destroy() { destroyed = true; log.push(["destroy"]); },
isDestroyed() { return destroyed; },
webContents: {
async executeJavaScript(code, userGesture) {
log.push(["execute", code, userGesture]);
// Runs the injected script in the global scope, like the renderer would.
return structuredClone(await runInThisContext(code));
},
},
};
};
describe("migrateRendererStorageOrigin end to end", () => {
let directory;
let markerPath;
const legacyBridgeUrl = "file:///bundle/desktop-storage-bridge.html";
const targetBridgeUrl = "edgeever-app://app/desktop-storage-bridge.html";
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), "edgeever-origin-migration-e2e-"));
markerPath = join(directory, RENDERER_STORAGE_MIGRATION_MARKER);
});
afterEach(async () => {
restoreGlobals();
await rm(directory, { recursive: true, force: true });
});
test("moves all data to the new origin, keeps the legacy copy and writes the marker", async () => {
const legacy = await seedFullLegacyOrigin();
const target = createOrigin();
const log = [];
const result = await migrateRendererStorageOrigin({
createWindow: createEvaluatingWindow({ [legacyBridgeUrl]: legacy, [targetBridgeUrl]: target }, log),
legacyBridgeUrl,
targetBridgeUrl,
markerPath,
});
expect(result).toEqual({ state: "migrated", counts: { localStorageEntries: 2, databases: 2, pluginSecrets: 2 } });
expect(log.map(([kind, value]) => (kind === "execute" ? kind : value ? `${kind}:${value}` : kind))).toEqual([
`load:${legacyBridgeUrl}`,
"execute",
`load:${targetBridgeUrl}`,
"execute",
"destroy",
]);
const importCode = log.filter(([kind]) => kind === "execute")[1][1];
expect(importCode).not.toContain("</script>");
expect(log.filter(([kind]) => kind === "execute").every(([, , userGesture]) => userGesture === true)).toBe(true);
expect(await readStore(target.indexedDB, "edgeever-local", "memos")).toEqual(await readStore(legacy.indexedDB, "edgeever-local", "memos"));
expect((await readStore(target.indexedDB, "edgeever-local", "memos"))[1].value.title).toBe("Second </script>");
expect((await readPluginSecrets(target.indexedDB)).values["com.example.beta:token"]).toBe("beta-secret");
expect(target.localStorage.entries()).toEqual(legacy.localStorage.entries());
expect(await databaseNames(legacy.indexedDB)).toHaveLength(3);
const marker = JSON.parse(await readFile(markerPath, "utf8"));
expect(marker.version).toBe(RENDERER_STORAGE_MIGRATION_VERSION);
expect(Number.isNaN(Date.parse(marker.migratedAt))).toBe(false);
expect(await Bun.file(`${markerPath}.tmp`).exists()).toBe(false);
const again = await migrateRendererStorageOrigin({
createWindow: () => { throw new Error("should not create a window"); },
legacyBridgeUrl,
targetBridgeUrl,
markerPath,
});
expect(again).toEqual({ state: "already-migrated" });
});
test("an interrupted export leaves no marker and no target data, and a retry completes", async () => {
const legacy = createOrigin([["theme", "dark"]]);
await seedLegacyNotes(legacy.indexedDB);
await seedLegacySecrets(legacy.indexedDB, [["com.example.alpha:token", "alpha-secret"]], { withKey: false });
const target = createOrigin([["target-only", "kept"]]);
const log = [];
await expect(migrateRendererStorageOrigin({
createWindow: createEvaluatingWindow({ [legacyBridgeUrl]: legacy, [targetBridgeUrl]: target }, log),
legacyBridgeUrl,
targetBridgeUrl,
markerPath,
})).rejects.toThrow("Plugin secret key is missing");
expect(log.at(-1)).toEqual(["destroy"]);
expect(log.some(([kind, url]) => kind === "load" && url === targetBridgeUrl)).toBe(false);
expect(await Bun.file(markerPath).exists()).toBe(false);
expect(await databaseNames(target.indexedDB)).toEqual([]);
expect(target.localStorage.entries()).toEqual([["target-only", "kept"]]);
const database = await openDatabase(legacy.indexedDB, "edgeever-plugin-secrets-v1");
const transaction = database.transaction("secrets", "readwrite");
transaction.objectStore("secrets").clear();
await transactionComplete(transaction);
database.close();
const retry = await migrateRendererStorageOrigin({
createWindow: createEvaluatingWindow({ [legacyBridgeUrl]: legacy, [targetBridgeUrl]: target }, []),
legacyBridgeUrl,
targetBridgeUrl,
markerPath,
});
expect(retry.state).toBe("migrated");
expect(await readStore(target.indexedDB, "edgeever-local", "memos")).toHaveLength(2);
expect(await Bun.file(markerPath).exists()).toBe(true);
});
test("a failed import followed by a retry yields exactly one copy of each record", async () => {
const legacy = await seedFullLegacyOrigin();
const target = createOrigin();
await seedLegacyNotes(target.indexedDB);
const blocker = await openDatabase(target.indexedDB, "edgeever-local");
const origins = { [legacyBridgeUrl]: legacy, [targetBridgeUrl]: target };
await expect(migrateRendererStorageOrigin({
createWindow: createEvaluatingWindow(origins, []),
legacyBridgeUrl,
targetBridgeUrl,
markerPath,
})).rejects.toThrow("was blocked");
expect(await Bun.file(markerPath).exists()).toBe(false);
blocker.close();
expect((await migrateRendererStorageOrigin({
createWindow: createEvaluatingWindow(origins, []),
legacyBridgeUrl,
targetBridgeUrl,
markerPath,
})).state).toBe("migrated");
expect(await readStore(target.indexedDB, "edgeever-local", "memos")).toHaveLength(2);
expect(await readStore(target.indexedDB, "edgeever-local", "counters")).toHaveLength(2);
expect(Object.keys((await readPluginSecrets(target.indexedDB)).values)).toHaveLength(2);
});
test("a leftover temporary marker from an interrupted run does not count as migrated", async () => {
await writeFile(`${markerPath}.tmp`, "{\"partial\":");
const legacy = await seedFullLegacyOrigin();
const target = createOrigin();
const result = await migrateRendererStorageOrigin({
createWindow: createEvaluatingWindow({ [legacyBridgeUrl]: legacy, [targetBridgeUrl]: target }, []),
legacyBridgeUrl,
targetBridgeUrl,
markerPath,
});
expect(result.state).toBe("migrated");
expect(JSON.parse(await readFile(markerPath, "utf8")).counts.databases).toBe(2);
expect(await Bun.file(`${markerPath}.tmp`).exists()).toBe(false);
});
test("does not write a marker when it cannot be persisted", async () => {
const missingDirectoryMarker = join(directory, "missing", RENDERER_STORAGE_MIGRATION_MARKER);
const log = [];
await expect(migrateRendererStorageOrigin({
createWindow: createEvaluatingWindow({ [legacyBridgeUrl]: createOrigin(), [targetBridgeUrl]: createOrigin() }, log),
legacyBridgeUrl,
targetBridgeUrl,
markerPath: missingDirectoryMarker,
})).rejects.toThrow();
expect(await Bun.file(missingDirectoryMarker).exists()).toBe(false);
expect(log.at(-1)).toEqual(["destroy"]);
});
test("destroys the window when a bridge page fails to load", async () => {
const log = [];
await expect(migrateRendererStorageOrigin({
createWindow: createEvaluatingWindow({ [legacyBridgeUrl]: createOrigin() }, log),
legacyBridgeUrl,
targetBridgeUrl,
markerPath,
})).rejects.toThrow(`Cannot load ${targetBridgeUrl}`);
expect(log.at(-1)).toEqual(["destroy"]);
expect(await Bun.file(markerPath).exists()).toBe(false);
});
test("propagates window creation failures and skips destroying an already destroyed window", async () => {
await expect(migrateRendererStorageOrigin({
createWindow: () => { throw new Error("no window"); },
legacyBridgeUrl,
targetBridgeUrl,
markerPath,
})).rejects.toThrow("no window");
let destroyCalls = 0;
await expect(migrateRendererStorageOrigin({
createWindow: () => ({
async loadURL() { throw new Error("renderer gone"); },
destroy() { destroyCalls += 1; },
isDestroyed() { return true; },
webContents: { async executeJavaScript() { return null; } },
}),
legacyBridgeUrl,
targetBridgeUrl,
markerPath,
})).rejects.toThrow("renderer gone");
expect(destroyCalls).toBe(0);
expect(await Bun.file(markerPath).exists()).toBe(false);
});
});
@@ -0,0 +1,329 @@
import { createRequire } from "node:module";
import { createHash, generateKeyPairSync, sign } from "node:crypto";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "bun:test";
import { createDesktopUpdateController } from "./desktop-update-controller.mjs";
import { WINDOWS_UPDATE_KEY_ID, verifyWindowsUpdateMetadata, verifyDownloadedWindowsUpdate } from "./windows-update-trust.mjs";
// Exercise the installed electron-updater's real check, download promise and
// quit-handler behavior. Replace only network/package acquisition and process
// spawning; no Electron app or Windows installer is launched by these tests.
const require = createRequire(new URL("../../package.json", import.meta.url));
const { BaseUpdater } = require("electron-updater/out/BaseUpdater.js");
const deferred = () => {
let resolve, reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
};
const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
const directories = [];
afterEach(async () => {
await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
});
const harness = async () => {
const directory = await mkdtemp(join(tmpdir(), "edgeever-update-target-"));
directories.push(directory);
const keys = generateKeyPairSync("ed25519");
const releases = new Map();
for (const version of ["2.17.0", "2.18.0", "2.19.0"]) {
const bytes = Buffer.from(`installer ${version}`);
const name = `EdgeEver-${version}-windows-x64.exe`;
const path = join(directory, name);
await writeFile(path, bytes);
const manifest = {
schemaVersion: 1, keyId: WINDOWS_UPDATE_KEY_ID, version, platform: "win32", arch: "x64",
releaseDate: "2026-11-03T00:00:00.000Z",
file: {
name, size: bytes.length,
sha512: createHash("sha512").update(bytes).digest("base64"),
sha256: createHash("sha256").update(bytes).digest("hex"),
},
};
const info = { version, files: [{ url: name, size: bytes.length, sha512: manifest.file.sha512 }] };
const manifestBytes = Buffer.from(JSON.stringify(manifest));
const signatureBytes = Buffer.from(JSON.stringify({ schemaVersion: 1, keyId: WINDOWS_UPDATE_KEY_ID,
signature: sign(null, manifestBytes, keys.privateKey).toString("base64") }));
releases.set(version, { path, info, manifest, manifestBytes, signatureBytes });
}
const h = { latest: "2.17.0", checks: 0, downloads: [], installs: [], errors: [], notices: [], statuses: [],
checkGate: null, metadataGate: null, downloadGate: null, hashGate: null,
checkError: null, metadataError: null, downloadError: null, eventVersion: null, packagePath: null,
corruptSignature: false, corruptMetadata: false };
const updater = new BaseUpdater(null, {
version: "2.16.1", isPackaged: true,
onQuit: (handler) => { h.quitHandler = handler; },
});
updater.logger = null;
updater.getUpdateInfoAndProvider = async () => {
h.checks++;
if (h.checkGate) await h.checkGate.promise;
if (h.checkError) throw h.checkError;
if (!h.latest) return { info: { version: "2.16.1" }, provider: {} };
const info = structuredClone(releases.get(h.latest).info);
if (h.corruptMetadata) info.files[0].sha512 = "invalid";
updater.selected = info;
return { info, provider: {} };
};
updater.doDownloadUpdate = async () => {
const version = updater.selected.version;
h.downloads.push(version);
if (h.downloadGate) await h.downloadGate.promise;
if (h.downloadError) throw h.downloadError;
updater.downloadedUpdateHelper = {
file: h.packagePath || releases.get(version).path,
downloadedFileInfo: { isAdminRightsRequired: false },
};
updater.dispatchUpdateDownloaded({ version: h.eventVersion || version, downloadedFile: updater.installerPath });
updater.addQuitHandler();
return [updater.installerPath];
};
updater.doInstall = ({ isSilent, isForceRunAfter }) => {
h.installs.push({ version: updater.selected.version, path: updater.installerPath, silent: isSilent, forceRun: isForceRunAfter });
return true;
};
updater.quitAndInstall = (silent, forceRun) => updater.install(silent, forceRun);
const controller = createDesktopUpdateController({
updater, platform: "win32",
fetchWindowsMetadata: async ({ version, updateInfo }) => {
if (h.metadataGate) await h.metadataGate.promise;
if (h.metadataError) throw h.metadataError;
const release = releases.get(version);
return verifyWindowsUpdateMetadata({ ...release, expectedVersion: version, updateInfo,
signatureBytes: h.corruptSignature ? Buffer.from(JSON.stringify({
schemaVersion: 1, keyId: WINDOWS_UPDATE_KEY_ID, signature: Buffer.alloc(64).toString("base64"),
})) : release.signatureBytes,
trustedPublicKeys: { [WINDOWS_UPDATE_KEY_ID]: keys.publicKey } });
},
verifyWindowsPackage: async (options) => {
if (h.hashGate) await h.hashGate.promise;
await verifyDownloadedWindowsUpdate(options);
},
onStatus: (status) => h.statuses.push(status),
onError: (error, stage) => h.errors.push({ error, stage }),
onDownloaded: (version) => h.notices.push(version),
beforeInstall: () => {},
});
// Await the download and hash work without timers or polling the filesystem.
const settle = async () => { await controller.download("test-wait").catch(() => {}); await flush(); };
const ready = async () => { await controller.check("startup"); await settle(); };
return Object.assign(h, { updater, controller, releases, settle, ready });
};
describe("desktop update target lifecycle (#776)", () => {
test("replaces a downloaded intermediate version with the newest release", async () => {
const h = await harness();
await h.ready();
h.latest = "2.19.0";
await h.controller.check("interval");
await h.settle();
expect(h.downloads).toEqual(["2.17.0", "2.19.0"]);
expect(h.controller.snapshot().version).toBe("2.19.0");
expect(await h.controller.install()).toEqual({ started: true });
expect(h.installs).toEqual([{ version: "2.19.0", path: h.releases.get("2.19.0").path, silent: true, forceRun: true }]);
});
test("checks again at installation time and installs the latest directly", async () => {
const h = await harness();
await h.ready();
h.latest = "2.18.0";
expect(await h.controller.install()).toEqual({ started: true });
expect(h.downloads).toEqual(["2.17.0", "2.18.0"]);
expect(h.installs.map((entry) => entry.version)).toEqual(["2.18.0"]);
});
test("normal Windows quit refreshes the installer and does not relaunch", async () => {
const h = await harness();
await h.ready();
h.latest = "2.19.0";
expect(h.controller.handleBeforeQuit({ preventDefault() {} })).toBe(true);
await h.controller.install();
expect(h.installs).toEqual([{ version: "2.19.0", path: h.releases.get("2.19.0").path, silent: true, forceRun: false }]);
expect(h.notices).toEqual(["2.17.0"]);
});
test("an unchanged release does not download or prompt again", async () => {
const h = await harness();
await h.ready();
await h.controller.check("manual");
expect(h.checks).toBe(2);
expect(h.downloads).toEqual(["2.17.0"]);
expect(h.notices).toEqual(["2.17.0"]);
expect(h.updater.autoInstallOnAppQuit).toBe(true);
});
test("an old native dialog cannot approve installing a replacement version", async () => {
const h = await harness();
await h.ready();
h.latest = "2.18.0";
expect(await h.controller.install("2.17.0")).toEqual({ started: false });
expect(h.installs).toEqual([]);
expect(h.notices).toEqual(["2.17.0", "2.18.0"]);
expect(await h.controller.install("2.18.0")).toEqual({ started: true });
});
test("blocks quit installation during a check and coalesces manual installation requests", async () => {
const h = await harness();
await h.ready();
h.checkGate = deferred();
const check = h.controller.check("interval");
expect(h.updater.autoInstallOnAppQuit).toBe(false);
const first = h.controller.install();
const second = h.controller.install();
expect(first).toBe(second);
await flush();
expect(h.installs).toEqual([]);
h.checkGate.resolve();
await check;
await first;
expect(h.checks).toBe(2);
expect(h.installs).toHaveLength(1);
});
test("serializes checks and installation around asynchronous Windows hashing", async () => {
const h = await harness();
await h.ready();
h.latest = "2.18.0";
h.hashGate = deferred();
await h.controller.check("manual");
await flush();
h.latest = "2.19.0";
await h.controller.check("resume");
expect(h.checks).toBe(2);
expect(h.updater.autoInstallOnAppQuit).toBe(false);
h.quitHandler(0);
expect(h.installs).toEqual([]);
expect(await h.controller.install()).toEqual({ started: false });
h.hashGate.resolve();
await h.settle();
expect(h.controller.snapshot()).toMatchObject({ state: "downloaded", version: "2.18.0" });
expect(h.updater.autoInstallOnAppQuit).toBe(true);
});
for (const failure of ["check", "metadata", "download", "signature", "metadata-digest", "package-digest", "event-version"]) {
test(`a replacement ${failure} failure never installs the old or unverified package`, async () => {
const h = await harness();
await h.ready();
h.latest = "2.18.0";
if (failure === "check") h.checkError = new Error("offline");
if (failure === "metadata") h.metadataError = new Error("metadata unavailable");
if (failure === "download") h.downloadError = new Error("download failed");
if (failure === "signature") h.corruptSignature = true;
if (failure === "metadata-digest") h.corruptMetadata = true;
if (failure === "package-digest") await writeFile(h.releases.get(h.latest).path, "tampered! 2.18.0");
if (failure === "event-version") h.eventVersion = "2.17.0";
await h.controller.check("interval").catch(() => {});
await h.settle();
expect(await h.controller.install()).toEqual({ started: false });
expect(h.updater.autoInstallOnAppQuit).toBe(false);
expect(h.installs).toEqual([]);
expect(h.errors.length).toBeGreaterThan(0);
expect(h.controller.snapshot().state).toBe("idle");
});
}
test("does not download a replacement until its signature gate completes", async () => {
const h = await harness();
await h.ready();
h.latest = "2.18.0";
h.metadataGate = deferred();
const check = h.controller.check("manual");
expect(h.controller.check("resume")).toBe(check);
await flush();
expect(h.downloads).toEqual(["2.17.0"]);
expect(h.updater.autoInstallOnAppQuit).toBe(false);
expect(await h.controller.install()).toEqual({ started: false });
h.metadataGate.resolve();
await check;
await h.settle();
expect(h.downloads).toEqual(["2.17.0", "2.18.0"]);
});
test("does not replace the updater target during a pending download", async () => {
const h = await harness();
await h.ready();
h.latest = "2.18.0";
h.downloadGate = deferred();
await h.controller.check("manual");
h.latest = "2.19.0";
await h.controller.check("resume");
expect(h.checks).toBe(2);
expect(h.updater.selected.version).toBe("2.18.0");
expect(h.updater.autoInstallOnAppQuit).toBe(false);
h.downloadGate.resolve();
await h.settle();
expect(h.controller.snapshot().version).toBe("2.18.0");
});
test("a changed digest at the same version cannot reuse the old verified target", async () => {
const h = await harness();
await h.ready();
h.corruptMetadata = true;
await expect(h.controller.check("interval")).rejects.toThrow("does not match the signed manifest");
expect(h.updater.autoInstallOnAppQuit).toBe(false);
expect(await h.controller.install()).toEqual({ started: false });
expect(h.installs).toEqual([]);
});
test("a withdrawn release revokes installation of the downloaded package", async () => {
const h = await harness();
await h.ready();
h.latest = null;
await h.controller.check("interval");
expect(h.controller.snapshot().state).toBe("idle");
expect(h.updater.autoInstallOnAppQuit).toBe(false);
expect(await h.controller.install()).toEqual({ started: false });
});
test("registers the real updater quit hook only after hashing and revokes it during replacement", async () => {
const h = await harness();
h.hashGate = deferred();
await h.controller.check("startup");
await flush();
expect(h.quitHandler).toBeUndefined();
expect(h.updater.autoInstallOnAppQuit).toBe(false);
h.hashGate.resolve();
await h.settle();
expect(typeof h.quitHandler).toBe("function");
h.latest = "2.18.0";
h.metadataGate = deferred();
const check = h.controller.check("interval");
h.quitHandler(0);
expect(h.installs).toEqual([]);
h.metadataGate.resolve();
await check;
await h.settle();
h.quitHandler(0);
expect(h.installs).toEqual([{ version: "2.18.0", path: h.releases.get("2.18.0").path, silent: true, forceRun: false }]);
});
test("a failed check can recover and reauthorize a verified cached package", async () => {
const h = await harness();
await h.ready();
h.checkError = new Error("offline");
await expect(h.controller.check("interval")).rejects.toThrow("offline");
expect(h.updater.autoInstallOnAppQuit).toBe(false);
h.checkError = null;
await h.ready();
expect(h.controller.snapshot().state).toBe("downloaded");
expect(await h.controller.install()).toEqual({ started: true });
});
test("requires the installed Windows file to be the verified file", async () => {
const h = await harness();
await h.ready();
h.updater.downloadedUpdateHelper.file = h.releases.get("2.18.0").path;
h.metadataGate = deferred();
const install = h.controller.install();
await flush();
expect(h.updater.autoInstallOnAppQuit).toBe(false);
expect(h.installs).toEqual([]);
h.metadataError = new Error("cannot authorize replacement");
h.metadataGate.resolve();
expect(await install).toEqual({ started: false });
});
});
+1 -1
View File
@@ -8,5 +8,5 @@
// CI_BUILD_NUMBER into the archive when present.
// Last completed ASC store build: 48 (2026-09-14, Xcode Cloud, v1.70.0).
// Build 49 is reserved for the 1.74.0 App Store submission.
MARKETING_VERSION = 2.18.0
MARKETING_VERSION = 2.19.0
CURRENT_PROJECT_VERSION = 49
+2 -2
View File
@@ -812,7 +812,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MARKETING_VERSION = 2.18.0;
MARKETING_VERSION = 2.19.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -902,7 +902,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MARKETING_VERSION = 2.18.0;
MARKETING_VERSION = 2.19.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
+2 -2
View File
@@ -3,7 +3,7 @@
"name": "EdgeEver",
"slug": "edgeever",
"scheme": "edgeever",
"version": "2.18.0",
"version": "2.19.0",
"runtimeVersion": {
"policy": "appVersion"
},
@@ -32,7 +32,7 @@
},
"android": {
"package": "org.edgeever.mobile",
"versionCode": 256,
"versionCode": 257,
"adaptiveIcon": {
"backgroundColor": "#16A06E",
"backgroundImage": "./assets/icon.png",
+4 -3
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type Ref, type RefObject } from "react";
import { createPortal } from "react-dom";
import type { Transaction } from "@tiptap/pm/state";
import type { Editor } from "@tiptap/react";
@@ -21,6 +21,7 @@ type EditorOutlineProps = {
collapsed: boolean;
shortcutLabel: string;
onCollapsedChange: (collapsed: boolean) => void;
contentRef?: Ref<HTMLElement>;
};
const OUTLINE_INDENT = 12;
@@ -53,7 +54,7 @@ const sameOutlineItems = (left: OutlineItem[], right: OutlineItem[]) =>
return other?.level === item.level && other.pos === item.pos && other.text === item.text;
});
export const EditorOutline = ({ editor, memoId, editingMemoIdRef, scrollContainer, collapsedAnchor, collapsed, shortcutLabel, onCollapsedChange }: EditorOutlineProps) => {
export const EditorOutline = ({ editor, memoId, editingMemoIdRef, scrollContainer, collapsedAnchor, collapsed, shortcutLabel, onCollapsedChange, contentRef }: EditorOutlineProps) => {
const { t } = useTranslation();
const [items, setItems] = useState<OutlineItem[]>([]);
const [activePos, setActivePos] = useState<number | null>(null);
@@ -377,7 +378,7 @@ export const EditorOutline = ({ editor, memoId, editingMemoIdRef, scrollContaine
</div>
{!collapsed && (
<nav className="pr-2" aria-label={t("editor.outline")}>
<nav ref={contentRef} className="pr-2" aria-label={t("editor.outline")}>
{renderItems(tree)}
</nav>
)}
+22
View File
@@ -1,3 +1,4 @@
import { autoUpdate } from "@floating-ui/react-dom";
import { AI_ASSISTANT_LAUNCHER_POSITION } from "@/lib/ai-assistant-launcher";
import { editorClipboardTextSerializer } from "@/lib/editor-clipboard";
import { useRef, useState, useEffect, useLayoutEffect, useCallback, useMemo, lazy, Suspense, type CSSProperties, type FocusEvent as ReactFocusEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
@@ -141,6 +142,7 @@ import {
docToMarkdown,
MEMO_CONTENT_STYLE,
noteProseCssVariables,
noteProseStyleCssVariables,
markdownToDoc,
normalizeImageGalleries,
PLUGIN_EMBED_NODE_TYPE,
@@ -625,6 +627,23 @@ const RichEditorPane = ({
});
const [editorScrollContainer, setEditorScrollContainer] = useState<HTMLDivElement | null>(null);
const [collapsedOutlineAnchor, setCollapsedOutlineAnchor] = useState<HTMLDivElement | null>(null);
const [outlineContent, setOutlineContent] = useState<HTMLElement | null>(null);
const [aiLauncher, setAiLauncher] = useState<HTMLButtonElement | null>(null);
useLayoutEffect(() => {
if (!outlineContent || !aiLauncher) return;
const container = aiLauncher.offsetParent;
if (!(container instanceof HTMLElement)) return;
// Follow the visible content edge, excluding the outline's scrollbar and
// inner padding. Frame tracking also covers the reading-gutter transition.
return autoUpdate(outlineContent, aiLauncher, () => {
const contentRight = outlineContent.getBoundingClientRect().right
- Number.parseFloat(getComputedStyle(outlineContent).paddingRight || "0");
aiLauncher.style.right = `${container.getBoundingClientRect().right - contentRight}px`;
}, { animationFrame: true });
}, [outlineContent, aiLauncher]);
useLayoutEffect(() => {
if (!outlineContent) aiLauncher?.style.removeProperty("right");
}, [outlineContent, aiLauncher]);
const [editorScrollbarGutter, setEditorScrollbarGutter] = useState(0);
const [headerTitleSlot, setHeaderTitleSlot] = useState<HTMLDivElement | null>(null);
const [headerStatusCluster, setHeaderStatusCluster] = useState<HTMLDivElement | null>(null);
@@ -4549,6 +4568,7 @@ const RichEditorPane = ({
data-note-title={title || getEditableMemoTitle(memo?.title)}
style={{
...noteProseCssVariables(noteProse),
...noteProseStyleCssVariables(noteProseStyle),
"--memo-content-divider-spacing": `${MEMO_CONTENT_STYLE.divider.marginVertical}px`,
} as CSSProperties}
className={cn(
@@ -4738,6 +4758,7 @@ const RichEditorPane = ({
editingMemoIdRef={editingMemoIdRef}
editor={editor}
scrollContainer={editorScrollContainer}
contentRef={setOutlineContent}
collapsedAnchor={collapsedOutlineAnchor}
collapsed={editorOutlineCollapsed}
shortcutLabel={formatShortcutBinding(shortcutSettings.toggleOutline)}
@@ -4850,6 +4871,7 @@ const RichEditorPane = ({
type="button"
variant="outline"
size="icon"
ref={setAiLauncher}
data-ai-assistant-launcher=""
className={cn(
"absolute z-30 size-11 rounded-full border-slate-200 bg-card text-slate-950 shadow-[0_8px_24px_rgba(15,23,42,0.14)] hover:bg-card hover:text-slate-950",
@@ -68,7 +68,7 @@ export const EditorPhonePreview = ({ editor, title, noteProse, scrollContainer,
const { t } = useTranslation();
const { mermaidTheme } = useMermaidTheme();
const noteProseStyle = useNoteProseStylePreference();
const proseKey = `${noteProse.palette}:${noteProse.fontSize}:${noteProse.lineHeight}:${noteProse.customCss}:${noteProseStyle.headingStyle}:${noteProseStyle.quoteStyle}:${noteProseStyle.emphasisStyle}:${noteProseStyle.showMagazineCover}`;
const proseKey = `${noteProse.palette}:${noteProse.fontSize}:${noteProse.lineHeight}:${noteProse.customCss}:${noteProseStyle.headingStyle}:${noteProseStyle.quoteStyle}:${noteProseStyle.emphasisStyle}:${noteProseStyle.showMagazineCover}:${noteProseStyle.paragraphSpacing}:${noteProseStyle.textAlign}:${noteProseStyle.letterSpacing}`;
const previewSyncGeneration = useRef(0);
const [markup, setMarkup] = useState({ html: "", style: "" });
const [follow, setFollow] = useState(readEditorPhonePreviewFollowPreference);
+19 -2
View File
@@ -17,7 +17,7 @@ import {
Users,
Wrench,
} from "lucide-react";
import { useSyncExternalStore, type ReactNode } from "react";
import { useState, useSyncExternalStore, type ReactNode } from "react";
import { isDesktopViewport } from "@/lib/workspace-shell";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router";
@@ -125,6 +125,7 @@ export const SettingsPane = ({
const selectTab = (tab: TabKey | null) => setSearchParams(tab ? { tab } : {}, { replace: true });
const { unseen: deployedUpdateUnseen } = useDeployedUpdateNotice();
const canClearLocalData = Boolean(window.edgeeverDesktop?.canClearLocalData);
const [devShowWebExport, setDevShowWebExport] = useState(false);
const tabItems: TabItem[] = [
{
@@ -275,7 +276,23 @@ export const SettingsPane = ({
case "data":
return (
<SettingsGroup>
{window.edgeeverDesktop?.backup ? <DesktopScheduledBackupCard /> : <DataExportCard refreshWorkspaceAfterImport={refreshWorkspaceAfterImport} />}
{import.meta.env.DEV && (
<div className="flex items-center justify-between rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-700 dark:text-amber-300">
<span>Dev 预览模式:{devShowWebExport ? "当前为 Web 端原生导出卡片" : "当前为桌面端备份与恢复预览"}</span>
<button
type="button"
className="font-medium underline hover:text-amber-900 dark:hover:text-amber-100"
onClick={() => setDevShowWebExport((prev) => !prev)}
>
{devShowWebExport ? "切换到桌面端预览" : "切换到 Web 原生导出"}
</button>
</div>
)}
{window.edgeeverDesktop?.backup || (import.meta.env.DEV && !devShowWebExport) ? (
<DesktopScheduledBackupCard />
) : (
<DataExportCard refreshWorkspaceAfterImport={refreshWorkspaceAfterImport} />
)}
<ThirdPartyImportCard controller={noteImport} onOpenMemo={onOpenMemo} />
</SettingsGroup>
);
+20 -1
View File
@@ -87,6 +87,7 @@ import {
writeMemoSortPreference,
} from "@/lib/app-helpers";
import { useBrowserBackLayer } from "@/lib/app-hooks";
import { createFocusFullscreen } from "@/lib/focus-fullscreen";
import { sortDesktopTitleMemoSummaries, updateMemoSummaryInLists } from "@/lib/memo-list-cache";
import {
cacheMemoDetail,
@@ -2792,11 +2793,29 @@ export const WorkspaceApp = ({
}
};
const focusFullscreenRef = useRef<ReturnType<typeof createFocusFullscreen> | null>(null);
useEffect(() => {
if (window.edgeeverDesktop?.isAvailable) return;
const fullscreen = createFocusFullscreen(document, () => {
runWorkspaceViewTransition(() => setDesktopFocusMode(false));
});
focusFullscreenRef.current = fullscreen;
return () => {
focusFullscreenRef.current = null;
fullscreen.dispose();
};
}, [setDesktopFocusMode]);
useEffect(() => {
if (!desktopFocusModeActive) focusFullscreenRef.current?.setEnabled(false);
}, [desktopFocusModeActive]);
const updateDesktopFocusMode = useCallback((enabled: boolean) => {
focusFullscreenRef.current?.setEnabled(enabled);
runWorkspaceViewTransition(() => {
setDesktopFocusMode(enabled);
});
}, []);
}, [setDesktopFocusMode]);
const toggleDesktopFocusMode = useCallback(() => {
updateDesktopFocusMode(!desktopFocusModeActive);
@@ -113,15 +113,70 @@ export const DesktopScheduledBackupCard = () => {
<DesktopBackupImport disabled={saving || runOpen || Boolean(deleting)}>{(restore, restoreDisabled) => <>
<div className="flex flex-wrap gap-2">
{busy ? <Button type="button" size="sm" variant="outline" disabled={saving} onClick={() => void attempt(() => bridge.backup("cancel", { backupSetId: status.backupSetId }))}>{t("common.cancel")}</Button> : <Button type="button" size="sm" className="bg-foreground text-background hover:bg-foreground/90" disabled={saving || (!pending && dirty)} onClick={startBackup}>{t(pending ? "desktopBackup.verifyPublication" : "desktopBackup.run")}</Button>}
<Button type="button" size="sm" variant="outline" disabled={restoreDisabled} onClick={() => restore()}><Upload className="h-4 w-4" />{t("desktopBackup.importButton")}</Button>
<Button type="button" size="sm" variant="ghost" disabled={saving} onClick={() => void attempt(() => bridge.backup("open-folder", { backupSetId: status.backupSetId }))}><FolderOpen className="h-4 w-4" />{t("desktopBackup.openFolder")}</Button>
<Button type="button" size="sm" variant="outline" disabled={restoreDisabled} onClick={() => restore()}><Upload className="mr-1.5 h-4 w-4" />{t("desktopBackup.importButton")}</Button>
<Button type="button" size="sm" variant="outline" disabled={saving} onClick={() => void attempt(() => bridge.backup("open-folder", { backupSetId: status.backupSetId }))}><FolderOpen className="mr-1.5 h-4 w-4" />{t("desktopBackup.openFolder")}</Button>
</div>
<div className="grid gap-1.5 rounded-md bg-muted/40 p-3 text-xs" aria-live="polite" data-backup-status>
<p className="font-medium text-slate-800">{t("desktopBackup.lastSuccess", { time: date(status.lastSuccessAt) })}</p>
{lastBackup ? <p className="text-slate-700">{t("desktopBackup.lastBackupSize", { size: formatBytes(lastBackup.size) })}</p> : null}
{status.task ? <p className="flex items-center gap-2"><Loader2 className="h-3.5 w-3.5 animate-spin" />{t(`desktopBackup.phases.${status.task.phase}`)}{status.task.total > 0 ? ` ${status.task.completed} / ${status.task.total}` : ""}</p> : status.lastCheck ? <p className="text-slate-700">{t(`desktopBackup.checkResults.${status.lastCheck.result}`)}</p> : null}
<p className="text-muted-foreground">{t("desktopBackup.lastCheck", { time: status.lastCheck ? date(status.lastCheck.at) : t("desktopBackup.notChecked") })}</p>
<p className={dirty ? "text-amber-700" : "text-muted-foreground"}>{dirty ? t(saving ? "desktopBackup.savingSettings" : "desktopBackup.completePassword") : status.config.enabled ? t("desktopBackup.nextDue", { time: status.nextDueAt ? date(status.nextDueAt) : t("desktopBackup.whenReady") }) : t("desktopBackup.autoOff")}</p>
<div className="grid gap-2 rounded-lg border border-border/60 bg-muted/30 p-3.5 text-xs" aria-live="polite" data-backup-status>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/40 pb-2">
<div className="flex items-center gap-2 font-medium text-slate-800 dark:text-slate-200">
{status.task ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
<span>{t(`desktopBackup.phases.${status.task.phase}`)}{status.task.total > 0 ? ` (${status.task.completed} / ${status.task.total})` : ""}</span>
</>
) : status.config.enabled ? (
<>
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-600" />
</span>
<span>{t("desktopBackup.autoRunning")}</span>
<span className="font-normal text-muted-foreground">({t(`desktopBackup.intervals.${config.interval}`)} · {t("desktopBackup.copies", { count: config.retention })})</span>
</>
) : (
<>
<span className="inline-flex h-2 w-2 rounded-full bg-slate-400" />
<span>{t("desktopBackup.autoOff")}</span>
</>
)}
</div>
{status.lastCheck?.result === "success" ? (
<span className="rounded bg-emerald-100/80 px-1.5 py-0.5 text-[11px] font-normal text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-300">
{t("desktopBackup.statusNormal")}
</span>
) : null}
</div>
<div className="grid gap-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-slate-700 dark:text-slate-300">
<span>{t("desktopBackup.lastSuccess", { time: date(status.lastSuccessAt) })}</span>
{lastBackup ? (
<>
<span className="text-muted-foreground">·</span>
<span className="tabular-nums font-medium">{formatBytes(lastBackup.size)}</span>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">{t(lastBackup.encrypted ? "desktopBackup.encrypted" : "desktopBackup.plain")}</span>
</>
) : status.lastSuccessAt ? (
<span className="text-muted-foreground">({t("desktopBackup.historyCleanedOrMoved")})</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground">
{status.config.enabled ? (
<span>{t("desktopBackup.nextDue", { time: status.nextDueAt ? date(status.nextDueAt) : t("desktopBackup.whenReady") })}</span>
) : null}
{status.lastCheck && status.lastCheck.result !== "success" ? (
<span className="text-amber-700">{t(`desktopBackup.checkResults.${status.lastCheck.result}`)}</span>
) : null}
</div>
<p className="truncate font-mono text-[11px] text-muted-foreground">
{config.directory}/{status.backupSetId}
</p>
</div>
{dirty ? <p className="text-amber-700">{t(saving ? "desktopBackup.savingSettings" : "desktopBackup.completePassword")}</p> : null}
{error || status.error ? <p className="break-words text-destructive" role="alert">{error ?? describeDesktopBackupError(t, status.error)}</p> : null}
{status.warning ? <p className="text-amber-700">{describeDesktopBackupError(t, status.warning)}</p> : null}
<DesktopOffsiteStatusLines status={status} disabled={saving} onUpload={() => void attempt(() => bridge.backup("offsite-upload", { backupSetId: status.backupSetId }))} />
@@ -175,11 +230,19 @@ export const DesktopScheduledBackupCard = () => {
</details>
<DesktopOffsiteSettings status={status} disabled={saving} />
<section className="grid gap-2.5 border-t pt-3" aria-label={t("desktopBackup.history", { count: status.history.length })}>
<h3 className="text-xs font-medium text-slate-700">{t("desktopBackup.history", { count: status.history.length })}</h3>
{status.history.length ? <ul className="divide-y divide-slate-100">{[...status.history].reverse().map((item) => <li key={item.name} className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 py-2.5 first:pt-0 last:pb-0">
<div className="min-w-0 flex-1 basis-60 space-y-0.5"><p className="break-all font-mono text-xs text-slate-800">{item.name}</p><p className="text-xs text-muted-foreground">{date(item.completedAt)} · <span className="whitespace-nowrap tabular-nums">{formatBytes(item.size)}</span> · {t(item.encrypted ? "desktopBackup.encrypted" : "desktopBackup.plain")}</p></div>
<div className="flex shrink-0 gap-1"><Button size="sm" variant="outline" className="h-7 text-xs font-normal" disabled={restoreDisabled} onClick={() => restore(item.name)}>{t("desktopBackup.restore")}</Button><Button size="sm" variant="ghost" className="h-7 text-xs font-normal text-destructive hover:text-destructive" disabled={busy || saving} onClick={() => { setDeleteError(null); setDeleting(item.name); }}>{t("common.delete")}</Button></div>
</li>)}</ul> : <p className="text-xs text-muted-foreground">{t("desktopBackup.emptyHistory")}</p>}
<div className="flex items-center justify-between">
<h3 className="text-xs font-medium text-slate-700 dark:text-slate-300">{t("desktopBackup.history", { count: status.history.length })}</h3>
</div>
{status.history.length ? <ul className="divide-y divide-border/40 rounded-md border border-border/40 bg-muted/10 px-3">{[...status.history].reverse().map((item) => <li key={item.name} className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 py-2.5">
<div className="min-w-0 flex-1 basis-60 space-y-1">
<p className="break-all font-mono text-xs font-medium text-slate-800 dark:text-slate-200">{item.name}</p>
<p className="text-xs text-muted-foreground">{date(item.completedAt)} · <span className="whitespace-nowrap tabular-nums font-mono font-medium">{formatBytes(item.size)}</span> · {t(item.encrypted ? "desktopBackup.encrypted" : "desktopBackup.plain")}</p>
</div>
<div className="flex shrink-0 gap-1.5">
<Button size="sm" variant="outline" className="h-7 text-xs font-normal" disabled={restoreDisabled} onClick={() => restore(item.name)}>{t("desktopBackup.restore")}</Button>
<Button size="sm" variant="ghost" className="h-7 text-xs font-normal text-destructive hover:text-destructive" disabled={busy || saving} onClick={() => { setDeleteError(null); setDeleting(item.name); }}>{t("common.delete")}</Button>
</div>
</li>)}</ul> : <div className="rounded-md border border-dashed border-border/60 py-4 text-center"><p className="text-xs text-muted-foreground">{t("desktopBackup.emptyHistory")}</p></div>}
</section>
</>}</DesktopBackupImport>
</CardContent>
@@ -108,7 +108,10 @@ describe("settings information architecture", () => {
const shortcuts = readFileSync(new URL("./ShortcutSettingsItem.tsx", import.meta.url), "utf8");
expect(preferenceCard).toContain('t("settings.groups.interface")');
expect(preferenceCard).toContain('t("settings.groups.readingAppearance")');
expect(preferenceCard).toContain('t("settings.groups.reading")');
expect(preferenceCard).toContain('t("settings.groups.readingElements")');
expect(preferenceCard.indexOf("editorBodyCssTitle")).toBeLessThan(preferenceCard.indexOf("editorBodyFontTitle"));
expect(preferenceCard).toContain('t("settings.groups.editing")');
expect(preferenceCard).not.toContain("ShortcutSettingsItem");
expect(settingsPane).toContain('t("settings.tabs.shortcuts")');
@@ -5,7 +5,7 @@ import { FONT_CATALOG, NOTE_BODY_FONT_MENU_GROUPS, isRemoteNoteImageFont, type R
import { FontLoadFeedback } from "@/components/FontLoadFeedback";
import { editorFontLoadState, uiFontLoadState, retryEditorCatalogFont, retryUiCatalogFont } from "@/lib/editor-catalog-fonts";
import { FontLabel } from "@/components/FontLabel";
import { AlignVerticalSpaceAround, AppWindow, BookOpenText, ChartNoAxesCombined, Code2, Heading, Highlighter, Image, LayoutTemplate, Languages, ListTree, MousePointerClick, Palette, Quote, Sparkles, SpellCheck, SunMoon, Type } from "lucide-react";
import { AlignLeft, AlignVerticalSpaceAround, AppWindow, BookOpenText, ChartNoAxesCombined, Code2, Heading, Highlighter, Image, LayoutTemplate, Languages, ListTree, MousePointerClick, MoveHorizontal, Palette, Pilcrow, Quote, Sparkles, SpellCheck, SunMoon, Type } from "lucide-react";
import { useState, useEffect, useSyncExternalStore, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type {
@@ -22,6 +22,12 @@ import {
NOTE_PROSE_EMPHASIS_STYLES,
NOTE_PROSE_FONT_SIZES,
NOTE_PROSE_HEADING_STYLES,
NOTE_PROSE_PARAGRAPH_SPACINGS,
NOTE_PROSE_TEXT_ALIGNS,
NOTE_PROSE_LETTER_SPACINGS,
parseNoteProseParagraphSpacing,
parseNoteProseTextAlign,
parseNoteProseLetterSpacing,
NOTE_PROSE_PALETTE_CHOICES,
NOTE_PROSE_PALETTES,
NOTE_PROSE_QUOTE_STYLES,
@@ -443,241 +449,299 @@ export const PreferenceCard = ({
) : null}
{section === "reading" ? (
<PreferenceSection title={t("settings.groups.reading")}>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<BookOpenText className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyFontTitle")}</div>
<>
<PreferenceSection title={t("settings.groups.readingAppearance")}>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Palette className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyPaletteTitle")}</div>
</div>
</div>
</div>
<FontChoiceFields
label={t("settings.editorBodyFontTitle")}
preference={editorBodyFont}
includeCatalog
onChange={updateEditorBodyFont}
/>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Type className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyFontSizeTitle")}</div>
</div>
</div>
<div className="w-full shrink-0 sm:w-80">
<Select
value={String(noteProse.fontSize)}
onValueChange={(value) => onNoteProseChange({ fontSize: Number(value) as ResolvedNoteProse["fontSize"] })}
>
<SelectTrigger aria-label={t("settings.editorBodyFontSizeTitle")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NOTE_PROSE_FONT_SIZES.map((size) => (
<SelectItem key={size} value={String(size)}>{size}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<AlignVerticalSpaceAround className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyLineHeightTitle")}</div>
</div>
</div>
<div className="w-full shrink-0 sm:w-80">
<Select
value={String(noteProse.lineHeight)}
onValueChange={(value) => onNoteProseChange({ lineHeight: Number(value) as ResolvedNoteProse["lineHeight"] })}
>
<SelectTrigger aria-label={t("settings.editorBodyLineHeightTitle")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NOTE_PROSE_LINE_HEIGHT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{t(option.labelKey)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Palette className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyPaletteTitle")}</div>
</div>
</div>
<div className="flex w-full shrink-0 flex-col gap-2 sm:w-80">
<Select
value={noteProse.palette}
onValueChange={(value) => onNoteProseChange({ palette: value as NoteProsePaletteChoice })}
>
<SelectTrigger aria-label={t("settings.editorBodyPaletteTitle")} className="h-9 bg-card">
<SelectValue>
<span className="flex items-center gap-2">
<NoteProsePaletteSwatch paletteId={noteProse.palette} />
<span>{t(`settings.editorBodyPalettes.${noteProse.palette}`)}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
{NOTE_PROSE_PALETTE_CHOICES.map((paletteId) => (
<SelectItem
key={paletteId}
value={paletteId}
className="pr-2.5 [&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:flex-1"
>
<span className="flex w-full items-center justify-between gap-3">
<span>{t(`settings.editorBodyPalettes.${paletteId}`)}</span>
<NoteProsePaletteSwatch paletteId={paletteId} />
<div className="flex w-full shrink-0 flex-col gap-2 sm:w-80">
<Select
value={noteProse.palette}
onValueChange={(value) => onNoteProseChange({ palette: value as NoteProsePaletteChoice })}
>
<SelectTrigger aria-label={t("settings.editorBodyPaletteTitle")} className="h-9 bg-card">
<SelectValue>
<span className="flex items-center gap-2">
<NoteProsePaletteSwatch paletteId={noteProse.palette} />
<span>{t(`settings.editorBodyPalettes.${noteProse.palette}`)}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Heading className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyHeadingStyleTitle")}</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
{NOTE_PROSE_PALETTE_CHOICES.map((paletteId) => (
<SelectItem
key={paletteId}
value={paletteId}
className="pr-2.5 [&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:flex-1"
>
<span className="flex w-full items-center justify-between gap-3">
<span>{t(`settings.editorBodyPalettes.${paletteId}`)}</span>
<NoteProsePaletteSwatch paletteId={paletteId} />
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="w-full shrink-0 sm:w-80">
<Select
value={noteProseStyle.headingStyle}
onValueChange={(value) => writeNoteProseStylePreference({ headingStyle: value as NoteProseHeadingStyle })}
>
<SelectTrigger aria-label={t("settings.editorBodyHeadingStyleTitle")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NOTE_PROSE_HEADING_STYLES.map((styleId) => (
<SelectItem key={styleId} value={styleId}>
{t(`settings.editorBodyHeadingStyles.${styleId}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Quote className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyQuoteStyleTitle")}</div>
<div className="hidden min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4 lg:flex">
<div className="flex min-w-0 items-center gap-3">
<Code2 className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyCssTitle")}</div>
{noteProse.customCss?.trim() ? (
<span className="inline-flex items-center rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300">
{t("settings.editorBodyCssConfigured")}
</span>
) : null}
</div>
<p className="text-xs leading-5 text-slate-500">{t("settings.accountSyncDescription")}</p>
</div>
</div>
<div className="w-full shrink-0 sm:w-auto">
<Button variant="outline" className="h-9 px-3 text-xs" onClick={openCssDialog}>
{t("settings.editorBodyCssEdit")}
</Button>
</div>
</div>
<div className="w-full shrink-0 sm:w-80">
<Select
value={noteProseStyle.quoteStyle}
onValueChange={(value) => writeNoteProseStylePreference({ quoteStyle: value as NoteProseQuoteStyle })}
>
<SelectTrigger aria-label={t("settings.editorBodyQuoteStyleTitle")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NOTE_PROSE_QUOTE_STYLES.map((styleId) => (
<SelectItem key={styleId} value={styleId}>
{t(`settings.editorBodyQuoteStyles.${styleId}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</PreferenceSection>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Highlighter className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyEmphasisStyleTitle")}</div>
<PreferenceSection title={t("settings.groups.reading")}>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<BookOpenText className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyFontTitle")}</div>
</div>
</div>
</div>
<div className="w-full shrink-0 sm:w-80">
<Select
value={noteProseStyle.emphasisStyle}
onValueChange={(value) => writeNoteProseStylePreference({ emphasisStyle: value as NoteProseEmphasisStyle })}
>
<SelectTrigger aria-label={t("settings.editorBodyEmphasisStyleTitle")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NOTE_PROSE_EMPHASIS_STYLES.map((styleId) => (
<SelectItem key={styleId} value={styleId}>
{t(`settings.editorBodyEmphasisStyles.${styleId}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<LayoutTemplate className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyMagazineCoverTitle")}</div>
<p className="text-xs leading-5 text-slate-500">{t("settings.editorBodyMagazineCoverDescription")}</p>
</div>
</div>
<div className="w-full shrink-0 sm:w-auto">
<Switch
checked={noteProseStyle.showMagazineCover}
aria-label={t("settings.editorBodyMagazineCoverTitle")}
onCheckedChange={(checked) => writeNoteProseStylePreference({ showMagazineCover: checked })}
<FontChoiceFields
label={t("settings.editorBodyFontTitle")}
preference={editorBodyFont}
includeCatalog
onChange={updateEditorBodyFont}
/>
</div>
</div>
<div className="hidden min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4 lg:flex">
<div className="flex min-w-0 items-center gap-3">
<Code2 className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyCssTitle")}</div>
<p className="text-xs leading-5 text-slate-500">{t("settings.accountSyncDescription")}</p>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Type className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyFontSizeTitle")}</div>
</div>
</div>
<div className="w-full shrink-0 sm:w-80">
<Select
value={String(noteProse.fontSize)}
onValueChange={(value) => onNoteProseChange({ fontSize: Number(value) as ResolvedNoteProse["fontSize"] })}
>
<SelectTrigger aria-label={t("settings.editorBodyFontSizeTitle")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NOTE_PROSE_FONT_SIZES.map((size) => (
<SelectItem key={size} value={String(size)}>{size}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="w-full shrink-0 sm:w-auto">
<Button variant="outline" className="h-9 px-3 text-xs" onClick={openCssDialog}>
{t("settings.editorBodyCssEdit")}
</Button>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<ChartNoAxesCombined className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.mermaidThemeTitle")}</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<AlignVerticalSpaceAround className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyLineHeightTitle")}</div>
</div>
</div>
<div className="w-full shrink-0 sm:w-80">
<Select
value={String(noteProse.lineHeight)}
onValueChange={(value) => onNoteProseChange({ lineHeight: Number(value) as ResolvedNoteProse["lineHeight"] })}
>
<SelectTrigger aria-label={t("settings.editorBodyLineHeightTitle")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NOTE_PROSE_LINE_HEIGHT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{t(option.labelKey)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="w-full shrink-0 sm:w-80">
<Select value={mermaidThemePreference} onValueChange={(value) => setMermaidTheme(value as typeof mermaidThemePreference)}>
<SelectTrigger aria-label={t("settings.mermaidThemeTitle")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MERMAID_THEME_PREFERENCES.map((theme) => (
<SelectItem key={theme} value={theme}>
{t(`settings.mermaidThemes.${theme}`)}
</SelectItem>
))}
</SelectContent>
</Select>
{[
{
id: "paragraphSpacing",
title: t("settings.editorParagraphSpacingTitle"),
icon: Pilcrow,
value: String(noteProseStyle.paragraphSpacing),
options: NOTE_PROSE_PARAGRAPH_SPACINGS.map((value) => ({ value: String(value), label: `${value}px` })),
onChange: (value: string) => writeNoteProseStylePreference({ paragraphSpacing: parseNoteProseParagraphSpacing(Number(value)) ?? 8 }),
},
{
id: "textAlign",
title: t("settings.editorTextAlignTitle"),
icon: AlignLeft,
value: noteProseStyle.textAlign,
options: NOTE_PROSE_TEXT_ALIGNS.map((value) => ({ value, label: t(`settings.editorTextAlignOptions.${value}`) })),
onChange: (value: string) => writeNoteProseStylePreference({ textAlign: parseNoteProseTextAlign(value) ?? "left" }),
},
{
id: "letterSpacing",
title: t("settings.editorLetterSpacingTitle"),
icon: MoveHorizontal,
value: noteProseStyle.letterSpacing,
options: NOTE_PROSE_LETTER_SPACINGS.map((value) => ({ value, label: value === "default" ? t("settings.editorLetterSpacingDefault") : value })),
onChange: (value: string) => writeNoteProseStylePreference({ letterSpacing: parseNoteProseLetterSpacing(value) ?? "default" }),
},
].map((setting) => {
const Icon = setting.icon;
return (
<div key={setting.id} className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Icon className="h-4 w-4 shrink-0 text-slate-500" />
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{setting.title}</div>
</div>
<div className="w-full shrink-0 sm:w-80">
<Select value={setting.value} onValueChange={setting.onChange}>
<SelectTrigger aria-label={setting.title} className="h-9 bg-card"><SelectValue /></SelectTrigger>
<SelectContent>
{setting.options.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
);
})}
</PreferenceSection>
<PreferenceSection title={t("settings.groups.readingElements")}>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Heading className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyHeadingStyleTitle")}</div>
</div>
</div>
<div className="w-full shrink-0 sm:w-80">
<Select
value={noteProseStyle.headingStyle}
onValueChange={(value) => writeNoteProseStylePreference({ headingStyle: value as NoteProseHeadingStyle })}
>
<SelectTrigger aria-label={t("settings.editorBodyHeadingStyleTitle")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NOTE_PROSE_HEADING_STYLES.map((styleId) => (
<SelectItem key={styleId} value={styleId}>
{t(`settings.editorBodyHeadingStyles.${styleId}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</PreferenceSection>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Quote className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyQuoteStyleTitle")}</div>
</div>
</div>
<div className="w-full shrink-0 sm:w-80">
<Select
value={noteProseStyle.quoteStyle}
onValueChange={(value) => writeNoteProseStylePreference({ quoteStyle: value as NoteProseQuoteStyle })}
>
<SelectTrigger aria-label={t("settings.editorBodyQuoteStyleTitle")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NOTE_PROSE_QUOTE_STYLES.map((styleId) => (
<SelectItem key={styleId} value={styleId}>
{t(`settings.editorBodyQuoteStyles.${styleId}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<Highlighter className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyEmphasisStyleTitle")}</div>
</div>
</div>
<div className="w-full shrink-0 sm:w-80">
<Select
value={noteProseStyle.emphasisStyle}
onValueChange={(value) => writeNoteProseStylePreference({ emphasisStyle: value as NoteProseEmphasisStyle })}
>
<SelectTrigger aria-label={t("settings.editorBodyEmphasisStyleTitle")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NOTE_PROSE_EMPHASIS_STYLES.map((styleId) => (
<SelectItem key={styleId} value={styleId}>
{t(`settings.editorBodyEmphasisStyles.${styleId}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<LayoutTemplate className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.editorBodyMagazineCoverTitle")}</div>
<p className="text-xs leading-5 text-slate-500">{t("settings.editorBodyMagazineCoverDescription")}</p>
</div>
</div>
<div className="w-full shrink-0 sm:w-auto">
<Switch
checked={noteProseStyle.showMagazineCover}
aria-label={t("settings.editorBodyMagazineCoverTitle")}
onCheckedChange={(checked) => writeNoteProseStylePreference({ showMagazineCover: checked })}
/>
</div>
</div>
<div className="flex min-h-16 flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex min-w-0 items-center gap-3">
<ChartNoAxesCombined className="h-4 w-4 shrink-0 text-slate-500" />
<div className="min-w-0">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.mermaidThemeTitle")}</div>
</div>
</div>
<div className="w-full shrink-0 sm:w-80">
<Select value={mermaidThemePreference} onValueChange={(value) => setMermaidTheme(value as typeof mermaidThemePreference)}>
<SelectTrigger aria-label={t("settings.mermaidThemeTitle")} className="h-9 bg-card">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MERMAID_THEME_PREFERENCES.map((theme) => (
<SelectItem key={theme} value={theme}>
{t(`settings.mermaidThemes.${theme}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</PreferenceSection>
</>
) : null}
{section === "editing" ? (
@@ -14,9 +14,14 @@ export const ThirdPartyImportCard = ({ controller, onOpenMemo }: { controller: N
return <Card className="w-full min-w-0 shadow-none">
<CardHeader className={SETTINGS_CARD_HEADER_CLASSNAME}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="min-w-0 space-y-1.5">
<CardTitle className={SETTINGS_CARD_TITLE_CLASSNAME}><Upload className={SETTINGS_CARD_ICON_CLASSNAME} />{t("noteImport.title")}</CardTitle>
<CardDescription className={SETTINGS_CARD_DESCRIPTION_CLASSNAME}>{t("noteImport.description")}</CardDescription>
<div className="flex flex-wrap gap-1.5 pt-0.5 text-[11px] text-muted-foreground">
<span className="rounded bg-muted px-2 py-0.5 font-mono">Evernote (.enex)</span>
<span className="rounded bg-muted px-2 py-0.5 font-mono">Obsidian</span>
<span className="rounded bg-muted px-2 py-0.5 font-mono">Joplin (.jex)</span>
</div>
</div>
<Button size="sm" variant="outline" onClick={controller.open}>{t(running ? "noteImport.viewProgress" : "noteImport.open")}</Button>
</div>
+614 -1
View File
@@ -1,5 +1,49 @@
import { afterEach, describe, expect, test } from "bun:test";
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { parseHTML } from "linkedom";
import {
DEFAULT_MEMO_TITLE,
EDITOR_HEADING_LEVELS,
IMAGE_COMPRESSION_STORAGE_KEY,
MEMO_DRAG_MIME,
MEMO_LIST_DENSITY_STORAGE_KEY,
MEMO_SORT_STORAGE_KEY,
NOTEBOOK_DRAG_MIME,
buildNotebookTree,
compareDateDesc,
filterMemos,
filterNotebookTree,
focusNotebookTreeButton,
getEditableMemoTitle,
getExpandableNotebookIds,
getMemoDragIds,
getMemoFilterOptions,
getMemoSortOptions,
getMemoTitle,
getNotebookAncestorIds,
getNotebookDropPosition,
getNotebookDropSortOrder,
getNotebookMoveOptions,
getNotebookSortOptions,
hasEdgeEverDragData,
hasMemoDragData,
hasNotebookDragData,
headingBlockValue,
isDefaultMemoTitle,
isTextEntryTarget,
normalizeShortcutKey,
notebookTreeContainsId,
readImageCompressionPreference,
readMemoListDensityPreference,
readMemoSortPreference,
resolvedShortcutModifiers,
setMemoDragPreview,
sortMemos,
writeImageCompressionPreference,
writeMemoListDensityPreference,
writeMemoSortPreference,
writeShortcutSettingsPreference,
} from "./app-helpers.ts";
import {
DEFAULT_SHORTCUT_SETTINGS,
DESKTOP_FOCUS_MODE_STORAGE_KEY,
@@ -1007,3 +1051,572 @@ describe("editor heading block value", () => {
})).toBe("paragraph");
});
});
const notebookBase = {
parentId: null,
slug: null,
icon: null,
color: null,
memoCount: 0,
sortOrder: 0,
lastMemoUpdatedAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const makeNotebook = (id, overrides = {}) => ({ ...notebookBase, id, name: id, ...overrides });
const memoBase = {
title: "",
notebookId: "inbox",
tags: [],
isPinned: false,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const makeMemo = (id, overrides = {}) => ({ ...memoBase, id, ...overrides });
const translate = (key) => `t:${key}`;
const installThrowingLocalStorage = () => {
globalThis.window = {
localStorage: {
getItem: () => {
throw new Error("blocked");
},
setItem: () => {
throw new Error("blocked");
},
},
};
};
// A small notebook tree: work > (projects > alpha), work > beta, personal.
const notebookTreeFixture = () => buildNotebookTree([
makeNotebook("work", { name: "Work", sortOrder: 1 }),
makeNotebook("projects", { name: "Projects", parentId: "work", sortOrder: 1 }),
makeNotebook("alpha", { name: "Alpha", parentId: "projects", sortOrder: 1 }),
makeNotebook("beta", { name: "Beta", parentId: "work", sortOrder: 2 }),
makeNotebook("personal", { name: "Personal", sortOrder: 2 }),
]);
describe("memo titles", () => {
test("treats the default title as empty for editing", () => {
expect(isDefaultMemoTitle(DEFAULT_MEMO_TITLE)).toBe(true);
expect(isDefaultMemoTitle(` ${DEFAULT_MEMO_TITLE} `)).toBe(true);
expect(isDefaultMemoTitle("Plan")).toBe(false);
expect(isDefaultMemoTitle(null)).toBe(false);
expect(getEditableMemoTitle(DEFAULT_MEMO_TITLE)).toBe("");
expect(getEditableMemoTitle(" Plan ")).toBe("Plan");
expect(getEditableMemoTitle(undefined)).toBe("");
});
test("falls back to the default title for blank titles", () => {
expect(getMemoTitle(" Plan ")).toBe("Plan");
expect(getMemoTitle(" ")).toBe(DEFAULT_MEMO_TITLE);
expect(getMemoTitle(null)).toBe(DEFAULT_MEMO_TITLE);
});
test("builds heading block values", () => {
expect(EDITOR_HEADING_LEVELS.map(headingBlockValue)).toEqual([
"heading-1", "heading-2", "heading-3", "heading-4", "heading-5", "heading-6",
]);
});
});
describe("active block value without a usable editor", () => {
test("reports a paragraph for missing, destroyed or uninitialised editors", () => {
expect(getActiveBlockValue(null)).toBe("paragraph");
expect(getActiveBlockValue({ isDestroyed: true, extensionManager: {}, isActive: () => true })).toBe("paragraph");
expect(getActiveBlockValue({ isDestroyed: false, isActive: () => true })).toBe("paragraph");
});
test("reports a paragraph when the editor throws while reading state", () => {
expect(getActiveBlockValue({
isDestroyed: false,
extensionManager: {},
isActive: () => {
throw new Error("view detached");
},
})).toBe("paragraph");
});
});
describe("translated option lists", () => {
test("lists memo sort, notebook sort and memo filter options in order", () => {
expect(getMemoSortOptions(translate)).toEqual([
{ value: "updated-desc", label: "t:options.memoSort.updatedDesc" },
{ value: "created-desc", label: "t:options.memoSort.createdDesc" },
{ value: "title-asc", label: "t:options.memoSort.titleAsc" },
{ value: "title-desc", label: "t:options.memoSort.titleDesc" },
]);
expect(getNotebookSortOptions(translate).map((option) => option.value)).toEqual([
"custom", "name-asc", "memo-count-desc", "updated-desc",
]);
expect(getNotebookSortOptions(translate)[2].label).toBe("t:options.notebookSort.memoCountDesc");
expect(getMemoFilterOptions(translate)).toEqual([
{ value: "all", label: "t:options.memoFilter.all" },
{ value: "pinned", label: "t:options.memoFilter.pinned" },
{ value: "tagged", label: "t:options.memoFilter.tagged" },
{ value: "untagged", label: "t:options.memoFilter.untagged" },
]);
});
});
describe("image compression preference", () => {
test("defaults to enabled and only disables on an explicit false", () => {
const values = installLocalStorage();
expect(readImageCompressionPreference()).toBe(true);
writeImageCompressionPreference(false);
expect(values.get(IMAGE_COMPRESSION_STORAGE_KEY)).toBe("false");
expect(readImageCompressionPreference()).toBe(false);
writeImageCompressionPreference(true);
expect(values.get(IMAGE_COMPRESSION_STORAGE_KEY)).toBe("true");
expect(readImageCompressionPreference()).toBe(true);
});
test("stays enabled when local storage is unavailable", () => {
installThrowingLocalStorage();
expect(readImageCompressionPreference()).toBe(true);
expect(() => writeImageCompressionPreference(false)).not.toThrow();
});
});
describe("memo list density preference", () => {
test("accepts compact and falls back to preview for anything else", () => {
const values = installLocalStorage();
expect(readMemoListDensityPreference()).toBe("preview");
writeMemoListDensityPreference("compact");
expect(values.get(MEMO_LIST_DENSITY_STORAGE_KEY)).toBe("compact");
expect(readMemoListDensityPreference()).toBe("compact");
values.set(MEMO_LIST_DENSITY_STORAGE_KEY, "dense");
expect(readMemoListDensityPreference()).toBe("preview");
});
test("falls back to preview when local storage is unavailable", () => {
installThrowingLocalStorage();
expect(readMemoListDensityPreference()).toBe("preview");
expect(() => writeMemoListDensityPreference("compact")).not.toThrow();
});
});
describe("memo sort preference", () => {
test("round-trips supported sort modes and rejects unknown ones", () => {
const values = installLocalStorage();
expect(readMemoSortPreference()).toBe("updated-desc");
writeMemoSortPreference("title-asc");
expect(values.get(MEMO_SORT_STORAGE_KEY)).toBe("title-asc");
expect(readMemoSortPreference()).toBe("title-asc");
values.set(MEMO_SORT_STORAGE_KEY, "random");
expect(readMemoSortPreference()).toBe("updated-desc");
});
test("falls back to the default when local storage is unavailable", () => {
installThrowingLocalStorage();
expect(readMemoSortPreference()).toBe("updated-desc");
expect(() => writeMemoSortPreference("title-desc")).not.toThrow();
});
});
describe("preferences when local storage is unavailable", () => {
test("every reader returns its safe default and every writer stays silent", () => {
installThrowingLocalStorage();
expect(readNotebookSidebarCollapsedPreference()).toBe(false);
expect(readNotebookSidebarWidthPreference()).toBe(DEFAULT_NOTEBOOK_SIDEBAR_WIDTH_PX);
expect(readDesktopReadingProtectionPreference()).toBe(false);
expect(readEditorOutlineCollapsedPreference()).toBe(true);
expect(readEditorOutlineCollapsedPreference({ defaultCollapsed: false })).toBe(false);
expect(readEditorContentAlignmentPreference()).toBe("start");
expect(readEditorToolbarExpandedPreference()).toBe(false);
expect(readEditorPhonePreviewPreference()).toBe(false);
expect(readEditorPhonePreviewFollowPreference()).toBe(true);
expect(readMemoListWidthPreference()).toBe(DEFAULT_MEMO_LIST_WIDTH_PX);
expect(readNotebookSortPreference()).toBe("name-asc");
expect(readNotebookTreeCollapsedIdsPreference()).toEqual(new Set());
expect(readNotebookSectionCollapsedPreference()).toBe(false);
expect(() => {
writeNotebookSidebarCollapsedPreference(true);
writeNotebookSidebarWidthPreference(300);
writeDesktopReadingProtectionPreference(true);
writeEditorOutlineCollapsedPreference(false);
writeEditorContentAlignmentPreference("center");
writeEditorToolbarExpandedPreference(true);
writeEditorPhonePreviewPreference(true);
writeEditorPhonePreviewFollowPreference(false);
writeMemoListWidthPreference(400);
writeNotebookSortPreference("custom");
writeNotebookTreeCollapsedIdsPreference(["a"]);
writeNotebookSectionCollapsedPreference(true);
writeShortcutSettingsPreference(DEFAULT_SHORTCUT_SETTINGS);
}).not.toThrow();
});
test("rejects a stored notebook sort mode that is not supported", () => {
const values = installLocalStorage();
values.set(NOTEBOOK_SORT_STORAGE_KEY, "random");
expect(readNotebookSortPreference()).toBe("name-asc");
});
test("ignores a non-array collapsed notebook list", () => {
const values = installLocalStorage();
values.set(NOTEBOOK_TREE_COLLAPSED_IDS_STORAGE_KEY, JSON.stringify({ a: true }));
expect(readNotebookTreeCollapsedIdsPreference()).toEqual(new Set());
});
test("ignores a non-numeric memo list width", () => {
const values = installLocalStorage();
values.set(MEMO_LIST_WIDTH_STORAGE_KEY, "wide");
expect(readMemoListWidthPreference()).toBe(DEFAULT_MEMO_LIST_WIDTH_PX);
});
test("persists shortcut settings as JSON", () => {
const values = installLocalStorage();
writeShortcutSettingsPreference(DEFAULT_SHORTCUT_SETTINGS);
expect(JSON.parse(values.get(SHORTCUT_SETTINGS_STORAGE_KEY))).toEqual(DEFAULT_SHORTCUT_SETTINGS);
});
test("falls back to default shortcuts when the stored value is not JSON", () => {
const values = installLocalStorage();
values.set(SHORTCUT_SETTINGS_STORAGE_KEY, "{not json");
expect(readShortcutSettingsPreference()).toEqual(getDefaultShortcutSettings());
});
});
describe("normalizeShortcutKey", () => {
test("names the space bar and lowercases other keys", () => {
expect(normalizeShortcutKey(" ")).toBe("space");
expect(normalizeShortcutKey("K")).toBe("k");
expect(normalizeShortcutKey("ArrowUp")).toBe("arrowup");
});
});
describe("shortcut edge cases", () => {
test("bindings without a primary modifier resolve to no modifiers on any platform", () => {
const binding = { key: "k", ctrlOrMeta: false, shift: false, alt: true };
expect(resolvedShortcutModifiers(binding, "MacIntel")).toEqual({ ctrl: false, meta: false });
expect(resolvedShortcutModifiers(binding, "Win32")).toEqual({ ctrl: false, meta: false });
});
test("ignores bare modifier keys and unmodified key presses", () => {
const event = { key: "Shift", code: "ShiftLeft", ctrlKey: true, metaKey: false, altKey: false, shiftKey: true };
expect(shortcutBindingFromKeyboardEvent(event, "Win32")).toBeNull();
expect(shortcutBindingFromKeyboardEvent({ ...event, key: "k", code: "KeyK", ctrlKey: false }, "Win32")).toBeNull();
expect(getShortcutActionForEvent({ ...event, key: "k", code: "KeyK", ctrlKey: false }, DEFAULT_SHORTCUT_SETTINGS, "Win32")).toBeNull();
});
});
describe("notebook tree helpers", () => {
test("returns expandable ancestors of a notebook, including itself when it has children", () => {
const tree = notebookTreeFixture();
expect(getNotebookAncestorIds(tree, "alpha")).toEqual(["work", "projects"]);
expect(getNotebookAncestorIds(tree, "projects")).toEqual(["work", "projects"]);
expect(getNotebookAncestorIds(tree, "personal")).toEqual([]);
expect(getNotebookAncestorIds(tree, "missing")).toEqual([]);
});
test("lists every notebook that has children", () => {
expect(getExpandableNotebookIds(notebookTreeFixture())).toEqual(["work", "projects"]);
expect(getExpandableNotebookIds([])).toEqual([]);
});
test("detects whether a notebook is anywhere in the tree", () => {
const tree = notebookTreeFixture();
expect(notebookTreeContainsId(tree, "alpha")).toBe(true);
expect(notebookTreeContainsId(tree, "personal")).toBe(true);
expect(notebookTreeContainsId(tree, "missing")).toBe(false);
});
test("filters the tree to matches and keeps their ancestors", () => {
const tree = notebookTreeFixture();
expect(filterNotebookTree(tree, " ")).toBe(tree);
const filtered = filterNotebookTree(tree, "alp");
const flatten = (nodes) => nodes.flatMap((node) => [node.id, ...flatten(node.children)]);
expect(flatten(filtered)).toEqual(["work", "projects", "alpha"]);
// Filtering does not mutate the original tree.
expect(flatten(tree)).toEqual(["work", "projects", "alpha", "beta", "personal"]);
expect(filterNotebookTree(tree, "zzz")).toEqual([]);
});
test("builds indented move options in tree order", () => {
const options = getNotebookMoveOptions([
makeNotebook("work", { name: "Work", slug: "work", sortOrder: 1 }),
makeNotebook("projects", { name: "Projects", parentId: "work" }),
makeNotebook("alpha", { name: "Alpha", parentId: "projects" }),
makeNotebook("personal", { name: "Personal", sortOrder: 2 }),
]);
expect(options).toEqual([
{ id: "work", name: "Work", selectLabel: "Work", slug: "work", depth: 0 },
{ id: "projects", name: "Projects", selectLabel: "  └ Projects", slug: null, depth: 1 },
{ id: "alpha", name: "Alpha", selectLabel: "    └ Alpha", slug: null, depth: 2 },
{ id: "personal", name: "Personal", selectLabel: "Personal", slug: null, depth: 0 },
]);
});
});
describe("date and notebook comparators", () => {
test("orders dates newest first and pushes invalid dates last", () => {
expect(compareDateDesc("2026-01-01T00:00:00.000Z", "2026-02-01T00:00:00.000Z")).toBeGreaterThan(0);
expect(compareDateDesc("2026-02-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z")).toBeLessThan(0);
expect(compareDateDesc("2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z")).toBe(0);
expect(compareDateDesc("invalid", "2026-01-01T00:00:00.000Z")).toBe(1);
expect(compareDateDesc("2026-01-01T00:00:00.000Z", "invalid")).toBe(-1);
expect(compareDateDesc("invalid", "also invalid")).toBe(0);
});
test("sorts notebooks by name with numeric awareness", () => {
const notebooks = [
makeNotebook("n10", { name: "Note 10" }),
makeNotebook("n2", { name: "Note 2" }),
makeNotebook("a", { name: "apple" }),
];
expect([...notebooks].sort(getNotebookSortComparator("name-asc")).map((item) => item.id)).toEqual(["a", "n2", "n10"]);
});
test("sorts notebooks by memo count, then name", () => {
const notebooks = [
makeNotebook("b", { name: "B", memoCount: 2 }),
makeNotebook("a", { name: "A", memoCount: 2 }),
makeNotebook("c", { name: "C", memoCount: 9 }),
];
expect([...notebooks].sort(getNotebookSortComparator("memo-count-desc")).map((item) => item.id)).toEqual(["c", "a", "b"]);
});
test("sorts notebooks by latest memo activity, falling back to notebook updates and name", () => {
const notebooks = [
makeNotebook("old", { name: "Old", updatedAt: "2026-01-01T00:00:00.000Z" }),
makeNotebook("recent-memo", { name: "Z", lastMemoUpdatedAt: "2026-03-01T00:00:00.000Z" }),
makeNotebook("tie-b", { name: "B", updatedAt: "2026-02-01T00:00:00.000Z" }),
makeNotebook("tie-a", { name: "A", updatedAt: "2026-02-01T00:00:00.000Z" }),
];
expect([...notebooks].sort(getNotebookSortComparator("updated-desc")).map((item) => item.id)).toEqual([
"recent-memo", "tie-a", "tie-b", "old",
]);
});
});
describe("memo sorting and filtering", () => {
const memos = [
makeMemo("b", { title: "Beta", updatedAt: "2026-01-03T00:00:00.000Z", createdAt: "2026-01-01T00:00:00.000Z" }),
makeMemo("a", { title: "alpha", updatedAt: "2026-01-02T00:00:00.000Z", createdAt: "2026-01-03T00:00:00.000Z", tags: ["x"] }),
makeMemo("pinned", { title: "Zulu", isPinned: true, updatedAt: "2026-01-01T00:00:00.000Z", createdAt: "2026-01-02T00:00:00.000Z" }),
makeMemo("a2", { title: "Alpha", updatedAt: "2026-01-04T00:00:00.000Z", createdAt: "2026-01-04T00:00:00.000Z" }),
];
const ids = (items) => items.map((memo) => memo.id);
test("keeps pinned memos first and orders the rest by last update", () => {
expect(ids(sortMemos(memos, "updated-desc"))).toEqual(["pinned", "a2", "b", "a"]);
expect(ids(sortMemos(memos, "updated-desc", false))).toEqual(["a2", "b", "a", "pinned"]);
});
test("orders by creation date", () => {
expect(ids(sortMemos(memos, "created-desc", false))).toEqual(["a2", "a", "pinned", "b"]);
});
test("orders by title case-insensitively with newest first among equal titles", () => {
expect(ids(sortMemos(memos, "title-asc", false))).toEqual(["a2", "a", "b", "pinned"]);
expect(ids(sortMemos(memos, "title-desc", false))).toEqual(["pinned", "b", "a2", "a"]);
});
test("does not mutate the input list", () => {
const before = ids(memos);
sortMemos(memos, "title-asc");
expect(ids(memos)).toEqual(before);
});
test("filters by tags and pins", () => {
expect(ids(filterMemos(memos, "tagged"))).toEqual(["a"]);
expect(ids(filterMemos(memos, "untagged"))).toEqual(["b", "pinned", "a2"]);
expect(ids(filterMemos(memos, "pinned"))).toEqual(["pinned"]);
expect(filterMemos(memos, "all")).toBe(memos);
});
});
describe("drag and drop helpers", () => {
const dataTransfer = (data) => ({
types: Object.keys(data),
getData: (type) => data[type] ?? "",
});
test("recognises EdgeEver drag payloads", () => {
const memoDrag = dataTransfer({ [MEMO_DRAG_MIME]: "[]" });
const notebookDrag = dataTransfer({ [NOTEBOOK_DRAG_MIME]: "nb" });
const fileDrag = dataTransfer({ Files: "" });
expect(hasMemoDragData(memoDrag)).toBe(true);
expect(hasNotebookDragData(memoDrag)).toBe(false);
expect(hasNotebookDragData(notebookDrag)).toBe(true);
expect(hasEdgeEverDragData(memoDrag)).toBe(true);
expect(hasEdgeEverDragData(notebookDrag)).toBe(true);
expect(hasEdgeEverDragData(fileDrag)).toBe(false);
});
test("reads only non-blank string memo ids from the drag payload", () => {
expect(getMemoDragIds(dataTransfer({ [MEMO_DRAG_MIME]: JSON.stringify(["m1", "", " ", 3, null, "m2"]) }))).toEqual(["m1", "m2"]);
expect(getMemoDragIds(dataTransfer({ [MEMO_DRAG_MIME]: JSON.stringify({ id: "m1" }) }))).toEqual([]);
expect(getMemoDragIds(dataTransfer({ [MEMO_DRAG_MIME]: "{broken" }))).toEqual([]);
expect(getMemoDragIds(dataTransfer({ "text/plain": JSON.stringify(["m1"]) }))).toEqual([]);
});
test("splits a notebook row into before, inside and after drop zones", () => {
const event = (clientY) => ({
clientY,
currentTarget: { getBoundingClientRect: () => ({ top: 100, height: 100 }) },
});
expect(getNotebookDropPosition(event(110))).toBe("before");
expect(getNotebookDropPosition(event(130))).toBe("inside");
expect(getNotebookDropPosition(event(150))).toBe("inside");
expect(getNotebookDropPosition(event(170))).toBe("inside");
expect(getNotebookDropPosition(event(190))).toBe("after");
});
test("computes a sort order between the neighbouring siblings", () => {
const notebooks = [
makeNotebook("a", { sortOrder: 1000 }),
makeNotebook("b", { sortOrder: 2000 }),
makeNotebook("c", { sortOrder: 4001 }),
makeNotebook("child", { parentId: "a", sortOrder: 50 }),
];
const [a, b, c, child] = notebooks;
expect(getNotebookDropSortOrder(notebooks, b, "before")).toBe(1500);
expect(getNotebookDropSortOrder(notebooks, b, "after")).toBe(3000);
expect(getNotebookDropSortOrder(notebooks, c, "before")).toBe(3000);
expect(getNotebookDropSortOrder(notebooks, a, "before")).toBe(0);
expect(getNotebookDropSortOrder(notebooks, c, "after")).toBe(5001);
// A lone child has no siblings to fit between.
expect(getNotebookDropSortOrder(notebooks, child, "before")).toBe(-950);
expect(getNotebookDropSortOrder(notebooks, child, "after")).toBe(1050);
});
test("appends after the last sibling when the target is not among the siblings", () => {
const notebooks = [makeNotebook("a", { sortOrder: 10 }), makeNotebook("b", { sortOrder: 20 })];
const stranger = makeNotebook("x", { sortOrder: 0 });
expect(getNotebookDropSortOrder(notebooks, stranger, "before")).toBe(1020);
});
test("offsets from the target itself when it has no siblings at all", () => {
const orphan = makeNotebook("orphan", { parentId: "gone", sortOrder: 500 });
expect(getNotebookDropSortOrder([], orphan, "before")).toBe(-500);
expect(getNotebookDropSortOrder([], orphan, "after")).toBe(1500);
});
});
describe("DOM helpers", () => {
// linkedom's window forwards writes to globalThis, so the fake setTimeout
// below replaces the real one; restore it after every test.
const domKeys = ["window", "document", "HTMLElement", "setTimeout"];
let saved;
const restore = () => {
for (const [key, descriptor] of saved) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete globalThis[key];
}
};
beforeAll(() => {
saved = new Map(domKeys.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
});
afterEach(restore);
afterAll(restore);
const installDom = (html = "") => {
const dom = parseHTML(`<!DOCTYPE html><html><head></head><body>${html}</body></html>`);
const timers = [];
dom.setTimeout = (callback) => {
timers.push(callback);
return timers.length;
};
globalThis.window = dom;
globalThis.document = dom.document;
globalThis.HTMLElement = dom.HTMLElement;
return { document: dom.document, timers };
};
test("recognises text entry targets", () => {
const { document: doc } = installDom(`
<input id="input"><textarea id="textarea"></textarea><select id="select"></select>
<div contenteditable="true"><span id="editable-child"></span></div>
<div role="textbox" id="textbox"></div>
<div class="ProseMirror"><p id="prose"></p></div>
<button id="button"></button>
`);
for (const id of ["input", "textarea", "select", "editable-child", "textbox", "prose"]) {
expect(isTextEntryTarget(doc.getElementById(id))).toBe(true);
}
expect(isTextEntryTarget(doc.getElementById("button"))).toBe(false);
expect(isTextEntryTarget(null)).toBe(false);
expect(isTextEntryTarget({ closest: () => true })).toBe(false);
});
test("moves focus between enabled notebook tree buttons", () => {
const { document: doc } = installDom(`
<div data-notebook-tree>
<button data-notebook-tree-button id="b1"></button>
<button data-notebook-tree-button id="b2" disabled></button>
<button data-notebook-tree-button id="b3"></button>
<button data-notebook-tree-button id="b4"></button>
</div>
<button data-notebook-tree-button id="outside"></button>
`);
const calls = [];
for (const button of doc.querySelectorAll("button")) {
button.focus = (options) => calls.push({ focus: button.id, options });
button.scrollIntoView = (options) => calls.push({ scroll: button.id, options });
}
const button = (id) => doc.getElementById(id);
focusNotebookTreeButton(button("b1"), "next");
expect(calls).toEqual([
{ focus: "b3", options: { preventScroll: true } },
{ scroll: "b3", options: { block: "nearest" } },
]);
calls.length = 0;
focusNotebookTreeButton(button("b3"), "previous");
focusNotebookTreeButton(button("b1"), "last");
focusNotebookTreeButton(button("b4"), "first");
expect(calls.filter((call) => call.focus).map((call) => call.focus)).toEqual(["b1", "b4", "b1"]);
calls.length = 0;
// Already at the edge, a disabled current button, or a button outside a tree.
focusNotebookTreeButton(button("b4"), "next");
focusNotebookTreeButton(button("b1"), "previous");
focusNotebookTreeButton(button("b1"), "first");
focusNotebookTreeButton(button("b2"), "next");
focusNotebookTreeButton(button("outside"), "next");
expect(calls).toEqual([]);
});
test("builds a temporary drag preview with the label as text", () => {
const { document: doc, timers } = installDom();
const dragImages = [];
const transfer = { setDragImage: (element, x, y) => dragImages.push({ element, x, y }) };
setMemoDragPreview(transfer, '<img src=x onerror="alert(1)"> 2 notes');
expect(dragImages).toHaveLength(1);
const { element, x, y } = dragImages[0];
expect({ x, y }).toEqual({ x: 16, y: 18 });
expect(element.parentNode).toBe(doc.body);
expect(element.querySelector("span").textContent).toBe('<img src=x onerror="alert(1)"> 2 notes');
expect(element.querySelector("img")).toBeNull();
expect(element.querySelector("svg")).not.toBeNull();
expect(element.style.position).toBe("fixed");
expect(element.style.pointerEvents).toBe("none");
expect(timers).toHaveLength(1);
timers[0]();
expect(element.parentNode).toBeNull();
});
});
+151 -1
View File
@@ -1,7 +1,157 @@
import { useEffect, useState } from "react";
import type { DesktopBackupStatus, DesktopBackupError } from "@edgeever/shared";
import type { DesktopBackupStatus, DesktopBackupError, DesktopBackupConfig, DesktopOffsiteConfig } from "@edgeever/shared";
import type { TFunction } from "i18next";
const createDevMockBackupState = (): DesktopBackupStatus => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 3600000).toISOString();
const nextMonth = new Date(now.getTime() + 86400000 * 30).toISOString();
return {
blocked: false,
config: {
enabled: true,
interval: "weekly",
retention: 5,
directory: "~/Documents/EdgeEver Backups",
encrypted: false,
},
backupSetId: "dev_workspace_preview",
actualDirectory: "~/Documents/EdgeEver Backups/dev_workspace_preview",
passwordSet: false,
secureStorageAvailable: true,
lastCheck: { at: oneHourAgo, result: "success" },
lastAttemptAt: oneHourAgo,
lastSuccessAt: oneHourAgo,
lastRestoreAt: null,
nextDueAt: nextMonth,
pendingPublication: null,
target: { baseUrl: "http://localhost:5173", accountId: "dev-user" },
error: null,
warning: null,
importError: null,
importWarning: null,
history: [
{
name: "EdgeEver-2026-11-02_130344.zip",
directory: "~/Documents/EdgeEver Backups/dev_workspace_preview",
completedAt: oneHourAgo,
size: 134680210,
encrypted: false,
},
{
name: "EdgeEver-2026-10-26_091211.zip",
directory: "~/Documents/EdgeEver Backups/dev_workspace_preview",
completedAt: new Date(now.getTime() - 86400000 * 7).toISOString(),
size: 125120890,
encrypted: false,
},
],
task: null,
offsite: {
config: { enabled: false, url: "", username: "", folder: "/EdgeEver/backups" },
passwordSet: false,
secureStorageAvailable: true,
lastAttemptAt: null,
lastSuccessAt: null,
lastUploaded: null,
error: null,
warning: null,
task: null,
},
};
};
if (import.meta.env.DEV && typeof window !== "undefined" && !window.edgeeverDesktop?.backup) {
let devStatus = createDevMockBackupState();
const listeners = new Set<(status: DesktopBackupStatus) => void>();
const broadcast = () => {
listeners.forEach((listener) => listener(devStatus));
};
const devBridge: Pick<EdgeEverDesktopBridge, "backup" | "onBackupStatus"> = {
onBackupStatus(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
async backup(action, payload) {
switch (action) {
case "status":
return devStatus as any;
case "save": {
const input = payload as { config: DesktopBackupConfig; password?: string };
devStatus = {
...devStatus,
config: input.config,
passwordSet: Boolean(input.password || devStatus.passwordSet),
};
broadcast();
return devStatus as any;
}
case "choose-directory":
return "~/Documents/EdgeEver Backups" as any;
case "open-folder":
console.info("[Dev Preview] 打开备份目录:", devStatus.actualDirectory);
return true as any;
case "run": {
const newName = `EdgeEver-${new Date().toISOString().slice(0, 10)}_${Date.now().toString().slice(-6)}.zip`;
devStatus = {
...devStatus,
lastSuccessAt: new Date().toISOString(),
lastCheck: { at: new Date().toISOString(), result: "success" },
history: [
{
name: newName,
directory: devStatus.actualDirectory,
completedAt: new Date().toISOString(),
size: 138900120,
encrypted: devStatus.config.encrypted,
},
...devStatus.history,
],
};
broadcast();
return devStatus as any;
}
case "delete-history": {
const input = payload as { name: string };
devStatus = {
...devStatus,
history: devStatus.history.filter((item) => item.name !== input.name),
};
broadcast();
return devStatus as any;
}
case "cancel":
devStatus = { ...devStatus, task: null };
broadcast();
return devStatus as any;
case "offsite-save": {
const input = payload as { config: DesktopOffsiteConfig; password?: string };
devStatus = {
...devStatus,
offsite: devStatus.offsite ? {
...devStatus.offsite,
config: input.config,
passwordSet: Boolean(input.password || devStatus.offsite.passwordSet),
} : null,
};
broadcast();
return devStatus as any;
}
case "offsite-test":
return { ok: true } as any;
default:
return devStatus as any;
}
},
};
window.edgeeverDesktop = {
...(window.edgeeverDesktop ?? ({} as any)),
...devBridge,
};
}
export const useDesktopBackup = () => {
const [status, setStatus] = useState<DesktopBackupStatus | null>(null);
const bridge = window.edgeeverDesktop;
@@ -317,3 +317,365 @@ describe("desktop Markdown note creation", () => {
expect(lastRequest.params.contentText).toBe("**literal**");
});
});
const { cancelPendingDesktopImportMemo } = await import("./desktop-repository.ts");
// Routes sidecar RPCs to `handlers` and records every call and dispatched event.
const withSidecar = async (handlers, run, { online = true, bridge = {} } = {}) => {
const { api } = await import("./api.ts");
const desktop = window.edgeeverDesktop;
const original = { ...desktop };
const dispatchEvent = window.dispatchEvent;
const onLine = Object.getOwnPropertyDescriptor(globalThis.navigator, "onLine");
Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: online });
const calls = [];
const events = [];
const apiOriginals = {};
const stubApi = (overrides) => {
for (const [key, value] of Object.entries(overrides)) {
if (!(key in apiOriginals)) apiOriginals[key] = api[key];
api[key] = value;
}
};
window.dispatchEvent = (event) => { events.push(event.type); return true; };
Object.assign(desktop, bridge, {
sidecarRequest: async (method, params) => {
calls.push({ method, params });
const handler = handlers[method];
if (!handler) throw new Error(`Unexpected sidecar method: ${method}`);
return handler(params);
},
});
try {
await run({ calls, events, stubApi });
} finally {
for (const key of Object.keys(desktop)) if (!(key in original)) delete desktop[key];
Object.assign(desktop, original);
Object.assign(api, apiOriginals);
window.dispatchEvent = dispatchEvent;
if (onLine) Object.defineProperty(globalThis.navigator, "onLine", onLine);
else delete globalThis.navigator.onLine;
}
};
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
const emptySummary = { totalCount: 0, totalBytes: 0, imageCount: 0, attachmentCount: 0 };
const desktopResource = (id, overrides = {}) => ({
id, memoId: "memo-1", kind: "attachment", filename: `${id}.txt`, byteSize: 3, url: `/api/v1/resources/${id}/blob`, ...overrides,
});
const desktopTemplate = (id, overrides = {}) => ({
id, name: id, description: null, title: `${id} title`, contentJson: { type: "doc", content: [] }, contentMarkdown: "**Body**", tags: ["t"],
createdAt: "2026-01-01", updatedAt: "2026-01-01", ...overrides,
});
describe("desktop repository bridge", () => {
test("rejects every request when the desktop bridge is unavailable", async () => {
window.edgeeverDesktop.isAvailable = false;
try {
await expect(createDesktopRepository().listNotebooks()).rejects.toThrow("EdgeEver desktop bridge is unavailable");
} finally {
window.edgeeverDesktop.isAvailable = true;
}
});
test("announces queue changes after every local mutation", async () => {
const ok = () => ({ ok: true });
const handlers = Object.fromEntries([
"notebook.create", "notebook.update", "notebook.delete", "notebook.restore", "template.create", "template.update", "template.delete",
"tag.rename", "tag.delete", "memo.moveBatch", "memo.deleteBatch", "memo.emptyTrash", "memo.pinBatch", "memo.delete",
].map((method) => [method, ok]));
handlers["memo.restore"] = () => ({ memo: { id: "memo-1" } });
await withSidecar(handlers, async ({ calls, events }) => {
const repository = createDesktopRepository();
await repository.createNotebook({ name: "A" });
await repository.updateNotebook("nb-1", { name: "B" });
await repository.deleteNotebook("nb-1");
await repository.restoreNotebook("nb-1");
await repository.createTemplate({ name: "T" });
await repository.updateTemplate("tpl-1", { name: "U" });
await repository.deleteTemplate("tpl-1");
await repository.renameTag("a", "b");
await repository.deleteTag("b");
await repository.moveMemos({ memoIds: ["m"], notebookId: "nb-2" });
await repository.deleteMemos({ memoIds: ["m"] });
await repository.emptyTrash();
await repository.pinMemos({ memoIds: ["m"], isPinned: true });
await repository.deleteMemo("memo-1");
expect(await repository.restoreMemo("memo-1")).toEqual({ memo: { id: "memo-1" }, queued: true });
expect(events).toHaveLength(15);
expect(new Set(events)).toEqual(new Set(["edgeever:sync-queue-changed"]));
expect(calls.find((call) => call.method === "notebook.update").params).toEqual({ notebookId: "nb-1", name: "B" });
expect(calls.find((call) => call.method === "template.update").params).toEqual({ templateId: "tpl-1", name: "U" });
expect(calls.find((call) => call.method === "memo.delete").params).toEqual({ memoId: "memo-1", permanent: false });
});
});
test("cancels a pending import by permanently deleting its queued create", async () => {
await withSidecar({ "memo.delete": () => ({ ok: true }) }, async ({ calls, events }) => {
expect(await cancelPendingDesktopImportMemo("memo-import")).toEqual({ ok: true });
expect(calls).toEqual([{ method: "memo.delete", params: { memoId: "memo-import", permanent: true, cancelPendingCreate: true } }]);
expect(events).toEqual(["edgeever:sync-queue-changed"]);
});
});
});
describe("desktop repository templates", () => {
test("returns cached templates and refreshes the cache in the background", async () => {
const cached = [];
await withSidecar({
"template.list": () => ({ templates: [desktopTemplate("tpl-local")] }),
"template.cache": ({ template }) => { cached.push(template.id); return { ok: true }; },
}, async ({ stubApi }) => {
stubApi({ listTemplates: async () => ({ templates: [desktopTemplate("tpl-remote")] }) });
expect((await createDesktopRepository().listTemplates()).templates.map((template) => template.id)).toEqual(["tpl-local"]);
await settle();
expect(cached).toEqual(["tpl-remote"]);
});
});
test("keeps cached templates when the background refresh fails", async () => {
await withSidecar({ "template.list": () => ({ templates: [desktopTemplate("tpl-local")] }) }, async ({ stubApi }) => {
stubApi({ listTemplates: async () => { throw new Error("network down"); } });
expect((await createDesktopRepository().listTemplates()).templates).toHaveLength(1);
await settle();
});
});
test("returns the empty cache offline without contacting the server", async () => {
await withSidecar({ "template.list": () => ({ templates: [] }) }, async ({ stubApi }) => {
stubApi({ listTemplates: async () => { throw new Error("must not call the server"); } });
expect(await createDesktopRepository().listTemplates()).toEqual({ templates: [] });
}, { online: false });
});
test("fills an empty cache from the server when online", async () => {
const cached = [];
await withSidecar({
"template.list": () => ({ templates: [] }),
"template.cache": ({ template }) => { cached.push(template.id); return { ok: true }; },
}, async ({ stubApi }) => {
stubApi({ listTemplates: async () => ({ templates: [desktopTemplate("tpl-a"), desktopTemplate("tpl-b")] }) });
expect((await createDesktopRepository().listTemplates()).templates.map((template) => template.id)).toEqual(["tpl-a", "tpl-b"]);
expect(cached).toEqual(["tpl-a", "tpl-b"]);
});
});
test("creates a note from a cached template and rejects an unknown template", async () => {
await withSidecar({
"template.list": () => ({ templates: [desktopTemplate("tpl-1")] }),
"memo.create": (params) => ({ memo: { id: "memo-new", ...params } }),
}, async ({ calls, stubApi }) => {
stubApi({ listTemplates: async () => ({ templates: [] }) });
const repository = createDesktopRepository();
const { memo } = await repository.useTemplate("tpl-1", "nb-1");
expect(memo).toMatchObject({ notebookId: "nb-1", title: "tpl-1 title", contentMarkdown: "**Body**", tags: ["t"], contentText: "Body" });
await expect(repository.useTemplate("tpl-missing", "nb-1")).rejects.toThrow("Template not found: tpl-missing");
expect(calls.filter((call) => call.method === "memo.create")).toHaveLength(1);
});
});
});
describe("desktop repository resources", () => {
test("caches uploaded resources in the sidecar and serves them through the desktop protocol", async () => {
await withSidecar({ "resource.cache": () => ({ ok: true }) }, async ({ calls, stubApi }) => {
stubApi({ uploadMemoResource: async () => ({ resource: desktopResource("res-up") }) });
const { resource } = await createDesktopRepository().uploadMemoResource("memo-1", new File(["abc"], "a.txt"));
expect(resource.url).toBe("edgeever-resource://resource/res-up");
expect(calls).toEqual([{ method: "resource.cache", params: { resource: desktopResource("res-up") } }]);
});
});
test("reads resource bytes from the escaped blob endpoint", async () => {
await withSidecar({}, async ({ stubApi }) => {
const urls = [];
stubApi({ getResourceResponse: async (url, init) => { urls.push([url, init]); return new Response("bytes"); } });
expect(await (await createDesktopRepository().readResource("res/1")).text()).toBe("bytes");
expect(urls).toEqual([["/api/v1/resources/res%2F1/blob", { cache: "no-store" }]]);
});
});
test("replaces the cached resource after updating its contents", async () => {
await withSidecar({ "resource.delete": () => ({ ok: true }), "resource.cache": () => ({ ok: true }) }, async ({ calls, stubApi }) => {
stubApi({ updateResourceContent: async (id) => ({ resource: desktopResource(id, { byteSize: 9 }) }) });
const { resource } = await createDesktopRepository().updateResource("res-1", new File(["x"], "a.txt"), "hash");
expect(resource.url).toBe("edgeever-resource://resource/res-1");
expect(calls.map((call) => call.method)).toEqual(["resource.delete", "resource.cache"]);
});
});
test("does not touch the sidecar cache when the server update fails", async () => {
await withSidecar({}, async ({ calls, stubApi }) => {
stubApi({ updateResourceContent: async () => { throw new Error("hash mismatch"); } });
await expect(createDesktopRepository().updateResource("res-1", new File(["x"], "a.txt"), "stale")).rejects.toThrow("hash mismatch");
expect(calls).toEqual([]);
});
});
test("lists cached resources with staged uploads first and summed totals", async () => {
let refreshes = 0;
await withSidecar({
"resource.list": () => ({ resources: [desktopResource("res-1"), desktopResource("res-abs", { url: "https://cdn.example.com/res-abs" })], summary: { totalCount: 2, totalBytes: 6, imageCount: 0, attachmentCount: 2 } }),
"resource.cache": () => ({ ok: true }),
}, async ({ stubApi }) => {
stubApi({ listResources: async () => { refreshes += 1; return { resources: [desktopResource("res-1")], summary: emptySummary }; } });
const result = await createDesktopRepository().listResources();
expect(result.resources.map((resource) => resource.url)).toEqual([
"edgeever-staged://stage-1",
"edgeever-resource://resource/res-1",
"https://cdn.example.com/res-abs",
]);
expect(result.summary).toEqual({ totalCount: 3, totalBytes: 16, imageCount: 1, attachmentCount: 2 });
await settle();
expect(refreshes).toBe(1);
}, { bridge: { apiBaseUrl: "https://notes.example.com/", listStagedResources: async () => [{ id: "stage-1", memoId: "memo-1", name: "shot.png", type: "image/png", size: 10 }] } });
});
test("returns the empty cache offline and fills it from the server online", async () => {
await withSidecar({ "resource.list": () => ({ resources: [], summary: emptySummary }) }, async ({ stubApi }) => {
stubApi({ listResources: async () => { throw new Error("must not call the server"); } });
expect(await createDesktopRepository().listResources()).toEqual({ resources: [], summary: emptySummary });
}, { online: false });
const cached = [];
await withSidecar({
"resource.list": () => ({ resources: [], summary: emptySummary }),
"resource.cache": ({ resource }) => { cached.push(resource.id); return { ok: true }; },
}, async ({ stubApi }) => {
stubApi({ listResources: async () => ({ resources: [desktopResource("res-2")], summary: { ...emptySummary, totalCount: 1 } }) });
const result = await createDesktopRepository().listResources();
expect(result.resources.map((resource) => resource.url)).toEqual(["edgeever-resource://resource/res-2"]);
expect(cached).toEqual(["res-2"]);
});
});
test("surfaces a server failure on an empty cache unless staged uploads can still be shown", async () => {
await withSidecar({ "resource.list": () => ({ resources: [], summary: emptySummary }) }, async ({ stubApi }) => {
stubApi({ listResources: async () => { throw new Error("network down"); } });
await expect(createDesktopRepository().listResources()).rejects.toThrow("network down");
});
});
test("renames and deletes resources on the server before updating the sidecar cache", async () => {
await withSidecar({ "resource.cache": () => ({ ok: true }), "resource.delete": () => ({ ok: true }) }, async ({ calls, stubApi }) => {
stubApi({
renameResource: async (id, filename) => ({ resource: desktopResource(id, { filename }) }),
deleteResource: async () => ({ ok: true }),
});
const repository = createDesktopRepository();
const renamed = await repository.renameResource("res-1", "new.txt");
expect(renamed.resource).toMatchObject({ filename: "new.txt", url: "edgeever-resource://resource/res-1" });
expect(await repository.deleteResource("res-1")).toEqual({ ok: true });
expect(calls.map((call) => call.method)).toEqual(["resource.cache", "resource.delete"]);
});
});
test("keeps the sidecar cache when the server delete fails", async () => {
await withSidecar({}, async ({ calls, stubApi }) => {
stubApi({ deleteResource: async () => { throw new Error("forbidden"); } });
await expect(createDesktopRepository().deleteResource("res-1")).rejects.toThrow("forbidden");
expect(calls).toEqual([]);
});
});
});
describe("desktop repository notes", () => {
test("lists tags from the sidecar", async () => {
await withSidecar({ "tag.list": () => ({ tags: [{ name: "a", memoCount: 1, updatedAt: null }] }) }, async () => {
expect((await createDesktopRepository().listTags()).tags[0].name).toBe("a");
});
});
test("merges notes in the sidecar and shows resource links through the desktop protocol", async () => {
await withSidecar({
"memo.merge": () => ({ memo: { id: "memo-merged", contentJson: { type: "doc", content: [{ type: "image", attrs: { src: "/api/v1/resources/res-1/blob" } }] }, contentMarkdown: "![a](/api/v1/resources/res-1/blob)" } }),
}, async ({ events }) => {
const { memo } = await createDesktopRepository().mergeMemos({ memoIds: ["a", "b"] });
expect(memo.contentJson.content[0].attrs.src).toBe("edgeever-resource://resource/res-1");
expect(memo.contentMarkdown).toBe("![a](edgeever-resource://resource/res-1)");
expect(events).toEqual(["edgeever:sync-queue-changed"]);
});
});
test("refuses to duplicate a note offline", async () => {
await withSidecar({}, async ({ calls }) => {
await expect(createDesktopRepository().duplicateMemo("memo-1")).rejects.toThrow("memo_duplicate_offline");
expect(calls).toEqual([]);
}, { online: false });
});
test("passes the guarded-save error through when it is neither a conflict nor an old host", async () => {
const memo = { id: "guarded", revision: 2, contentHash: "base", title: "Title", tags: [], contentMarkdown: "Original", contentJson: { type: "doc", content: [] } };
await withSidecar({ "memo.updateGuarded": () => { throw "disk full"; } }, async () => {
const error = await createDesktopRepository().updateMemo(memo, { title: "Title", contentJson: memo.contentJson, tags: [] }, { guardLocalSnapshot: true }).catch((caught) => caught);
expect(error).toBe("disk full");
});
});
test("adopts the cloud copy by acknowledging the update conflict and discarding other conflicts of the note", async () => {
await withSidecar({
"sync.apply": () => ({ ok: true }),
"sync.outbox.list": () => ({ items: [
{ id: 1, kind: "memo.update", entityId: "memo-1", status: "conflict", version: 3 },
{ id: 2, kind: "memo.delete", entityId: "memo-1", status: "conflict", version: 1 },
{ id: 3, kind: "memo.update", entityId: "memo-1", status: "pending", version: 4 },
{ id: 4, kind: "memo.update", entityId: "memo-other", status: "conflict", version: 1 },
] }),
"sync.outbox.ack": () => ({ ok: true }),
"sync.outbox.discard": () => ({ ok: true }),
}, async ({ calls, events, stubApi }) => {
const cloud = { id: "memo-1", title: "Cloud", contentJson: { type: "doc", content: [] }, contentMarkdown: "Cloud" };
stubApi({ getMemo: async () => ({ memo: cloud }) });
const { memo } = await createDesktopRepository().adoptCloudMemo("memo-1");
expect(memo.title).toBe("Cloud");
expect(calls.map((call) => call.method)).toEqual(["sync.apply", "sync.outbox.list", "sync.outbox.ack", "sync.outbox.discard"]);
expect(calls[2].params).toEqual({ id: 1, version: 3, remoteMemo: cloud });
expect(calls[3].params).toEqual({ id: 2 });
expect(events).toEqual(["edgeever:sync-queue-changed"]);
});
});
test("returns cached revisions offline and caches server revisions online", async () => {
const local = { revisions: [{ id: "rev-local" }] };
await withSidecar({ "memo.revisions": () => local }, async ({ stubApi }) => {
stubApi({ listMemoRevisions: async () => { throw new Error("must not call the server"); } });
expect(await createDesktopRepository().listMemoRevisions("memo-1")).toBe(local);
}, { online: false });
const cached = [];
await withSidecar({
"memo.revisions": () => local,
"memo.revision.cache": ({ revision }) => { cached.push(revision.id); return { ok: true }; },
}, async ({ stubApi }) => {
stubApi({ listMemoRevisions: async () => ({ revisions: [{ id: "rev-remote" }] }) });
expect((await createDesktopRepository().listMemoRevisions("memo-1")).revisions).toEqual([{ id: "rev-remote" }]);
expect(cached).toEqual(["rev-remote"]);
});
});
test("falls back to cached revisions on a server failure and reports it when none are cached", async () => {
const local = { revisions: [{ id: "rev-local" }] };
let revisions = local;
await withSidecar({ "memo.revisions": () => revisions }, async ({ stubApi }) => {
stubApi({ listMemoRevisions: async () => { throw new Error("network down"); } });
const repository = createDesktopRepository();
expect(await repository.listMemoRevisions("memo-1")).toBe(local);
revisions = { revisions: [] };
await expect(repository.listMemoRevisions("memo-1")).rejects.toThrow("network down");
});
});
test("restores a revision in the sidecar and shows it through the desktop protocol", async () => {
await withSidecar({
"memo.restoreRevision": () => ({ memo: { id: "memo-1", contentJson: { type: "doc", content: [] }, contentMarkdown: "![x](/api/v1/resources/res-9/blob)" } }),
}, async ({ calls, events }) => {
const { memo } = await createDesktopRepository().restoreMemoRevision("memo-1", "rev-1");
expect(memo.contentMarkdown).toBe("![x](edgeever-resource://resource/res-9)");
expect(calls[0].params).toEqual({ memoId: "memo-1", revisionId: "rev-1" });
expect(events).toEqual(["edgeever:sync-queue-changed"]);
});
});
test("reports a no-op sync because the desktop host synchronizes on its own", async () => {
expect(await createDesktopRepository().sync()).toEqual({ bootstrapped: false, changed: 0 });
});
});
+713 -1
View File
@@ -1,4 +1,16 @@
import { describe, expect, test } from "bun:test";
import { afterAll, describe, expect, test } from "bun:test";
// Dexie binds indexedDB when local-db is first imported, so install the fake
// before any test can load it, whatever the test order.
const { IDBKeyRange: FakeIDBKeyRange, indexedDB: fakeIndexedDB } = await import("fake-indexeddb");
const previousIndexedDB = globalThis.indexedDB;
const previousIDBKeyRange = globalThis.IDBKeyRange;
globalThis.indexedDB = fakeIndexedDB;
globalThis.IDBKeyRange = FakeIDBKeyRange;
afterAll(() => {
globalThis.indexedDB = previousIndexedDB;
globalThis.IDBKeyRange = previousIDBKeyRange;
});
const {
classifyDesktopSyncFailure,
@@ -526,3 +538,703 @@ describe("desktop sync failure handling", () => {
expect(shouldAttemptDesktopRecoveryPull("sync_staged_resources", { pending: 0, syncing: 0 }, false)).toBe(false);
});
});
const {
discardDesktopMemoConflict,
discardDesktopSyncIssue,
getDesktopSyncIssues,
getDesktopSyncSummary,
recoverDesktopMemoUpdate,
retryDesktopSyncIssue,
} = await import("./desktop-sync.ts");
// A stateful fake of the desktop sidecar outbox. Acknowledged and failed items
// leave the queue, as they would once the sidecar schedules their next attempt.
const runWithDesktopSidecar = async (options, run) => {
const {
queue: initialQueue = [],
status = null,
localMemos = {},
revisions = null,
staged = [],
aliases = [],
bridge = {},
online = true,
api: apiOverrides = {},
} = options;
const previousWindow = globalThis.window;
const previousNavigator = globalThis.navigator;
const originalApi = {};
for (const [key, value] of Object.entries(apiOverrides)) {
originalApi[key] = api[key];
api[key] = value;
}
const state = { queue: initialQueue.map((item) => ({ attemptCount: 0, status: "pending", ...item })), calls: [], acks: [], failures: [], applied: [], events: [], removedStaged: [], aliasesRecorded: [] };
const handle = async (method, params) => {
state.calls.push({ method, params });
switch (method) {
case "sync.outbox.list": return { items: state.queue.map((item) => ({ ...item, payload: { ...item.payload } })) };
case "sync.outbox.ack": state.acks.push(params); state.queue = state.queue.filter((item) => item.id !== params.id); return { ok: true };
case "sync.outbox.fail": state.failures.push(params); state.queue = state.queue.filter((item) => item.id !== params.id); return { ok: true };
case "sync.status": return status ? status(state) : { pending: state.queue.length, syncing: 0, conflict: 0, error: 0, cursor: 5, syncIdentity: "workspace-a" };
case "sync.apply": state.applied.push(...params.changes); return { ok: true };
case "memo.get": {
const memo = localMemos[params.memoId];
if (!memo) throw new Error(`memo_not_found: ${params.memoId}`);
return { memo: typeof memo === "function" ? memo(state) : memo };
}
case "memo.revisions":
if (!revisions) throw new Error("Unknown sidecar method: memo.revisions");
return { revisions };
default: return { ok: true };
}
};
globalThis.window = {
dispatchEvent: (event) => { state.events.push(event.type); return true; },
edgeeverDesktop: {
isAvailable: true,
listStagedResources: async () => staged,
listStagedResourceAliases: async () => aliases,
readStagedResourcePart: async () => new Uint8Array([1, 2, 3]),
recordStagedResourceAlias: async (id, url) => { state.aliasesRecorded.push([id, url]); },
removeStagedResource: async (id) => { state.removedStaged.push(id); },
sidecarRequest: handle,
...bridge,
},
};
globalThis.navigator = { onLine: online };
try {
await run(state);
} finally {
Object.assign(api, originalApi);
globalThis.navigator = previousNavigator;
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
}
};
const settledStatus = () => ({ pending: 0, syncing: 0, conflict: 0, error: 0, cursor: 5, syncIdentity: "workspace-a" });
const quietPull = { syncChanges: async () => ({ changes: [], cursor: 5, hasMore: false }) };
const doc = (text) => ({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text }] }] });
describe("desktop outbox replay", () => {
test("replays every supported action against the server and acknowledges it", async () => {
const serverCalls = [];
const record = (name, result = { ok: true }) => async (...args) => { serverCalls.push([name, ...args]); return result; };
const queue = [
{ id: 1, version: 1, kind: "memo.delete", entityId: "memo-1", payload: { memoId: "memo-1", permanent: true } },
{ id: 2, version: 1, kind: "memo.deleteBatch", entityId: "memo-2", payload: { memoIds: ["memo-2", 7], permanent: false } },
{ id: 3, version: 1, kind: "memo.emptyTrash", entityId: "trash", payload: {} },
{ id: 4, version: 1, kind: "memo.pinBatch", entityId: "memo-3", payload: { memoIds: ["memo-3", "memo-4"], isPinned: true } },
{ id: 5, version: 1, kind: "memo.merge", entityId: "memo-merged", payload: { memoIds: ["memo-5", "memo-6"], notebookId: "nb-1", title: "Merged" } },
{ id: 6, version: 1, kind: "notebook.update", entityId: "nb-1", payload: { notebookId: "nb-1", name: "Renamed", parentId: 5, sortOrder: 2, icon: null, color: "red", iconInverted: true, isBold: false, colorTarget: "both" } },
{ id: 7, version: 1, kind: "notebook.delete", entityId: "nb-2", payload: { notebookIds: ["nb-child", "nb-2"] } },
{ id: 8, version: 1, kind: "notebook.restore", entityId: "nb-3", payload: {} },
{ id: 9, version: 1, kind: "template.create", entityId: "tpl-local", payload: { name: "T", tags: ["a", 1] } },
{ id: 10, version: 1, kind: "template.update", entityId: "tpl-1", payload: { name: "U", contentMarkdown: "body" } },
{ id: 11, version: 1, kind: "template.delete", entityId: "tpl-2", payload: {} },
{ id: 12, version: 1, kind: "tag.rename", entityId: "old", payload: { tag: "old", name: "new" } },
{ id: 13, version: 1, kind: "tag.delete", entityId: "gone", payload: {} },
{ id: 14, version: 1, kind: "memo.move", entityId: "memo-7", payload: { memoIds: ["memo-7"], notebookId: "nb-4" } },
{ id: 15, version: 1, kind: "memo.restore", entityId: "memo-8", payload: {} },
];
const merged = { id: "memo-merged-cloud", contentJson: { type: "doc", content: [] }, contentMarkdown: "" };
await runWithDesktopSidecar({
queue,
status: () => ({ pending: 1, syncing: 0, conflict: 0, error: 0, cursor: 5, syncIdentity: "workspace-a" }),
api: {
deleteMemo: record("deleteMemo"),
deleteMemos: record("deleteMemos"),
emptyTrash: record("emptyTrash"),
updateMemo: record("updateMemo"),
mergeMemos: record("mergeMemos", { memo: merged }),
updateNotebook: record("updateNotebook", { notebook: { id: "nb-1", name: "Renamed", parentId: null } }),
deleteNotebook: record("deleteNotebook"),
restoreNotebook: record("restoreNotebook", { notebook: { id: "nb-3", name: "Back", parentId: null } }),
createTemplate: record("createTemplate", { template: { id: "tpl-cloud" } }),
updateTemplate: record("updateTemplate", { template: { id: "tpl-1" } }),
deleteTemplate: record("deleteTemplate"),
renameTag: record("renameTag"),
deleteTag: record("deleteTag"),
moveMemos: record("moveMemos"),
restoreMemo: record("restoreMemo"),
},
}, async (state) => {
const result = await syncDesktopData();
expect(result).toMatchObject({ attempted: 15, synced: 15, failed: 0, conflicted: 0 });
expect(state.failures).toEqual([]);
expect(state.acks.map((ack) => ack.id)).toEqual(queue.map((item) => item.id));
expect(serverCalls).toEqual([
["deleteMemo", "memo-1", { permanent: true }],
["deleteMemos", { memoIds: ["memo-2"], permanent: false }],
["emptyTrash"],
["updateMemo", "memo-3", { isPinned: true }],
["updateMemo", "memo-4", { isPinned: true }],
["mergeMemos", { memoIds: ["memo-5", "memo-6"], notebookId: "nb-1", title: "Merged" }],
["updateNotebook", "nb-1", { name: "Renamed", parentId: null, sortOrder: 2, icon: null, color: "red", textColor: undefined, iconInverted: true, rowInverted: undefined, isBold: false, colorTarget: "both" }],
["deleteNotebook", "nb-child"],
["deleteNotebook", "nb-2"],
["restoreNotebook", "nb-3"],
["createTemplate", { name: "T", description: null, title: null, contentMarkdown: "", tags: ["a"] }],
["updateTemplate", "tpl-1", { name: "U", description: null, title: null, contentMarkdown: "body", tags: [] }],
["deleteTemplate", "tpl-2"],
["renameTag", "old", "new"],
["deleteTag", "gone"],
["moveMemos", { memoIds: ["memo-7"], notebookId: "nb-4" }],
["restoreMemo", "memo-8"],
]);
expect(state.acks.find((ack) => ack.id === 5).remoteMemo).toEqual(merged);
expect(state.acks.find((ack) => ack.id === 9).remoteTemplate).toEqual({ id: "tpl-cloud" });
expect(state.applied.filter((change) => change.entityType === "notebook").map((change) => change.entityId)).toEqual(["nb-1", "nb-3"]);
expect(state.events).toContain("edgeever:memo-sync-acknowledged");
expect(state.calls.some((call) => call.method === "sync.cursor.set")).toBe(false);
});
});
test("keeps an unsupported action as a permanent, non-retryable error", async () => {
await runWithDesktopSidecar({
queue: [{ id: 1, version: 2, kind: "memo.archive", entityId: "memo-1", payload: {} }],
api: quietPull,
}, async (state) => {
const result = await syncDesktopData();
expect(result).toMatchObject({ attempted: 1, synced: 0, failed: 1 });
expect(state.failures).toEqual([{
id: 1, version: 2, error: "Unsupported desktop sync operation: memo.archive", errorCode: "unexpected_sync_error", conflict: false, retryable: false, nextAttemptAt: null,
}]);
});
});
test("schedules network and server failures for retry but not client errors", async () => {
const failures = {
"memo-net": new TypeError("Failed to fetch"),
"memo-busy": new ApiRequestError("Busy", 429),
"memo-bad": new ApiRequestError("Bad request", 400, "invalid_input"),
};
await runWithDesktopSidecar({
queue: Object.keys(failures).map((memoId, index) => ({ id: index + 1, version: 1, attemptCount: 2, kind: "memo.delete", entityId: memoId, payload: {} })),
api: { deleteMemo: async (memoId) => { throw failures[memoId]; }, ...quietPull },
}, async (state) => {
const result = await syncDesktopData();
expect(result).toMatchObject({ attempted: 3, failed: 3, conflicted: 0 });
expect(state.failures.map(({ id, errorCode, retryable, conflict }) => ({ id, errorCode, retryable, conflict }))).toEqual([
{ id: 1, errorCode: "network_error", retryable: true, conflict: false },
{ id: 2, errorCode: "http_429", retryable: true, conflict: false },
{ id: 3, errorCode: "invalid_input", retryable: false, conflict: false },
]);
expect(typeof state.failures[0].nextAttemptAt).toBe("string");
expect(state.failures[2].nextAttemptAt).toBeNull();
});
});
test("waits for local notebook and note ids before replaying dependent actions", async () => {
let deletes = 0;
await runWithDesktopSidecar({
queue: [
{ id: 1, version: 1, kind: "notebook.update", entityId: "nb-1", payload: { notebookId: "nb-1", parentId: "nb_local_parent" } },
{ id: 2, version: 1, kind: "memo.delete", entityId: "memo_local_1", payload: {} },
{ id: 3, version: 1, kind: "memo.restore", entityId: "memo-2", payload: { memoId: "memo_local_2" } },
],
api: { deleteMemo: async () => { deletes += 1; }, restoreMemo: async () => { deletes += 1; } },
}, async (state) => {
const result = await syncDesktopData();
expect(result.attempted).toBe(0);
expect(deletes).toBe(0);
expect(state.acks).toEqual([]);
expect(state.failures).toEqual([]);
expect(state.queue).toHaveLength(3);
});
});
test("returns an empty result without touching the sidecar while offline", async () => {
await runWithDesktopSidecar({ online: false, queue: [{ id: 1, version: 1, kind: "memo.delete", entityId: "memo-1", payload: {} }] }, async (state) => {
expect(await syncDesktopData()).toMatchObject({ attempted: 0, synced: 0, failed: 0, conflicted: 0 });
expect(state.calls).toEqual([]);
});
});
test("shares one running synchronization between concurrent callers", async () => {
let releaseDelete;
await runWithDesktopSidecar({
queue: [{ id: 1, version: 1, kind: "memo.delete", entityId: "memo-1", payload: {} }],
api: { deleteMemo: () => new Promise((resolve) => { releaseDelete = resolve; }), ...quietPull },
}, async () => {
const first = syncDesktopData();
const second = syncDesktopData();
expect(second).toBe(first);
let pauseResolved = false;
const pause = pauseDesktopSyncForImport().then((release) => { pauseResolved = true; return release; });
await new Promise((resolve) => setTimeout(resolve, 5));
expect(pauseResolved).toBe(false);
releaseDelete({ ok: true });
expect((await first).synced).toBe(1);
(await pause)();
expect(pauseResolved).toBe(true);
});
});
});
describe("desktop memo update replay", () => {
const updateItem = (payload) => ({ id: 1, version: 3, kind: "memo.update", entityId: "memo-1", payload: { memoId: "memo-1", title: "Draft", tags: [], contentMarkdown: "Local draft", contentJson: doc("Local draft"), ...payload } });
const editSession = (baseRevision, baseContentHash) => async () => ({ editSession: { id: "session-1", baseRevision, baseContentHash } });
const remote = (overrides = {}) => ({ id: "memo-1", title: "Draft", tags: [], revision: 10, contentHash: "cloud-10", contentMarkdown: "Someone else", contentJson: doc("Someone else"), ...overrides });
test("sends a draft whose base is still current and stores the acknowledged note", async () => {
const updates = [];
const saved = remote({ revision: 4, contentHash: "cloud-4", contentMarkdown: "Local draft" });
await runWithDesktopSidecar({
queue: [updateItem({ expectedRevision: 3, expectedContentHash: "cloud-3", contentMarkdown: "![a](edgeever-resource://resource/res-1)" })],
api: { createMemoEditSession: editSession(3, "cloud-3"), updateMemo: async (memoId, input) => { updates.push([memoId, input]); return { memo: saved }; }, ...quietPull },
}, async (state) => {
const result = await syncDesktopData();
expect(result.synced).toBe(1);
expect(result.syncedMemos.get("memo-1")).toEqual(saved);
expect(updates[0][1]).toMatchObject({ expectedRevision: 3, expectedContentHash: "cloud-3", editSessionId: "session-1", contentMarkdown: "![a](/api/v1/resources/res-1/blob)" });
expect(state.acks[0]).toMatchObject({ id: 1, version: 3, remoteMemo: saved });
});
});
test("acknowledges a lost save without sending it again when the cloud already holds the draft", async () => {
let updates = 0;
const cloud = remote({ contentMarkdown: "Local draft", contentJson: doc("Local draft") });
await runWithDesktopSidecar({
queue: [updateItem({ expectedRevision: 9, expectedContentHash: "cloud-9" })],
api: { createMemoEditSession: editSession(10, "cloud-10"), getMemo: async () => ({ memo: cloud }), updateMemo: async () => { updates += 1; }, ...quietPull },
}, async (state) => {
expect((await syncDesktopData()).synced).toBe(1);
expect(updates).toBe(0);
expect(state.acks[0].remoteMemo).toEqual(cloud);
});
});
test("rebases onto the cloud revision when a local snapshot proves this device wrote it", async () => {
const updates = [];
const cloud = remote({ contentMarkdown: "Earlier local text", contentHash: "cloud-10" });
await runWithDesktopSidecar({
queue: [updateItem({ expectedRevision: 9, expectedContentHash: "cloud-9" })],
revisions: [{ id: "revision_local_1", revision: 9, contentHash: "sidecar-x", contentMarkdown: "Earlier local text" }],
api: {
createMemoEditSession: editSession(10, "cloud-10"),
getMemo: async () => ({ memo: cloud }),
updateMemo: async (memoId, input) => { updates.push(input); return { memo: remote({ revision: 11 }) }; },
...quietPull,
},
}, async (state) => {
expect((await syncDesktopData()).synced).toBe(1);
expect(updates[0]).toMatchObject({ expectedRevision: 10, expectedContentHash: "cloud-10" });
expect(state.failures).toEqual([]);
});
});
test("keeps a draft as a conflict when the cloud changed elsewhere and local history is unavailable", async () => {
let updates = 0;
await runWithDesktopSidecar({
queue: [updateItem({ expectedRevision: 9, expectedContentHash: "cloud-9" })],
status: () => ({ pending: 0, syncing: 0, conflict: 1, error: 0, cursor: 5, syncIdentity: "workspace-a" }),
api: { createMemoEditSession: editSession(10, "cloud-10"), getMemo: async () => ({ memo: remote() }), updateMemo: async () => { updates += 1; }, ...quietPull },
}, async (state) => {
const result = await syncDesktopData();
expect(result).toMatchObject({ conflicted: 1, failed: 0, synced: 0 });
expect(updates).toBe(0);
expect(state.failures[0]).toMatchObject({ id: 1, conflict: true, retryable: false, errorCode: "revision_conflict", nextAttemptAt: null });
expect(state.calls.filter((call) => call.method === "sync.apply")).toEqual([]);
});
});
test("stops retrying a draft whose cloud note no longer exists", async () => {
await runWithDesktopSidecar({
queue: [updateItem({ expectedRevision: 3, expectedContentHash: "cloud-3" })],
api: { createMemoEditSession: async () => { throw new ApiRequestError("Not found", 404, "not_found"); }, ...quietPull },
}, async (state) => {
await syncDesktopData();
expect(state.failures[0]).toMatchObject({ errorCode: "memo_not_found", retryable: false, conflict: false });
});
});
});
describe("desktop remote pulls", () => {
test("bootstraps an uninitialized mirror page by page with the first page's cursor", async () => {
const pages = [
{ notebooks: [{ id: "child", parentId: "parent" }, { id: "parent", parentId: null }], memos: [{ id: "memo-1" }], snapshotCursor: 40, syncIdentity: "workspace-a", nextAfterId: "memo-1" },
{ notebooks: [], memos: [{ id: "memo-2" }], snapshotCursor: 99, syncIdentity: "workspace-z", nextAfterId: null },
];
const requested = [];
await runWithDesktopSidecar({
status: () => ({ ...settledStatus(), cursor: 0, syncIdentity: null }),
api: { syncBootstrap: async (params) => { requested.push(params); return pages.shift(); } },
}, async (state) => {
await syncDesktopData();
expect(requested).toEqual([{ afterId: null, limit: 200 }, { afterId: "memo-1", limit: 200 }]);
expect(state.calls.find((call) => call.method === "sync.bootstrap.prepare").params).toEqual({});
expect(state.applied.map((change) => change.entityId)).toEqual(["parent", "child", "memo-1", "memo-2"]);
expect(state.calls.find((call) => call.method === "sync.cursor.set").params).toEqual({ cursor: 40, syncIdentity: "workspace-a" });
});
});
test("applies incremental change pages until the server has no more", async () => {
const pages = [
{ changes: [{ entityType: "memo", entityId: "memo-1", operation: "upsert" }], cursor: 6, hasMore: true, serverCursor: 7, syncIdentity: "workspace-a" },
{ changes: [{ entityType: "memo", entityId: "memo-2", operation: "delete" }], cursor: 7, hasMore: false, serverCursor: 7, syncIdentity: "workspace-a" },
];
const cursors = [];
await runWithDesktopSidecar({
status: settledStatus,
api: { syncChanges: async ({ cursor }) => { cursors.push(cursor); return pages.shift(); } },
}, async (state) => {
await syncDesktopData();
expect(cursors).toEqual([5, 6]);
expect(state.applied.map((change) => change.entityId)).toEqual(["memo-1", "memo-2"]);
expect(state.calls.find((call) => call.method === "sync.cursor.set").params).toEqual({ cursor: 7, syncIdentity: "workspace-a" });
});
});
test("rebuilds the mirror when the server workspace was reset", async () => {
await runWithDesktopSidecar({
status: settledStatus,
api: {
syncChanges: async () => ({ changes: [], cursor: 0, hasMore: false, serverCursor: 1, syncIdentity: "workspace-b" }),
syncBootstrap: async () => ({ notebooks: [], memos: [], snapshotCursor: 1, syncIdentity: "workspace-b", nextAfterId: null }),
},
}, async (state) => {
await syncDesktopData();
expect(state.calls.find((call) => call.method === "sync.bootstrap.prepare").params).toEqual({ reset: true });
expect(state.applied).toEqual([]);
expect(state.calls.find((call) => call.method === "sync.cursor.set").params).toEqual({ cursor: 1, syncIdentity: "workspace-b" });
});
});
test("rebuilds the mirror when a later change page reveals a rewound cursor", async () => {
const pages = [
{ changes: [{ entityType: "memo", entityId: "memo-1", operation: "upsert" }], cursor: 9, hasMore: true, serverCursor: 9 },
{ changes: [], cursor: 0, hasMore: false, serverCursor: 2 },
];
let bootstraps = 0;
await runWithDesktopSidecar({
status: settledStatus,
api: {
syncChanges: async () => pages.shift(),
syncBootstrap: async () => { bootstraps += 1; return { notebooks: [], memos: [], snapshotCursor: 2, syncIdentity: "workspace-a", nextAfterId: null }; },
},
}, async (state) => {
await syncDesktopData();
expect(bootstraps).toBe(1);
expect(state.calls.filter((call) => call.method === "sync.cursor.set").map((call) => call.params)).toEqual([{ cursor: 2, syncIdentity: "workspace-a" }]);
});
});
});
describe("desktop sync failure recovery", () => {
test("records a failed resource phase, still pulls remote changes and clears the issue after a clean sync", async () => {
let pulls = 0;
let stagedFails = true;
const previousConsoleError = console.error;
console.error = () => {};
try {
await runWithDesktopSidecar({
status: settledStatus,
bridge: { listStagedResources: async () => { if (stagedFails) throw new Error("staging directory unavailable"); return []; } },
api: { syncChanges: async () => { pulls += 1; return { changes: [], cursor: 5, hasMore: false }; } },
}, async () => {
expect((await syncDesktopData()).failed).toBe(1);
expect(pulls).toBe(1);
expect(await getDesktopSyncSummary()).toEqual({ total: 1, pending: 0, syncing: 0, conflict: 0, error: 1 });
expect((await getDesktopSyncIssues()).globalIssue).toMatchObject({ phase: "sync_staged_resources", message: "staging directory unavailable", errorCode: null });
stagedFails = false;
expect((await syncDesktopData()).failed).toBe(0);
expect((await getDesktopSyncIssues()).globalIssue).toBeNull();
});
} finally {
console.error = previousConsoleError;
}
});
test("does not retry a failed pull and reports its HTTP status", async () => {
let pulls = 0;
const logged = [];
const previousConsoleError = console.error;
console.error = (...args) => { logged.push(args[0]); };
try {
await runWithDesktopSidecar({
status: settledStatus,
api: { syncChanges: async () => { pulls += 1; throw new ApiRequestError("Server error", 500); } },
}, async () => {
expect((await syncDesktopData()).failed).toBe(1);
expect(pulls).toBe(1);
expect((await getDesktopSyncIssues()).globalIssue).toMatchObject({ phase: "pull_remote_changes", errorCode: "http_500" });
expect(logged).toEqual(["[desktop-sync] Sync failed"]);
});
} finally {
console.error = previousConsoleError;
}
});
test("keeps the id mapping of an acknowledged create when a later phase fails", async () => {
const previousConsoleError = console.error;
console.error = () => {};
try {
await runWithDesktopSidecar({
queue: [{ id: 1, version: 1, kind: "memo.create", entityId: "memo_local_1", payload: { notebookId: "nb-1", contentMarkdown: "Hi" } }],
status: () => ({ pending: 1, syncing: 0, conflict: 0, error: 0, cursor: 5, syncIdentity: "workspace-a" }),
bridge: { remapStagedResourceMemoIds: async () => { throw new TypeError("bridge reloaded"); } },
api: { createMemo: async () => ({ memo: { id: "memo-cloud-1", contentJson: { type: "doc", content: [] }, contentMarkdown: "Hi" } }) },
}, async (state) => {
const result = await syncDesktopData();
expect(result.failed).toBe(1);
expect(result.memoIdMappings.get("memo_local_1")).toBe("memo-cloud-1");
expect(state.events).toContain("edgeever:memo-id-remapped");
expect((await getDesktopSyncIssues()).globalIssue).toMatchObject({ phase: "remap_created_resources", errorCode: "network_error" });
});
} finally {
console.error = previousConsoleError;
}
});
});
describe("desktop staged resource upload", () => {
const stagedImage = { id: "stage-1", memoId: "memo-1", name: "shot.png", type: "image/png", size: 3 };
const stagedUrl = "edgeever-staged://stage-1";
const uploadedUrl = "/api/v1/resources/res-1/blob";
test("uploads a saved staged image, rewrites the queued draft and removes the staged copy", async () => {
const uploads = [];
const updates = [];
await runWithDesktopSidecar({
staged: [stagedImage, { id: "stage-unsaved", memoId: "memo-1", name: "draft.png", type: "image/png", size: 3 }],
queue: [{ id: 1, version: 1, kind: "memo.update", entityId: "memo-1", payload: { memoId: "memo-1", expectedRevision: 2, expectedContentHash: "cloud-2", title: "", tags: [], contentMarkdown: `![shot](${stagedUrl})`, contentJson: { type: "doc", content: [{ type: "image", attrs: { src: stagedUrl } }] } } }],
localMemos: { "memo-1": { id: "memo-1", contentMarkdown: `![shot](${uploadedUrl})`, contentJson: { type: "doc", content: [] } } },
api: {
uploadMemoResourceParts: async (memoId, source) => {
uploads.push([memoId, source.filename, source.byteSize, await (await source.readPart(0, 3)).arrayBuffer()]);
return { resource: { id: "res-1", url: uploadedUrl } };
},
createMemoEditSession: async () => ({ editSession: { id: "session", baseRevision: 2, baseContentHash: "cloud-2" } }),
updateMemo: async (memoId, input) => { updates.push(input); return { memo: { id: memoId, contentJson: input.contentJson, contentMarkdown: input.contentMarkdown } }; },
...quietPull,
},
}, async (state) => {
const result = await syncDesktopData();
expect(result).toMatchObject({ failed: 0, synced: 2, attempted: 3 });
expect(uploads).toEqual([["memo-1", "shot.png", 3, new Uint8Array([1, 2, 3]).buffer]]);
expect(state.aliasesRecorded).toEqual([["stage-1", uploadedUrl]]);
expect(state.calls.find((call) => call.method === "resource.cache").params).toEqual({ resource: { id: "res-1", url: uploadedUrl } });
expect(updates[0].contentMarkdown).toBe(`![shot](${uploadedUrl})`);
expect(updates[0].contentJson.content[0].attrs.src).toBe(uploadedUrl);
expect(state.removedStaged).toEqual(["stage-1"]);
});
});
test("keeps the staged copy and counts a failure when the upload fails", async () => {
await runWithDesktopSidecar({
staged: [stagedImage],
queue: [{ id: 1, version: 1, kind: "template.update", entityId: "tpl-1", payload: { templateId: "tpl-1", contentMarkdown: `![shot](${stagedUrl})` } }],
status: () => ({ pending: 1, syncing: 0, conflict: 0, error: 0, cursor: 5, syncIdentity: "workspace-a" }),
api: {
uploadMemoResourceParts: async () => { throw new TypeError("offline"); },
updateTemplate: async () => ({ template: { id: "tpl-1" } }),
},
}, async (state) => {
const result = await syncDesktopData();
expect(result).toMatchObject({ failed: 1, attempted: 2 });
expect(state.aliasesRecorded).toEqual([]);
expect(state.removedStaged).toEqual([]);
});
});
test("refuses to consume a staged image when the host cannot record its durable alias", async () => {
await runWithDesktopSidecar({
staged: [stagedImage],
queue: [{ id: 1, version: 1, kind: "template.update", entityId: "tpl-1", payload: { templateId: "tpl-1", contentMarkdown: `![shot](${stagedUrl})` } }],
status: () => ({ pending: 1, syncing: 0, conflict: 0, error: 0, cursor: 5, syncIdentity: "workspace-a" }),
bridge: { recordStagedResourceAlias: undefined },
api: {
uploadMemoResourceParts: async () => ({ resource: { id: "res-1", url: uploadedUrl } }),
updateTemplate: async () => ({ template: { id: "tpl-1" } }),
},
}, async (state) => {
expect((await syncDesktopData()).failed).toBe(1);
expect(state.removedStaged).toEqual([]);
});
});
test("patches a created note that still shows an already uploaded staged image", async () => {
const updates = [];
let cloudMarkdown = `![shot](${stagedUrl})`;
await runWithDesktopSidecar({
staged: [stagedImage],
aliases: [{ id: "stage-1", memoId: "memo-1", resourceId: "res-1" }],
localMemos: { "memo-1": () => ({ id: "memo-1", contentMarkdown: cloudMarkdown, contentJson: { type: "doc", content: [] } }) },
api: {
getMemo: async () => ({ memo: { id: "memo-1", title: null, tags: ["a"], contentMarkdown: cloudMarkdown, contentJson: { type: "doc", content: [] } } }),
createMemoEditSession: async () => ({ editSession: { id: "session", baseRevision: 4, baseContentHash: "cloud-4" } }),
updateMemo: async (memoId, input) => {
updates.push(input);
cloudMarkdown = input.contentMarkdown;
return { memo: { id: memoId, contentMarkdown: input.contentMarkdown } };
},
...quietPull,
},
}, async (state) => {
const result = await syncDesktopData();
expect(result.failed).toBe(0);
expect(updates).toEqual([{ expectedRevision: 4, expectedContentHash: "cloud-4", editSessionId: "session", contentJson: { type: "doc", content: [] }, contentMarkdown: `![shot](${uploadedUrl})`, title: "", tags: ["a"] }]);
expect(state.applied.map((change) => change.entityId)).toEqual(["memo-1"]);
expect(state.removedStaged).toEqual(["stage-1"]);
});
});
test("leaves the cloud note alone while a queued local edit will carry the rewritten image", async () => {
let cloudReads = 0;
await runWithDesktopSidecar({
staged: [stagedImage],
aliases: [{ id: "stage-1", memoId: "memo-1", resourceId: "res-1" }],
queue: [{ id: 1, version: 1, kind: "memo.update", entityId: "memo-1", payload: { memoId: "memo-1", expectedRevision: 2, expectedContentHash: "h", title: "", tags: [], contentMarkdown: `![shot](${stagedUrl})` } }],
status: () => ({ pending: 1, syncing: 0, conflict: 0, error: 0, cursor: 5, syncIdentity: "workspace-a" }),
api: {
getMemo: async () => { cloudReads += 1; throw new Error("must not read the cloud note"); },
createMemoEditSession: async () => { throw new TypeError("offline"); },
},
}, async (state) => {
expect((await syncDesktopData()).failed).toBe(1);
expect(cloudReads).toBe(0);
expect(state.removedStaged).toEqual([]);
});
});
});
describe("desktop sync issue actions", () => {
test("retries, discards and recovers individual outbox items and announces the change", async () => {
const item = { id: 3, version: 4, kind: "memo.update", entityId: "memo-1" };
await runWithDesktopSidecar({}, async (state) => {
await retryDesktopSyncIssue(item);
await discardDesktopSyncIssue(item);
state.calls.length = 0;
await runRecover(state, item);
expect(state.events.filter((event) => event === "edgeever:sync-queue-changed")).toHaveLength(3);
});
async function runRecover(state, outboxItem) {
window.edgeeverDesktop.sidecarRequest = async (method, params) => {
state.calls.push({ method, params });
return { memo: { id: "memo-recovered" } };
};
expect(await recoverDesktopMemoUpdate(outboxItem, "nb-inbox")).toEqual({ id: "memo-recovered" });
expect(state.calls).toEqual([{ method: "sync.outbox.recoverMemoUpdate", params: { id: 3, version: 4, notebookId: "nb-inbox" } }]);
}
});
test("discarding all conflicts adopts the cloud copy of edited notes and drops other conflicted actions", async () => {
const cloud = { id: "memo-1", title: "Cloud" };
await runWithDesktopSidecar({
queue: [
{ id: 1, version: 2, kind: "memo.update", entityId: "memo-1", status: "conflict", payload: {} },
{ id: 2, version: 1, kind: "notebook.delete", entityId: "nb-1", status: "conflict", payload: {} },
{ id: 3, version: 1, kind: "memo.delete", entityId: "memo-2", status: "pending", payload: {} },
],
api: { getMemo: async () => ({ memo: cloud }) },
}, async (state) => {
expect(await discardDesktopConflicts()).toBe(2);
expect(state.applied).toEqual([{ entityType: "memo", operation: "upsert", entityId: "memo-1", memo: cloud, notebook: null }]);
expect(state.acks).toEqual([{ id: 1, version: 2, remoteMemo: cloud }]);
expect(state.calls.filter((call) => call.method === "sync.outbox.discard").map((call) => call.params)).toEqual([{ id: 2 }]);
});
});
test("keeps a conflict when the cloud copy cannot be read for a reason other than deletion", async () => {
await runWithDesktopSidecar({
queue: [{ id: 1, version: 2, kind: "memo.update", entityId: "memo-1", status: "conflict", payload: {} }],
api: { getMemo: async () => { throw new ApiRequestError("Unavailable", 503); } },
}, async (state) => {
await expect(discardDesktopConflicts()).rejects.toThrow("Unavailable");
expect(state.calls.map((call) => call.method)).toEqual(["sync.outbox.list"]);
});
});
test("adopting one note's cloud copy only resolves that note's conflicts", async () => {
const cloud = { id: "memo-1", title: "Cloud" };
await runWithDesktopSidecar({
queue: [
{ id: 1, version: 2, kind: "memo.update", entityId: "memo-1", status: "conflict", payload: {} },
{ id: 2, version: 1, kind: "memo.delete", entityId: "memo-1", status: "conflict", payload: {} },
{ id: 3, version: 1, kind: "memo.update", entityId: "memo-2", status: "conflict", payload: {} },
{ id: 4, version: 1, kind: "memo.update", entityId: "memo-1", status: "pending", payload: {} },
],
api: { getMemo: async () => ({ memo: cloud }) },
}, async (state) => {
expect(await discardDesktopMemoConflict("memo-1")).toEqual(cloud);
expect(state.acks).toEqual([{ id: 1, version: 2, remoteMemo: cloud }]);
expect(state.calls.filter((call) => call.method === "sync.outbox.discard").map((call) => call.params)).toEqual([{ id: 2 }]);
expect(state.events).toEqual(["edgeever:sync-queue-changed"]);
});
});
});
describe("desktop staged resource cleanup safeguards", () => {
const stagedImage = { id: "stage-1", memoId: "memo-1", name: "shot.png", type: "image/png", size: 3 };
const stagedUrl = "edgeever-staged://stage-1";
const uploadedUrl = "/api/v1/resources/res-1/blob";
const savedUpdate = { id: 1, version: 1, kind: "memo.update", entityId: "memo-1", payload: { memoId: "memo-1", expectedRevision: 2, expectedContentHash: "cloud-2", title: "", tags: [], contentMarkdown: `![shot](${stagedUrl})` } };
const uploadApi = (overrides = {}) => ({
uploadMemoResourceParts: async () => ({ resource: { id: "res-1", url: uploadedUrl } }),
createMemoEditSession: async () => ({ editSession: { id: "session", baseRevision: 2, baseContentHash: "cloud-2" } }),
updateMemo: async (memoId, input) => ({ memo: { id: memoId, contentJson: input.contentJson, contentMarkdown: input.contentMarkdown } }),
...quietPull,
...overrides,
});
test("keeps the staged copy when the local note cannot be read or patched", async () => {
let cloudReads = 0;
await runWithDesktopSidecar({
staged: [stagedImage],
queue: [savedUpdate],
api: uploadApi({ getMemo: async () => { cloudReads += 1; throw new TypeError("offline"); } }),
}, async (state) => {
const result = await syncDesktopData();
expect(result.failed).toBe(0);
expect(cloudReads).toBe(1);
expect(state.removedStaged).toEqual([]);
});
});
test("keeps the staged copy while an open editor still shows its old URL", async () => {
const previousDocument = globalThis.document;
globalThis.document = {
querySelectorAll: (selector) => selector === "textarea"
? [{ value: "" }]
: [{ getAttribute: (name) => (name === "src" ? stagedUrl : null) }],
};
try {
await runWithDesktopSidecar({
staged: [stagedImage],
queue: [savedUpdate],
localMemos: { "memo-1": { id: "memo-1", contentMarkdown: `![shot](${uploadedUrl})`, contentJson: { type: "doc", content: [] } } },
api: uploadApi(),
}, async (state) => {
expect((await syncDesktopData()).failed).toBe(0);
expect(state.aliasesRecorded).toEqual([["stage-1", uploadedUrl]]);
expect(state.removedStaged).toEqual([]);
});
} finally {
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
}
});
test("logs a failed recovery pull without masking the original failure", async () => {
const logged = [];
const previousConsoleError = console.error;
console.error = (...args) => { logged.push(args[0]); };
try {
await runWithDesktopSidecar({
status: settledStatus,
bridge: { listStagedResources: async () => { throw new Error("staging directory unavailable"); } },
api: { syncChanges: async () => { throw new TypeError("offline"); } },
}, async () => {
expect((await syncDesktopData()).failed).toBe(1);
expect(logged).toEqual(["[desktop-sync] Sync failed", "[desktop-sync] Recovery pull failed"]);
expect((await getDesktopSyncIssues()).globalIssue).toMatchObject({ phase: "sync_staged_resources", message: "staging directory unavailable" });
});
} finally {
console.error = previousConsoleError;
}
});
});
@@ -0,0 +1,88 @@
import { expect, test, mock } from "bun:test";
import { createFocusFullscreen } from "./focus-fullscreen";
const setup = () => {
const events = new EventTarget();
const doc = Object.assign(events, {
fullscreenEnabled: true,
fullscreenElement: null,
documentElement: {},
exitFullscreen: mock(async () => {
doc.fullscreenElement = null;
events.dispatchEvent(new Event("fullscreenchange"));
}),
});
doc.documentElement.requestFullscreen = mock(async () => {
doc.fullscreenElement = doc.documentElement;
events.dispatchEvent(new Event("fullscreenchange"));
});
const onExit = mock();
return { doc, onExit, control: createFocusFullscreen(doc, onExit) };
};
test("requests fullscreen during the gesture and exits with the focus control", async () => {
const { doc, control, onExit } = setup();
control.setEnabled(true);
expect(doc.documentElement.requestFullscreen).toHaveBeenCalledTimes(1);
await Promise.resolve();
control.setEnabled(false);
expect(doc.exitFullscreen).toHaveBeenCalledTimes(1);
expect(onExit).not.toHaveBeenCalled();
control.dispose();
});
test("browser Escape exits focus mode and repeated sessions remain usable", async () => {
const { doc, control, onExit } = setup();
control.setEnabled(true);
await Promise.resolve();
await doc.exitFullscreen();
expect(onExit).toHaveBeenCalledTimes(1);
control.setEnabled(true);
await Promise.resolve();
control.dispose();
expect(doc.fullscreenElement).toBeNull();
});
test("unsupported, rejected and synchronously failed requests retain focus fallback", async () => {
for (const failure of ["unsupported", "rejected", "thrown"]) {
const { doc, control, onExit } = setup();
if (failure === "unsupported") doc.fullscreenEnabled = false;
else doc.documentElement.requestFullscreen = mock(() => {
if (failure === "thrown") throw new Error("Denied");
return Promise.reject(new Error("Denied"));
});
control.setEnabled(true);
await Promise.resolve();
control.setEnabled(false);
expect(onExit).not.toHaveBeenCalled();
expect(doc.exitFullscreen).not.toHaveBeenCalled();
control.dispose();
}
});
test("leaves fullscreen opened by other controls alone", () => {
const { doc, control } = setup();
doc.fullscreenElement = doc.documentElement;
control.setEnabled(true);
control.setEnabled(false);
control.dispose();
expect(doc.documentElement.requestFullscreen).not.toHaveBeenCalled();
expect(doc.exitFullscreen).not.toHaveBeenCalled();
});
test("exits a pending request after focus mode closes or unmounts", async () => {
for (const dispose of [false, true]) {
const { doc, control, onExit } = setup();
let resolve;
doc.documentElement.requestFullscreen = () => new Promise((done) => { resolve = done; });
control.setEnabled(true);
if (dispose) control.dispose();
else control.setEnabled(false);
doc.fullscreenElement = doc.documentElement;
resolve();
await Promise.resolve();
expect(doc.exitFullscreen).toHaveBeenCalledTimes(1);
expect(onExit).not.toHaveBeenCalled();
control.dispose();
}
});
+59
View File
@@ -0,0 +1,59 @@
/** Own only the fullscreen session started by the focus-mode control. */
export const createFocusFullscreen = (doc: Document, onExit: () => void) => {
let enabled = false;
let owned = false;
let pending = false;
const exit = () => {
if (!owned || pending) return;
owned = false;
if (doc.fullscreenElement === doc.documentElement) {
void doc.exitFullscreen().catch(() => undefined);
}
};
const onChange = () => {
if (owned && !doc.fullscreenElement && !pending) {
owned = false;
if (enabled) {
enabled = false;
onExit();
}
}
};
doc.addEventListener("fullscreenchange", onChange);
const setEnabled = (next: boolean) => {
enabled = next;
if (!next) {
exit();
return;
}
if (pending || doc.fullscreenElement || !doc.fullscreenEnabled || !doc.documentElement.requestFullscreen) return;
// Invoke synchronously in the click/shortcut event to retain user activation.
pending = true;
owned = true;
try {
void doc.documentElement.requestFullscreen().then(() => {
pending = false;
if (!enabled) exit();
else onChange();
}, (error: unknown) => {
pending = false;
owned = false;
console.warn("Browser fullscreen unavailable; keeping focus mode.", error);
});
} catch (error) {
pending = false;
owned = false;
console.warn("Browser fullscreen unavailable; keeping focus mode.", error);
}
};
return {
setEnabled,
dispose: () => {
doc.removeEventListener("fullscreenchange", onChange);
setEnabled(false);
},
};
};
+471
View File
@@ -0,0 +1,471 @@
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
import { parseHTML } from "linkedom";
// linkedom's window forwards writes to globalThis, so location and setTimeout set on it below must be restored too.
const GLOBAL_KEYS = ["window", "document", "fetch", "FileReader", "location", "setTimeout"];
const originalDescriptors = new Map(
GLOBAL_KEYS.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]),
);
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
const linkedomWindow = parseHTML("<!DOCTYPE html><html><head></head><body></body></html>");
linkedomWindow.location = { href: "https://notes.example/app/memo/1" };
linkedomWindow.setTimeout = (callback) => {
callback();
return 0;
};
globalThis.window = linkedomWindow;
globalThis.document = linkedomWindow.document;
// Deterministic data URL encoder: "data:<type>;base64,<payload>".
class FakeFileReader {
readAsDataURL(blob) {
blob.arrayBuffer().then((buffer) => {
if (blob.type === "application/x-unreadable") {
this.error = new Error("unreadable blob");
this.onerror?.();
return;
}
if (blob.type === "application/x-binary-result") {
this.result = buffer;
this.onload?.();
return;
}
this.result = `data:${blob.type};base64,${Buffer.from(buffer).toString("base64")}`;
this.onload?.();
});
}
}
globalThis.FileReader = FakeFileReader;
afterAll(() => {
for (const [key, descriptor] of originalDescriptors) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete globalThis[key];
}
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
});
let fetchCalls = [];
const installFetch = (routes) => {
fetchCalls = [];
globalThis.fetch = async (url, init) => {
fetchCalls.push({ url, init });
const route = routes[url];
if (!route) throw new TypeError("network down");
if (route.status && route.status !== 200) return new Response("missing", { status: route.status });
return new Response(new Blob([route.body], { type: route.type }), { status: 200 });
};
};
afterEach(() => {
globalThis.fetch = originalDescriptors.get("fetch")?.value;
document.body.innerHTML = "";
});
let mod;
beforeAll(async () => {
mod = await import("./note-html-export.ts");
});
const createRoot = (html) => {
const root = document.createElement("div");
root.innerHTML = html;
return root;
};
describe("buildHtmlFilename", () => {
test("replaces reserved filename characters and collapses whitespace", () => {
expect(mod.buildHtmlFilename('a<b>c:d"e/f\\g|h?i*j', "Untitled")).toBe("a-b-c-d-e-f-g-h-i-j.html");
expect(mod.buildHtmlFilename("Tab\tand\nnewline", "Untitled")).toBe("Tab-and-newline.html");
expect(mod.buildHtmlFilename(" many spaces ", "Untitled")).toBe("many spaces.html");
});
test("strips trailing dots and spaces and falls back when nothing is left", () => {
expect(mod.buildHtmlFilename("Report... ", "Untitled")).toBe("Report.html");
expect(mod.buildHtmlFilename("...", "Untitled")).toBe("Untitled.html");
expect(mod.buildHtmlFilename("", "Fallback")).toBe("Fallback.html");
});
test("prefixes Windows device names, including ones with extensions", () => {
expect(mod.buildHtmlFilename("con", "Untitled")).toBe("_con.html");
expect(mod.buildHtmlFilename("LPT1.txt", "Untitled")).toBe("_LPT1.txt.html");
expect(mod.buildHtmlFilename("COM0", "Untitled")).toBe("COM0.html");
expect(mod.buildHtmlFilename("console", "Untitled")).toBe("console.html");
});
test("keeps an existing html extension and normalizes .htm", () => {
expect(mod.buildHtmlFilename("page.html", "Untitled")).toBe("page.html");
expect(mod.buildHtmlFilename("page.HTML", "Untitled")).toBe("page.HTML");
expect(mod.buildHtmlFilename("legacy.htm", "Untitled")).toBe("legacy.html");
});
test("truncates long titles to 100 characters before adding the extension", () => {
const filename = mod.buildHtmlFilename("x".repeat(250), "Untitled");
expect(filename).toBe(`${"x".repeat(100)}.html`);
});
});
describe("escapeHtml", () => {
test("escapes all markup-significant characters", () => {
expect(mod.escapeHtml(`<a href="x" onclick='y'>&</a>`)).toBe(
"&lt;a href=&quot;x&quot; onclick=&#39;y&#39;&gt;&amp;&lt;/a&gt;",
);
});
test("escapes ampersands first so entities are not double-decoded", () => {
expect(mod.escapeHtml("&lt;script&gt;")).toBe("&amp;lt;script&amp;gt;");
});
});
describe("getHtmlImageEmbedNoticeKind", () => {
test("classifies image embedding outcomes", () => {
expect(mod.createEmptyHtmlImageEmbedResult()).toEqual({ total: 0, embedded: 0, failed: 0 });
expect(mod.getHtmlImageEmbedNoticeKind({ total: 0, embedded: 0, failed: 0 })).toBe("none");
expect(mod.getHtmlImageEmbedNoticeKind({ total: 3, embedded: 3, failed: 0 })).toBe("none");
expect(mod.getHtmlImageEmbedNoticeKind({ total: 3, embedded: 1, failed: 2 })).toBe("partial");
expect(mod.getHtmlImageEmbedNoticeKind({ total: 2, embedded: 0, failed: 2 })).toBe("failed-all");
});
});
describe("buildNoteHtmlContentMarkup", () => {
test("joins notebook, date and tags into one escaped metadata line", () => {
const markup = mod.buildNoteHtmlContentMarkup({
title: "Plan",
notebook: "Work",
tags: ["a", "b"],
updatedAt: "2026-01-02",
bodyHtml: "<p>Body</p>",
});
expect(markup).toContain('<div class="edgeever-html-meta">Work · 2026-01-02 · #a · #b</div>');
expect(markup).toContain('<h1 class="edgeever-html-title">Plan</h1>');
expect(markup).toContain("<p>Body</p>");
});
test("omits the metadata line when there is no metadata", () => {
const markup = mod.buildNoteHtmlContentMarkup({ title: "Only title", bodyHtml: "" });
expect(markup).not.toContain("edgeever-html-meta");
});
test("escapes user-controlled title and metadata so they cannot inject markup", () => {
const markup = mod.buildNoteHtmlContentMarkup({
title: "<script>alert(1)</script>",
notebook: "<img src=x onerror=alert(2)>",
tags: ['"><svg onload=alert(3)>'],
updatedAt: "<b>now</b>",
bodyHtml: "",
});
expect(markup).not.toContain("<script>");
expect(markup).not.toContain("<img");
expect(markup).not.toContain("<svg");
expect(markup).not.toContain("<b>");
expect(markup).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
expect(markup).toContain("#&quot;&gt;&lt;svg onload=alert(3)&gt;");
const parsed = createRoot(markup);
expect(parsed.querySelectorAll("script, img, svg, b")).toHaveLength(0);
expect(parsed.querySelector(".edgeever-html-title").textContent).toBe("<script>alert(1)</script>");
});
});
describe("buildStandaloneHtmlDocument", () => {
test("produces a complete document with defaults", () => {
const html = mod.buildStandaloneHtmlDocument({ title: "Doc", bodyHtml: "<p>Hi</p>" });
expect(html.startsWith("<!DOCTYPE html>\n<html lang=\"en\">")).toBe(true);
expect(html).toContain('<meta charset="utf-8" />');
expect(html).toContain('<meta name="generator" content="EdgeEver" />');
expect(html).toContain("<title>Doc</title>");
expect(html).toContain("<style>\n\n</style>");
expect(html).toContain("<p>Hi</p>");
expect(html.endsWith("</html>\n")).toBe(true);
});
test("escapes title and language attribute while keeping styles and body verbatim", () => {
const html = mod.buildStandaloneHtmlDocument({
title: "</title><script>alert(1)</script>",
language: 'en" onload="alert(1)',
notebook: "NB",
tags: ["t"],
updatedAt: "today",
bodyHtml: "<p>body</p>",
styles: "body { color: red; }",
});
expect(html).toContain('<html lang="en&quot; onload=&quot;alert(1)">');
expect(html).toContain("<title>&lt;/title&gt;&lt;script&gt;alert(1)&lt;/script&gt;</title>");
expect(html).not.toContain("<script>");
expect(html).toContain("body { color: red; }");
expect(html).toContain("NB · today · #t");
const { document: exported } = parseHTML(html);
expect(exported.documentElement.getAttribute("lang")).toBe('en" onload="alert(1)');
expect(exported.documentElement.hasAttribute("onload")).toBe(false);
expect(exported.querySelectorAll("script")).toHaveLength(0);
expect(exported.querySelector("title").textContent).toBe("</title><script>alert(1)</script>");
});
});
describe("createHtmlFile and downloadHtmlFile", () => {
test("creates a UTF-8 html blob with a sanitized filename", async () => {
const { blob, filename } = mod.createHtmlFile("<p>ü</p>", "a/b", "Untitled");
expect(filename).toBe("a-b.html");
expect(blob.type).toBe("text/html;charset=utf-8");
expect(await blob.text()).toBe("<p>ü</p>");
});
test("clicks a hidden temporary link and revokes the object URL", async () => {
const created = [];
const revoked = [];
URL.createObjectURL = (blob) => {
created.push(blob);
return "blob:https://notes.example/export-1";
};
URL.revokeObjectURL = (url) => revoked.push(url);
const clicks = [];
const originalCreateElement = document.createElement.bind(document);
document.createElement = (tag) => {
const element = originalCreateElement(tag);
if (tag === "a") {
element.click = () => clicks.push({
href: element.href,
// linkedom percent-encodes the download attribute; browsers keep it verbatim.
download: decodeURIComponent(element.download),
hidden: element.hidden,
attached: element.parentNode === document.body,
});
}
return element;
};
try {
const filename = mod.downloadHtmlFile("<html></html>", "My: note", "Untitled");
expect(filename).toBe("My- note.html");
expect(created).toHaveLength(1);
expect(await created[0].text()).toBe("<html></html>");
expect(clicks).toEqual([{
href: "blob:https://notes.example/export-1",
download: "My- note.html",
hidden: true,
attached: true,
}]);
expect(document.body.querySelector("a")).toBeNull();
expect(revoked).toEqual(["blob:https://notes.example/export-1"]);
} finally {
delete document.createElement;
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
}
});
});
describe("embedImagesForHtmlExport", () => {
test("keeps data URLs, inlines fetched images and resolves relative URLs against the page", async () => {
installFetch({
"https://notes.example/app/images/a.png": { body: "PNG", type: "image/png" },
"https://cdn.example/b.gif": { body: "GIF", type: "image/gif" },
});
const root = createRoot([
'<img src="data:image/png;base64,AAAA">',
'<img src="../images/a.png">',
'<img src="https://cdn.example/b.gif">',
'<img src=" ">',
"<img>",
].join(""));
const result = await mod.embedImagesForHtmlExport(root);
expect(result).toEqual({ total: 3, embedded: 3, failed: 0 });
const sources = Array.from(root.querySelectorAll("img"), (img) => img.getAttribute("src"));
expect(sources[0]).toBe("data:image/png;base64,AAAA");
expect(sources[1]).toBe(`data:image/png;base64,${Buffer.from("PNG").toString("base64")}`);
expect(sources[2]).toBe(`data:image/gif;base64,${Buffer.from("GIF").toString("base64")}`);
const urls = fetchCalls.map((call) => call.url).sort();
expect(urls).toEqual(["https://cdn.example/b.gif", "https://notes.example/app/images/a.png"]);
for (const call of fetchCalls) expect(call.init).toEqual({ credentials: "include" });
});
test("fetches blob URLs without credentials", async () => {
installFetch({ "blob:https://notes.example/123": { body: "IMG", type: "image/webp" } });
const root = createRoot('<img src="blob:https://notes.example/123">');
const result = await mod.embedImagesForHtmlExport(root);
expect(result).toEqual({ total: 1, embedded: 1, failed: 0 });
expect(fetchCalls).toEqual([{ url: "blob:https://notes.example/123", init: { credentials: "omit" } }]);
expect(root.querySelector("img").getAttribute("src")).toStartWith("data:image/webp;base64,");
});
test("leaves the original link in place and counts failures for HTTP errors, network errors and unreadable blobs", async () => {
installFetch({
"https://cdn.example/missing.png": { status: 404 },
"https://cdn.example/bad.bin": { body: "x", type: "application/x-unreadable" },
"https://cdn.example/odd.bin": { body: "x", type: "application/x-binary-result" },
"https://cdn.example/ok.png": { body: "OK", type: "image/png" },
});
const root = createRoot([
'<img src="https://cdn.example/missing.png">',
'<img src="https://offline.example/x.png">',
'<img src="https://cdn.example/bad.bin">',
'<img src="https://cdn.example/odd.bin">',
'<img src="https://cdn.example/ok.png">',
].join(""));
const result = await mod.embedImagesForHtmlExport(root);
expect(result).toEqual({ total: 5, embedded: 1, failed: 4 });
expect(mod.getHtmlImageEmbedNoticeKind(result)).toBe("partial");
const sources = Array.from(root.querySelectorAll("img"), (img) => img.getAttribute("src"));
expect(sources.slice(0, 4)).toEqual([
"https://cdn.example/missing.png",
"https://offline.example/x.png",
"https://cdn.example/bad.bin",
"https://cdn.example/odd.bin",
]);
expect(sources[4]).toStartWith("data:image/png;base64,");
});
test("falls back to the raw src when the page URL cannot be resolved", async () => {
const location = window.location;
window.location = { href: "not a url" };
installFetch({});
try {
const root = createRoot('<img src="relative.png">');
const result = await mod.embedImagesForHtmlExport(root);
expect(result).toEqual({ total: 1, embedded: 0, failed: 1 });
expect(mod.getHtmlImageEmbedNoticeKind(result)).toBe("failed-all");
expect(fetchCalls.map((call) => call.url)).toEqual(["relative.png"]);
} finally {
window.location = location;
}
});
});
describe("code block preparation", () => {
test("highlights regular code blocks but leaves Mermaid sources alone", () => {
const root = createRoot([
'<pre><code class="language-javascript">const answer = 42;</code></pre>',
'<pre><code class="language-mermaid">graph TD\n A --> B</code></pre>',
].join(""));
mod.highlightCodeBlocksForHtmlExport(root);
const [js, mermaid] = root.querySelectorAll("pre > code");
expect(js.classList.contains("hljs")).toBe(true);
expect(js.querySelector(".hljs-keyword").textContent).toBe("const");
expect(js.textContent).toBe("const answer = 42;");
expect(mermaid.classList.contains("hljs")).toBe(false);
expect(mermaid.innerHTML).not.toContain("<span");
});
test("keeps the code block when highlighting throws", () => {
const root = createRoot('<pre><code class="language-javascript">x</code></pre>');
const code = root.querySelector("code");
Object.defineProperty(code, "innerHTML", {
get: () => { throw new Error("broken node"); },
configurable: true,
});
expect(() => mod.highlightCodeBlocksForHtmlExport(root)).not.toThrow();
expect(root.querySelector("pre > code")).toBe(code);
});
test("replaces valid Mermaid blocks with an SVG figure and keeps invalid or empty ones as source", async () => {
const root = createRoot([
'<pre><code class="language-mermaid">graph TD\n Start --> Finish</code></pre>',
'<pre><code class="language-mermaid">%%% not a diagram ((</code></pre>',
'<pre><code class="language-mermaid"> </code></pre>',
].join(""));
await mod.renderMermaidBlocksForHtmlExport(root);
const figures = root.querySelectorAll("figure.edgeever-html-mermaid");
expect(figures).toHaveLength(1);
expect(figures[0].querySelector("svg")).not.toBeNull();
expect(figures[0].innerHTML).toContain("Start");
const remaining = Array.from(root.querySelectorAll("pre > code.language-mermaid"), (code) => code.textContent);
expect(remaining).toEqual(["%%% not a diagram ((", " "]);
});
});
describe("prepareNoteBodyHtmlForExport and downloadNoteHtmlFile", () => {
test("prepares body HTML with highlighted code and embedded images", async () => {
installFetch({ "https://cdn.example/pic.png": { body: "P", type: "image/png" } });
const prepared = await mod.prepareNoteBodyHtmlForExport(
'<p>Hi</p><pre><code class="language-css">a { color: red; }</code></pre><img src="https://cdn.example/pic.png">',
);
expect(prepared.images).toEqual({ total: 1, embedded: 1, failed: 0 });
expect(prepared.bodyHtml).toContain("<p>Hi</p>");
expect(prepared.bodyHtml).toContain("hljs");
expect(prepared.bodyHtml).toContain(`src="data:image/png;base64,${Buffer.from("P").toString("base64")}"`);
expect(prepared.bodyHtml).not.toContain("https://cdn.example/pic.png");
});
test("downloads a standalone document and reports image results", async () => {
installFetch({});
const blobs = [];
URL.createObjectURL = (blob) => {
blobs.push(blob);
return "blob:https://notes.example/export-2";
};
URL.revokeObjectURL = () => {};
const originalCreateElement = document.createElement.bind(document);
document.createElement = (tag) => {
const element = originalCreateElement(tag);
if (tag === "a") element.click = () => {};
return element;
};
try {
const result = await mod.downloadNoteHtmlFile({
title: "<Trip>",
notebook: "Travel",
tags: ["summer"],
updatedAt: "2026-07-01",
language: "pl",
fallbackTitle: "Untitled",
bodyHtml: '<p>Body</p><img src="https://offline.example/a.png">',
styles: ".x{}",
});
expect(result).toEqual({
filename: "-Trip-.html",
images: { total: 1, embedded: 0, failed: 1 },
});
expect(blobs).toHaveLength(1);
const html = await blobs[0].text();
expect(html).toContain('<html lang="pl">');
expect(html).toContain("<title>&lt;Trip&gt;</title>");
expect(html).toContain("Travel · 2026-07-01 · #summer");
expect(html).toContain(".x{}");
expect(html).toContain('<img src="https://offline.example/a.png">');
} finally {
delete document.createElement;
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
}
});
test("uses empty styles and the fallback title when none are given", async () => {
installFetch({});
const blobs = [];
URL.createObjectURL = (blob) => {
blobs.push(blob);
return "blob:x";
};
URL.revokeObjectURL = () => {};
const originalCreateElement = document.createElement.bind(document);
document.createElement = (tag) => {
const element = originalCreateElement(tag);
if (tag === "a") element.click = () => {};
return element;
};
try {
const result = await mod.downloadNoteHtmlFile({ title: " ", fallbackTitle: "Untitled", bodyHtml: "<p>x</p>" });
expect(result).toEqual({ filename: "Untitled.html", images: { total: 0, embedded: 0, failed: 0 } });
expect(await blobs[0].text()).toContain("<style>\n\n</style>");
} finally {
delete document.createElement;
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
}
});
});
@@ -0,0 +1,410 @@
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
import { parseHTML } from "linkedom";
// createNoteImage drives html-to-image, the font loader and a canvas. The
// fakes below stand in for the browser pieces linkedom does not provide, so
// the export flow (layout, sizing, retries, cancellation, cleanup) runs
// without a real renderer or network access.
// linkedom's window forwards writes to globalThis; every key written through it is restored.
const GLOBAL_KEYS = [
"window",
"document",
"Element",
"HTMLElement",
"SVGElement",
"HTMLCanvasElement",
"HTMLImageElement",
"HTMLVideoElement",
"HTMLIFrameElement",
"HTMLTextAreaElement",
"HTMLInputElement",
"HTMLSelectElement",
"SVGImageElement",
"getComputedStyle",
"XMLSerializer",
"XMLHttpRequest",
"FileReader",
"Image",
"requestAnimationFrame",
"fetch",
"location",
"devicePixelRatio",
"setTimeout",
];
const originalDescriptors = new Map(
GLOBAL_KEYS.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]),
);
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
const linkedomWindow = parseHTML("<!DOCTYPE html><html><head></head><body></body></html>");
const { document: linkedomDocument } = linkedomWindow;
linkedomWindow.location = { href: "https://notes.example/app/", protocol: "https:" };
linkedomWindow.devicePixelRatio = 1;
linkedomWindow.setTimeout = (callback) => {
callback();
return 0;
};
const emptyComputedStyle = () => {
const style = [];
style.getPropertyValue = () => "";
style.getPropertyPriority = () => "";
return style;
};
linkedomWindow.getComputedStyle = emptyComputedStyle;
for (const key of [
"Element",
"HTMLElement",
"SVGElement",
"HTMLCanvasElement",
"HTMLImageElement",
"HTMLVideoElement",
"HTMLIFrameElement",
"HTMLTextAreaElement",
"HTMLInputElement",
"HTMLSelectElement",
]) {
globalThis[key] = linkedomWindow[key];
}
globalThis.SVGImageElement = linkedomWindow.SVGImageElement ?? class SVGImageElement {};
globalThis.window = linkedomWindow;
globalThis.document = linkedomDocument;
globalThis.getComputedStyle = emptyComputedStyle;
globalThis.requestAnimationFrame = (callback) => {
callback(0);
return 0;
};
globalThis.XMLSerializer = class {
serializeToString(node) {
return node.outerHTML;
}
};
// Every font and texture request fails fast, so fonts fall back to defaults.
globalThis.XMLHttpRequest = class {
open() {}
abort() {}
send() {
queueMicrotask(() => this.onerror?.());
}
};
globalThis.FileReader = class {
readAsDataURL(blob) {
blob.arrayBuffer().then((buffer) => {
this.result = `data:${blob.type};base64,${Buffer.from(buffer).toString("base64")}`;
this.onload?.();
});
}
};
globalThis.Image = class {
set src(value) {
this.currentSrc = value;
queueMicrotask(() => this.onload?.());
}
get src() {
return this.currentSrc;
}
decode() {
return Promise.resolve();
}
};
// Canvas fake: records the size and encoding of every rendered bitmap.
const canvasLog = [];
let probe = { nullContextHeights: new Set(), failingHeights: new Set() };
let toBlobResults = [];
let onToBlob = () => {};
const createFakeCanvas = () => {
const canvas = {
width: 300,
height: 150,
style: {},
getContext() {
if (probe.nullContextHeights.has(canvas.height)) return null;
const context = {
fillStyle: "",
fillRect() {
canvas.background = context.fillStyle;
},
drawImage() {},
getImageData: () => {
if (probe.failingHeights.has(canvas.height)) throw new Error("canvas too large");
return { data: [0, 0, 0, 255] };
},
};
return context;
},
toDataURL: () => "data:image/png;base64,AAAA",
toBlob(callback, type, quality) {
onToBlob();
const result = toBlobResults.length ? toBlobResults.shift() : "ok";
canvasLog.push({ width: canvas.width, height: canvas.height, background: canvas.background, type, quality, result });
callback(result === "empty" ? null : new Blob(["image-bytes"], { type }));
},
};
return canvas;
};
const originalCreateElement = linkedomDocument.createElement.bind(linkedomDocument);
linkedomDocument.createElement = (tag, options) => {
if (tag === "canvas") return createFakeCanvas();
return originalCreateElement(tag, options);
};
// linkedom never loads images. html-to-image waits for the inlined copy to
// load, so report every src assignment as loaded.
const imagePrototype = linkedomWindow.HTMLImageElement.prototype;
const imageSrcDescriptor = Object.getOwnPropertyDescriptor(imagePrototype, "src");
Object.defineProperty(imagePrototype, "src", {
...imageSrcDescriptor,
set(value) {
imageSrcDescriptor.set.call(this, value);
queueMicrotask(() => this.onload?.());
},
});
// linkedom has no layout engine; report a configurable rendered height.
let renderedHeight = 640.2;
linkedomWindow.HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
return { width: 800, height: renderedHeight, top: 0, left: 0, right: 800, bottom: renderedHeight };
};
afterAll(() => {
delete linkedomWindow.HTMLElement.prototype.getBoundingClientRect;
Object.defineProperty(imagePrototype, "src", imageSrcDescriptor);
for (const [key, descriptor] of originalDescriptors) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete globalThis[key];
}
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
});
// The module caches the canvas size probe and serializes renders, so each test
// gets a fresh instance and a default probe where only 65,535px fails.
let mod;
let moduleInstance = 0;
beforeEach(async () => {
mod = await import(`./note-image-export.ts?instance=${++moduleInstance}`);
probe = { nullContextHeights: new Set([65_535]), failingHeights: new Set() };
canvasLog.length = 0;
toBlobResults = [];
onToBlob = () => {};
renderedHeight = 640.2;
globalThis.fetch = async () => {
throw new TypeError("network disabled in tests");
};
});
const baseOptions = {
bodyHtml: "<p>Hello <strong>world</strong></p>",
title: "Weekly: plan",
notebook: "Work",
tags: ["focus"],
updatedAt: "2026-05-01",
fallbackTitle: "Untitled",
format: "png",
styles: ".probe{}",
};
const expectAbortError = async (promise) => {
let caught;
try {
await promise;
} catch (error) {
caught = error;
}
expect(caught?.name).toBe("AbortError");
expect(caught?.message).toBe("Image export cancelled");
};
describe("createNoteImage", () => {
test("renders a PNG at twice the card width when every canvas size probe fails", async () => {
probe = { nullContextHeights: new Set(), failingHeights: new Set([65_535, 32_767, 16_384, 8_192, 4_096]) };
const statuses = [];
const result = await mod.createNoteImage({
...baseOptions,
onFontLoadStatus: (status) => statuses.push(status),
});
expect(result.filename).toBe("Weekly- plan.png");
expect(result.mimeType).toBe("image/png");
expect(result.blob.type).toBe("image/png");
// 800px card at pixel ratio 2; the 640.2px layout height rounds up to 641.
expect({ width: result.width, height: result.height }).toEqual({ width: 1600, height: 1282 });
expect(result.images).toEqual({ total: 0, embedded: 0, failed: 0 });
// Fonts are unreachable here, so the export falls back instead of failing.
expect(statuses).toEqual(["loading", "error"]);
expect(result.fontFallback).toBe(true);
expect(canvasLog).toEqual([{
width: 1600,
height: 1282,
background: mod.NOTE_IMAGE_BACKGROUND_COLORS[mod.resolveTheme()],
type: "image/png",
quality: 1,
result: "ok",
}]);
// The off-screen render host is removed after rendering.
expect(document.body.children).toHaveLength(0);
});
test("renders a JPEG for a legacy theme at the selected card width and reports embedded images", async () => {
probe = { nullContextHeights: new Set([65_535]), failingHeights: new Set() };
const result = await mod.createNoteImage({
...baseOptions,
title: "",
fallbackTitle: "Untitled",
format: "jpeg",
theme: "editorial",
cardWidth: "standard",
bodyHtml: '<p>Pic</p><img src="data:image/png;base64,AAAA"><img src="https://offline.example/x.png">',
});
expect(result.filename).toBe("Untitled.jpg");
expect(result.mimeType).toBe("image/jpeg");
expect(result.images).toEqual({ total: 2, embedded: 1, failed: 1 });
expect(result.width).toBe(mod.NOTE_IMAGE_CARD_WIDTH_PIXELS.standard * 2);
expect(canvasLog).toHaveLength(1);
expect(canvasLog[0]).toMatchObject({
type: "image/jpeg",
quality: 0.92,
background: mod.NOTE_IMAGE_BACKGROUND_COLORS.frame,
});
});
test("falls back to the wide card for an unknown width", async () => {
const result = await mod.createNoteImage({ ...baseOptions, cardWidth: "gigantic" });
expect(result.width).toBe(mod.NOTE_IMAGE_CARD_WIDTH_PIXELS.wide * 2);
});
test("scales tall notes to the canvas limit and retries within the smaller area limit", async () => {
renderedHeight = 20_000;
toBlobResults = ["empty", "ok"];
const result = await mod.createNoteImage(baseOptions);
// First attempt uses the 32,767px probe result, the retry caps at 16,384px and 16 MP.
expect(canvasLog.map(({ result: outcome }) => outcome)).toEqual(["empty", "ok"]);
expect(canvasLog[0].height).toBeCloseTo(32_767, 6);
expect(canvasLog[1].height).toBeCloseTo(16_384, 6);
expect(canvasLog[1].width).toBeCloseTo(800 * (16_384 / 20_000), 6);
expect(result.height).toBeCloseTo(16_384, 6);
});
test("rejects when the retry also produces an empty file and still removes the render host", async () => {
toBlobResults = ["empty", "empty"];
let caught;
try {
await mod.createNoteImage(baseOptions);
} catch (error) {
caught = error;
}
expect(caught?.message).toBe("Image renderer returned an empty file");
expect(canvasLog).toHaveLength(2);
expect(document.body.children).toHaveLength(0);
});
test("rejects immediately when the signal is already aborted", async () => {
const controller = new AbortController();
controller.abort();
await expectAbortError(mod.createNoteImage({ ...baseOptions, signal: controller.signal }));
expect(canvasLog).toHaveLength(0);
});
test("stops before rendering when cancelled while fonts load", async () => {
const controller = new AbortController();
await expectAbortError(mod.createNoteImage({
...baseOptions,
signal: controller.signal,
onFontLoadStatus: (status) => {
if (status === "loading") controller.abort();
},
}));
expect(canvasLog).toHaveLength(0);
expect(document.body.children).toHaveLength(0);
});
test("does not retry when cancelled during the first capture", async () => {
const controller = new AbortController();
toBlobResults = ["empty", "ok"];
onToBlob = () => controller.abort();
await expectAbortError(mod.createNoteImage({ ...baseOptions, signal: controller.signal }));
expect(canvasLog).toHaveLength(1);
expect(document.body.children).toHaveLength(0);
});
test("serializes overlapping exports", async () => {
const [first, second] = await Promise.all([
mod.createNoteImage({ ...baseOptions, title: "First" }),
mod.createNoteImage({ ...baseOptions, title: "Second", format: "jpeg" }),
]);
expect([first.filename, second.filename]).toEqual(["First.png", "Second.jpg"]);
expect(canvasLog.map(({ type }) => type)).toEqual(["image/png", "image/jpeg"]);
});
});
describe("downloading note images", () => {
const installDownloadFakes = () => {
const events = [];
URL.createObjectURL = (blob) => {
events.push({ created: blob.type });
return "blob:https://notes.example/image-1";
};
URL.revokeObjectURL = (url) => events.push({ revoked: url });
const createElement = document.createElement;
document.createElement = (tag, options) => {
const element = createElement(tag, options);
if (tag === "a") {
element.click = () => events.push({
clicked: element.href,
// linkedom percent-encodes the download attribute; browsers keep it verbatim.
download: decodeURIComponent(element.download),
hidden: element.hidden,
attached: element.parentNode === document.body,
});
}
return element;
};
return {
events,
restore: () => {
document.createElement = createElement;
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
},
};
};
test("downloadPreparedNoteImage clicks a hidden temporary link and revokes the URL", () => {
const { events, restore } = installDownloadFakes();
try {
mod.downloadPreparedNoteImage({ blob: new Blob(["x"], { type: "image/png" }), filename: "My note.png" });
expect(events).toEqual([
{ created: "image/png" },
{
clicked: "blob:https://notes.example/image-1",
download: "My note.png",
hidden: true,
attached: true,
},
{ revoked: "blob:https://notes.example/image-1" },
]);
expect(document.body.querySelector("a")).toBeNull();
} finally {
restore();
}
});
test("downloadNoteImage renders, downloads and returns the prepared image", async () => {
const { events, restore } = installDownloadFakes();
try {
const prepared = await mod.downloadNoteImage({ ...baseOptions, title: "Trip", format: "jpeg" });
expect(prepared.filename).toBe("Trip.jpg");
expect(events.map((event) => Object.keys(event)[0])).toEqual(["created", "clicked", "revoked"]);
expect(events[1].download).toBe("Trip.jpg");
} finally {
restore();
}
});
});
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { resolveNoteProse } from "@edgeever/shared";
import { DEFAULT_NOTE_PROSE_STYLE_PREFERENCES } from "./note-prose-style-preference.ts";
import { notePrintAppearanceCss, notePrintAppearanceFromProse } from "./note-print-appearance.ts";
describe("note print appearance", () => {
@@ -30,6 +31,18 @@ describe("note print appearance", () => {
expect(css).not.toContain("color: #");
});
test("print uses the selected body rhythm with any heading style", () => {
for (const headingStyle of ["default", "bar", "numbered"]) {
const appearance = notePrintAppearanceFromProse(resolveNoteProse({}), "github-light",
{ choice: "system", customFamily: "" },
{ ...DEFAULT_NOTE_PROSE_STYLE_PREFERENCES, headingStyle, paragraphSpacing: 16, textAlign: "justify", letterSpacing: "1px" });
const css = notePrintAppearanceCss(appearance);
expect(css).toContain("--editor-paragraph-spacing: 16px");
expect(css).toContain("--editor-body-text-align: justify");
expect(css).toContain("--editor-body-letter-spacing: 1px");
}
});
test("tints links, bold, checked boxes, and tables with the account palette", () => {
const css = notePrintAppearanceCss(notePrintAppearanceFromProse(resolveNoteProse({
palette: "teal",
+7 -3
View File
@@ -1,6 +1,7 @@
import {
NOTE_PROSE_PALETTES,
noteProseCssVariables,
noteProseStyleCssVariables,
type NoteProsePaletteChoice,
type NoteProseStylePreferences,
type ResolvedNoteProse,
@@ -48,7 +49,7 @@ export const notePrintAppearanceFromProse = (
): NotePrintAppearance => ({
palette: prose.palette,
proseStyle,
cssVariables: noteProseCssVariables(prose),
cssVariables: { ...noteProseCssVariables(prose), ...noteProseStyleCssVariables(proseStyle) },
customCss: sanitizeAndScopeCss(prose.customCss),
fontFamily: resolveEditorBodyFontStack(font) ?? null,
fontId: loadableBodyFontId(font.choice),
@@ -109,9 +110,12 @@ const proseStyleRules = (
} else if (proseStyle.headingStyle === "numbered") {
rules.push(
".edgeever-print-content { counter-reset: note-prose-h2; }",
`.edgeever-print-content h2 { counter-increment: note-prose-h2; padding-bottom: 6px; border-bottom: 2px solid ${divider}; }`,
".edgeever-print-content h1 { font-size: calc(var(--editor-body-font-size, 16px) * 1.75); }",
".edgeever-print-content h4 { font-size: var(--editor-body-font-size, 16px); }",
`.edgeever-print-content h2 { font-size: calc(var(--editor-body-font-size, 16px) * 1.4); counter-increment: note-prose-h2; padding-bottom: 6px; border-bottom: 2px solid ${divider}; }`,
`.edgeever-print-content h2::before { content: counter(note-prose-h2, decimal-leading-zero) " · "; color: ${accent}; font-weight: 800; letter-spacing: 0.04em; }`,
`.edgeever-print-content h3 { border-left: 3px solid ${accent}; padding-left: 10px; }`,
`.edgeever-print-content h3 { position: relative; display: block; width: fit-content; max-width: 100%; box-sizing: border-box; font-size: var(--editor-body-font-size, 16px); font-weight: 700; line-height: 1.5; margin: 1.5em 0 0.6em; padding: 0.25em 0.75em 0.25em 1.5em; border: 0; border-radius: 1em; background: color-mix(in srgb, ${accent} 9%, transparent); color: ${accent}; overflow-wrap: anywhere; }`,
`.edgeever-print-content h3::before { content: ""; position: absolute; left: 0.75em; top: 0.8125em; width: 0.375em; height: 0.375em; border-radius: 50%; background: ${accent}; }`,
);
}
if (proseStyle.quoteStyle === "dashed") {
@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test";
import { noteProseStyleCssVariables } from "@edgeever/shared";
import { resolveStoredNoteProseStylePreference } from "./note-prose-style-preference.ts";
describe("independent body rhythm preferences", () => {
test("all heading styles use the same body defaults without migration", () => {
for (const headingStyle of ["default", "bar", "numbered"]) {
const preference = resolveStoredNoteProseStylePreference(JSON.stringify({ headingStyle }));
expect(preference.paragraphSpacing).toBe(8);
expect(preference.textAlign).toBe("left");
expect(preference.letterSpacing).toBe("default");
expect(noteProseStyleCssVariables(preference)).toEqual({
"--editor-paragraph-spacing": "8px",
"--editor-body-text-align": "left",
"--editor-body-letter-spacing": "-0.005em",
});
}
});
test("round trips supported choices independently from headings", () => {
for (const paragraphSpacing of [8, 12, 16]) {
for (const textAlign of ["left", "justify"]) {
for (const letterSpacing of ["default", "0.5px", "1px"]) {
const values = { paragraphSpacing, textAlign, letterSpacing, headingStyle: "numbered" };
const preference = resolveStoredNoteProseStylePreference(JSON.stringify(values));
expect(preference).toMatchObject(values);
expect(noteProseStyleCssVariables(preference)["--editor-paragraph-spacing"]).toBe(`${paragraphSpacing}px`);
}
}
}
});
test("invalid values fall back to body defaults", () => {
const preference = resolveStoredNoteProseStylePreference(JSON.stringify({
paragraphSpacing: -1, textAlign: "center", letterSpacing: "99px",
}));
expect(preference.paragraphSpacing).toBe(8);
expect(preference.textAlign).toBe("left");
expect(preference.letterSpacing).toBe("default");
});
});
@@ -3,12 +3,12 @@ import {
DEFAULT_NOTE_PROSE_EMPHASIS_STYLE,
DEFAULT_NOTE_PROSE_HEADING_STYLE,
DEFAULT_NOTE_PROSE_QUOTE_STYLE,
parseNoteProseParagraphSpacing,
parseNoteProseTextAlign,
parseNoteProseLetterSpacing,
parseNoteProseEmphasisStyle,
parseNoteProseHeadingStyle,
parseNoteProseQuoteStyle,
type NoteProseEmphasisStyle,
type NoteProseHeadingStyle,
type NoteProseQuoteStyle,
type NoteProseStylePreferences,
} from "@edgeever/shared";
@@ -18,6 +18,9 @@ export const NOTE_PROSE_STYLE_CHANGED_EVENT = "edgeever:note-prose-style-changed
let unsavedNoteProseStylePreference: NoteProseStylePreferences | null = null;
export const DEFAULT_NOTE_PROSE_STYLE_PREFERENCES: NoteProseStylePreferences = {
paragraphSpacing: 8,
textAlign: "left",
letterSpacing: "default",
headingStyle: DEFAULT_NOTE_PROSE_HEADING_STYLE,
quoteStyle: DEFAULT_NOTE_PROSE_QUOTE_STYLE,
emphasisStyle: DEFAULT_NOTE_PROSE_EMPHASIS_STYLE,
@@ -30,6 +33,9 @@ export const resolveStoredNoteProseStylePreference = (
if (!stored) return DEFAULT_NOTE_PROSE_STYLE_PREFERENCES;
try {
const parsed = JSON.parse(stored) as {
paragraphSpacing?: unknown;
textAlign?: unknown;
letterSpacing?: unknown;
headingStyle?: unknown;
quoteStyle?: unknown;
emphasisStyle?: unknown;
@@ -39,6 +45,9 @@ export const resolveStoredNoteProseStylePreference = (
return DEFAULT_NOTE_PROSE_STYLE_PREFERENCES;
}
return {
paragraphSpacing: parseNoteProseParagraphSpacing(parsed.paragraphSpacing) ?? 8,
textAlign: parseNoteProseTextAlign(parsed.textAlign) ?? "left",
letterSpacing: parseNoteProseLetterSpacing(parsed.letterSpacing) ?? "default",
headingStyle: parseNoteProseHeadingStyle(parsed.headingStyle) ?? DEFAULT_NOTE_PROSE_HEADING_STYLE,
quoteStyle: parseNoteProseQuoteStyle(parsed.quoteStyle) ?? DEFAULT_NOTE_PROSE_QUOTE_STYLE,
emphasisStyle: parseNoteProseEmphasisStyle(parsed.emphasisStyle) ?? DEFAULT_NOTE_PROSE_EMPHASIS_STYLE,
@@ -62,15 +71,13 @@ export const readNoteProseStylePreference = (): NoteProseStylePreferences => {
};
export const writeNoteProseStylePreference = (
patch: Partial<{
headingStyle: NoteProseHeadingStyle;
quoteStyle: NoteProseQuoteStyle;
emphasisStyle: NoteProseEmphasisStyle;
showMagazineCover: boolean;
}>,
patch: Partial<NoteProseStylePreferences>,
): NoteProseStylePreferences => {
const current = readNoteProseStylePreference();
const next: NoteProseStylePreferences = {
paragraphSpacing: parseNoteProseParagraphSpacing(patch.paragraphSpacing) ?? current.paragraphSpacing,
textAlign: parseNoteProseTextAlign(patch.textAlign) ?? current.textAlign,
letterSpacing: parseNoteProseLetterSpacing(patch.letterSpacing) ?? current.letterSpacing,
headingStyle: parseNoteProseHeadingStyle(patch.headingStyle) ?? current.headingStyle,
quoteStyle: parseNoteProseQuoteStyle(patch.quoteStyle) ?? current.quoteStyle,
emphasisStyle: parseNoteProseEmphasisStyle(patch.emphasisStyle) ?? current.emphasisStyle,
@@ -101,6 +108,9 @@ export const useNoteProseStylePreference = (): NoteProseStylePreferences => {
const detail = (event as CustomEvent<NoteProseStylePreferences | undefined>).detail;
if (detail && typeof detail === "object") {
setPreference({
paragraphSpacing: parseNoteProseParagraphSpacing(detail.paragraphSpacing) ?? 8,
textAlign: parseNoteProseTextAlign(detail.textAlign) ?? "left",
letterSpacing: parseNoteProseLetterSpacing(detail.letterSpacing) ?? "default",
headingStyle: parseNoteProseHeadingStyle(detail.headingStyle) ?? DEFAULT_NOTE_PROSE_HEADING_STYLE,
quoteStyle: parseNoteProseQuoteStyle(detail.quoteStyle) ?? DEFAULT_NOTE_PROSE_QUOTE_STYLE,
emphasisStyle: parseNoteProseEmphasisStyle(detail.emphasisStyle) ?? DEFAULT_NOTE_PROSE_EMPHASIS_STYLE,
@@ -0,0 +1,215 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { IDBFactory, IDBKeyRange } from "fake-indexeddb";
import { WebPluginPackageStore } from "./plugin-package-store.ts";
const DATABASE_NAME = "edgeever-plugin-packages-v1";
const requestDone = (request) => new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
const transactionComplete = (transaction) => new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error);
transaction.onerror = () => reject(transaction.error);
});
const pluginPackage = (pluginId, version, mainJs = `/* ${pluginId}@${version} */`) => ({
pluginId,
version,
mainJs,
stylesCss: null,
checksums: { mainJs: `sha-${pluginId}-${version}`, manifestJson: "sha-manifest" },
cachedAt: "2026-10-01T00:00:00.000Z",
});
const seedLegacyDatabase = async (records) => {
const request = indexedDB.open(DATABASE_NAME, 1);
request.onupgradeneeded = () => request.result.createObjectStore("packages", { keyPath: "pluginId" });
const database = await requestDone(request);
const transaction = database.transaction("packages", "readwrite");
for (const record of records) transaction.objectStore("packages").put(record);
await transactionComplete(transaction);
database.close();
};
const readRaw = async (storeName) => {
const database = await requestDone(indexedDB.open(DATABASE_NAME));
try {
if (!database.objectStoreNames.contains(storeName)) return null;
const transaction = database.transaction(storeName, "readonly");
const values = await requestDone(transaction.objectStore(storeName).getAll());
await transactionComplete(transaction);
return values;
} finally {
database.close();
}
};
describe("WebPluginPackageStore", () => {
const originals = {
indexedDB: Object.getOwnPropertyDescriptor(globalThis, "indexedDB"),
IDBKeyRange: Object.getOwnPropertyDescriptor(globalThis, "IDBKeyRange"),
};
const openStores = [];
const createStore = () => {
const store = new WebPluginPackageStore();
openStores.push(store);
return store;
};
beforeEach(() => {
Object.defineProperty(globalThis, "indexedDB", { value: new IDBFactory(), configurable: true, writable: true });
Object.defineProperty(globalThis, "IDBKeyRange", { value: IDBKeyRange, configurable: true, writable: true });
});
afterEach(async () => {
// Close connections so a later raw open in the same test is never blocked.
for (const store of openStores.splice(0)) {
await store["databasePromise"]?.then((database) => database.close(), () => undefined);
}
for (const [name, descriptor] of Object.entries(originals)) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else delete globalThis[name];
}
});
test("returns null for a package that is not cached", async () => {
expect(await createStore().get("com.example.alpha", "1.0.0")).toBeNull();
});
test("stores packages per plugin and version", async () => {
const store = createStore();
await store.put(pluginPackage("com.example.alpha", "1.0.0"));
await store.put(pluginPackage("com.example.alpha", "1.1.0"));
await store.put({ ...pluginPackage("com.example.beta", "1.0.0"), stylesCss: ".x{}", checksums: { mainJs: "a", manifestJson: "b", stylesCss: "c" } });
expect(await store.get("com.example.alpha", "1.0.0")).toEqual(pluginPackage("com.example.alpha", "1.0.0"));
expect((await store.get("com.example.alpha", "1.1.0")).mainJs).toBe("/* com.example.alpha@1.1.0 */");
expect((await store.get("com.example.beta", "1.0.0")).stylesCss).toBe(".x{}");
expect(await store.get("com.example.beta", "1.1.0")).toBeNull();
});
test("overwrites the same plugin version instead of duplicating it", async () => {
const store = createStore();
await store.put(pluginPackage("com.example.alpha", "1.0.0", "old()"));
await store.put(pluginPackage("com.example.alpha", "1.0.0", "new()"));
expect((await store.get("com.example.alpha", "1.0.0")).mainJs).toBe("new()");
expect(await readRaw("packageVersions")).toHaveLength(1);
});
test("persists packages across store instances", async () => {
const first = createStore();
await first.put(pluginPackage("com.example.alpha", "1.0.0"));
expect(await createStore().get("com.example.alpha", "1.0.0")).toEqual(pluginPackage("com.example.alpha", "1.0.0"));
});
test("removes a single version", async () => {
const store = createStore();
await store.put(pluginPackage("com.example.alpha", "1.0.0"));
await store.put(pluginPackage("com.example.alpha", "1.1.0"));
await store.remove("com.example.alpha", "1.0.0");
expect(await store.get("com.example.alpha", "1.0.0")).toBeNull();
expect(await store.get("com.example.alpha", "1.1.0")).not.toBeNull();
});
test("removes every version of one plugin only", async () => {
const store = createStore();
await store.put(pluginPackage("com.example.alpha", "1.0.0"));
await store.put(pluginPackage("com.example.alpha", "1.1.0"));
await store.put(pluginPackage("com.example.alphabet", "1.0.0"));
await store.put(pluginPackage("com.example.beta", "2.0.0"));
await store.remove("com.example.alpha");
await store.remove("com.example.unknown");
expect((await readRaw("packageVersions")).map((value) => `${value.pluginId}@${value.version}`).sort()).toEqual([
"com.example.alphabet@1.0.0",
"com.example.beta@2.0.0",
]);
});
describe("with a legacy single-version store", () => {
test("upgrades the database and keeps the legacy records", async () => {
await seedLegacyDatabase([pluginPackage("com.example.alpha", "1.0.0")]);
const store = createStore();
expect(await store.get("com.example.beta", "1.0.0")).toBeNull();
expect(await readRaw("packages")).toHaveLength(1);
expect(await readRaw("packageVersions")).toEqual([]);
});
test("serves a matching legacy package and copies it into the versioned store", async () => {
const legacy = pluginPackage("com.example.alpha", "1.0.0");
await seedLegacyDatabase([legacy]);
const store = createStore();
expect(await store.get("com.example.alpha", "1.0.0")).toEqual(legacy);
expect(await readRaw("packageVersions")).toEqual([legacy]);
expect(await store.get("com.example.alpha", "1.0.0")).toEqual(legacy);
expect(await readRaw("packageVersions")).toHaveLength(1);
});
test("ignores a legacy package with a different version", async () => {
await seedLegacyDatabase([pluginPackage("com.example.alpha", "1.0.0")]);
const store = createStore();
expect(await store.get("com.example.alpha", "2.0.0")).toBeNull();
expect(await readRaw("packageVersions")).toEqual([]);
});
test("prefers the versioned store over the legacy record", async () => {
await seedLegacyDatabase([pluginPackage("com.example.alpha", "1.0.0", "legacy()")]);
const store = createStore();
await store.put(pluginPackage("com.example.alpha", "1.0.0", "current()"));
expect((await store.get("com.example.alpha", "1.0.0")).mainJs).toBe("current()");
});
test("removing all versions also drops the legacy record", async () => {
await seedLegacyDatabase([pluginPackage("com.example.alpha", "1.0.0"), pluginPackage("com.example.beta", "1.0.0")]);
const store = createStore();
await store.put(pluginPackage("com.example.alpha", "2.0.0"));
await store.remove("com.example.alpha");
expect(await store.get("com.example.alpha", "1.0.0")).toBeNull();
expect(await store.get("com.example.alpha", "2.0.0")).toBeNull();
expect((await readRaw("packages")).map((value) => value.pluginId)).toEqual(["com.example.beta"]);
});
test("removing a matching version drops the legacy record", async () => {
await seedLegacyDatabase([pluginPackage("com.example.alpha", "1.0.0")]);
const store = createStore();
await store.remove("com.example.alpha", "1.0.0");
expect(await readRaw("packages")).toEqual([]);
expect(await store.get("com.example.alpha", "1.0.0")).toBeNull();
});
test("removing a different version keeps the legacy record", async () => {
await seedLegacyDatabase([pluginPackage("com.example.alpha", "1.0.0")]);
const store = createStore();
await store.remove("com.example.alpha", "0.9.0");
await store.remove("com.example.unknown", "1.0.0");
expect(await readRaw("packages")).toHaveLength(1);
expect(await store.get("com.example.alpha", "1.0.0")).not.toBeNull();
});
});
test("surfaces an unavailable database", async () => {
const database = await requestDone(indexedDB.open(DATABASE_NAME, 3));
database.close();
const store = new WebPluginPackageStore();
await expect(store.get("com.example.alpha", "1.0.0")).rejects.toThrow();
await expect(store.put(pluginPackage("com.example.alpha", "1.0.0"))).rejects.toThrow();
});
});
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { decryptPluginSecret, encryptPluginSecret } from "./plugin-secret-store.ts";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { IDBFactory } from "fake-indexeddb";
import { WebPluginSecretStore, decryptPluginSecret, encryptPluginSecret } from "./plugin-secret-store.ts";
describe("plugin secret encryption", () => {
test("round-trips a secret with an authenticated AES-GCM payload", async () => {
@@ -18,4 +19,238 @@ describe("plugin secret encryption", () => {
await expect(decryptPluginSecret(key, { ...encrypted, ciphertext: modified })).rejects.toThrow();
});
test("uses a fresh IV for every encryption of the same value", async () => {
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
const first = await encryptPluginSecret(key, "same");
const second = await encryptPluginSecret(key, "same");
expect(first.iv).toHaveLength(12);
expect(Array.from(first.iv)).not.toEqual(Array.from(second.iv));
expect(Array.from(new Uint8Array(first.ciphertext))).not.toEqual(Array.from(new Uint8Array(second.ciphertext)));
});
test("rejects decryption with a different key", async () => {
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
const otherKey = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
const encrypted = await encryptPluginSecret(key, "edgeever-secret");
await expect(decryptPluginSecret(otherKey, encrypted)).rejects.toThrow();
});
});
const DATABASE_NAME = "edgeever-plugin-secrets-v1";
const requestDone = (request) => new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
const transactionComplete = (transaction) => new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error);
transaction.onerror = () => reject(transaction.error);
});
const withRawDatabase = async (callback) => {
const database = await requestDone(indexedDB.open(DATABASE_NAME));
try {
return await callback(database);
} finally {
database.close();
}
};
const rawRecords = () => withRawDatabase(async (database) => {
const transaction = database.transaction("secrets", "readonly");
const records = await requestDone(transaction.objectStore("secrets").getAll());
await transactionComplete(transaction);
return records;
});
const rawKeyStore = () => withRawDatabase(async (database) => {
const transaction = database.transaction("keys", "readonly");
const store = transaction.objectStore("keys");
const [keys, values] = await Promise.all([requestDone(store.getAllKeys()), requestDone(store.getAll())]);
await transactionComplete(transaction);
return { keys, values };
});
const rawWrite = (storeName, mutate) => withRawDatabase(async (database) => {
const transaction = database.transaction(storeName, "readwrite");
mutate(transaction.objectStore(storeName));
await transactionComplete(transaction);
});
const bytesContain = (buffer, text) => {
const haystack = Array.from(new Uint8Array(buffer)).join(",");
const needle = Array.from(new TextEncoder().encode(text)).join(",");
return haystack.includes(needle);
};
describe("WebPluginSecretStore", () => {
const originalIndexedDB = Object.getOwnPropertyDescriptor(globalThis, "indexedDB");
beforeEach(() => {
Object.defineProperty(globalThis, "indexedDB", { value: new IDBFactory(), configurable: true, writable: true });
});
afterEach(() => {
if (originalIndexedDB) Object.defineProperty(globalThis, "indexedDB", originalIndexedDB);
else delete globalThis.indexedDB;
});
test("returns null for a secret that was never stored", async () => {
const store = new WebPluginSecretStore();
expect(await store.get("com.example.alpha", "token")).toBeNull();
});
test("stores, reads and overwrites a secret", async () => {
const store = new WebPluginSecretStore();
await store.set("com.example.alpha", "token", "first");
expect(await store.get("com.example.alpha", "token")).toBe("first");
await store.set("com.example.alpha", "token", "second");
expect(await store.get("com.example.alpha", "token")).toBe("second");
expect(await rawRecords()).toHaveLength(1);
});
test("keeps secrets encrypted at rest under a non-extractable device key", async () => {
const store = new WebPluginSecretStore();
await store.set("com.example.alpha", "token", "sk-plaintext-value");
const [record] = await rawRecords();
expect(record.id).toBe("com.example.alpha:token");
expect(Object.keys(record).sort()).toEqual(["ciphertext", "id", "iv"]);
expect(record.iv).toHaveLength(12);
expect(bytesContain(record.ciphertext, "sk-plaintext-value")).toBe(false);
expect(JSON.stringify(record)).not.toContain("sk-plaintext-value");
const { keys, values } = await rawKeyStore();
expect(keys).toEqual(["device-master-key"]);
expect(values[0]).toBeInstanceOf(CryptoKey);
expect(values[0].extractable).toBe(false);
expect(values[0].algorithm.name).toBe("AES-GCM");
});
test("reuses the persisted device key across store instances", async () => {
await new WebPluginSecretStore().set("com.example.alpha", "token", "persisted");
const reopened = new WebPluginSecretStore();
expect(await reopened.get("com.example.alpha", "token")).toBe("persisted");
await reopened.set("com.example.alpha", "other", "value");
expect((await rawKeyStore()).keys).toEqual(["device-master-key"]);
});
test("creates a single device key for concurrent first writes", async () => {
const store = new WebPluginSecretStore();
await Promise.all([
store.set("com.example.alpha", "a", "1"),
store.set("com.example.alpha", "b", "2"),
store.set("com.example.beta", "a", "3"),
]);
const reopened = new WebPluginSecretStore();
expect(await reopened.get("com.example.alpha", "a")).toBe("1");
expect(await reopened.get("com.example.alpha", "b")).toBe("2");
expect(await reopened.get("com.example.beta", "a")).toBe("3");
});
test("scopes secrets per plugin and key", async () => {
const store = new WebPluginSecretStore();
await store.set("com.example.alpha", "token", "alpha-token");
await store.set("com.example.beta", "token", "beta-token");
await store.set("com.example.alpha", "account.work", "alpha-work");
expect(await store.get("com.example.alpha", "token")).toBe("alpha-token");
expect(await store.get("com.example.beta", "token")).toBe("beta-token");
expect(await store.get("com.example.alpha", "account.work")).toBe("alpha-work");
expect(await store.get("com.example.beta", "account.work")).toBeNull();
});
test("removes a single secret without touching others", async () => {
const store = new WebPluginSecretStore();
await store.set("com.example.alpha", "token", "alpha-token");
await store.set("com.example.alpha", "refresh", "alpha-refresh");
await store.set("com.example.beta", "token", "beta-token");
await store.remove("com.example.alpha", "token");
await store.remove("com.example.alpha", "missing");
expect(await store.get("com.example.alpha", "token")).toBeNull();
expect(await store.get("com.example.alpha", "refresh")).toBe("alpha-refresh");
expect(await store.get("com.example.beta", "token")).toBe("beta-token");
});
test("clears one plugin namespace without touching plugins that share a prefix", async () => {
const store = new WebPluginSecretStore();
await store.set("com.example.alpha", "token", "1");
await store.set("com.example.alpha", "refresh", "2");
await store.set("com.example.alphabet", "token", "3");
await store.set("com.example.beta", "token", "4");
await store.clearNamespace("com.example.alpha");
expect(await store.get("com.example.alpha", "token")).toBeNull();
expect(await store.get("com.example.alpha", "refresh")).toBeNull();
expect(await store.get("com.example.alphabet", "token")).toBe("3");
expect(await store.get("com.example.beta", "token")).toBe("4");
expect((await rawRecords()).map((record) => record.id).sort()).toEqual([
"com.example.alphabet:token",
"com.example.beta:token",
]);
});
test("clearing an empty or unknown namespace is a no-op", async () => {
const store = new WebPluginSecretStore();
await store.clearNamespace("com.example.alpha");
await store.set("com.example.beta", "token", "kept");
await store.clearNamespace("com.example.alpha");
expect(await store.get("com.example.beta", "token")).toBe("kept");
});
test("rejects a tampered ciphertext instead of returning corrupt data", async () => {
const store = new WebPluginSecretStore();
await store.set("com.example.alpha", "token", "original");
const [record] = await rawRecords();
const tampered = record.ciphertext.slice(0);
new Uint8Array(tampered)[0] ^= 0xff;
await rawWrite("secrets", (secrets) => secrets.put({ ...record, ciphertext: tampered }));
await expect(new WebPluginSecretStore().get("com.example.alpha", "token")).rejects.toThrow();
});
test("rejects a secret whose IV no longer matches", async () => {
const store = new WebPluginSecretStore();
await store.set("com.example.alpha", "token", "original");
const [record] = await rawRecords();
await rawWrite("secrets", (secrets) => secrets.put({ ...record, iv: new Uint8Array(12) }));
await expect(store.get("com.example.alpha", "token")).rejects.toThrow();
});
test("cannot read old secrets after the device key is lost, but can store new ones", async () => {
await new WebPluginSecretStore().set("com.example.alpha", "token", "original");
await rawWrite("keys", (keys) => keys.delete("device-master-key"));
const store = new WebPluginSecretStore();
await expect(store.get("com.example.alpha", "token")).rejects.toThrow();
await store.set("com.example.alpha", "token", "replacement");
expect(await store.get("com.example.alpha", "token")).toBe("replacement");
});
test("surfaces a database that exists without its object stores", async () => {
const database = await requestDone(indexedDB.open(DATABASE_NAME, 1));
database.close();
// The current version already exists, so no upgrade runs to create the stores.
await expect(new WebPluginSecretStore().get("com.example.alpha", "token")).rejects.toThrow();
});
test("surfaces an unavailable database", async () => {
const database = await requestDone(indexedDB.open(DATABASE_NAME, 2));
database.close();
const store = new WebPluginSecretStore();
await expect(store.get("com.example.alpha", "token")).rejects.toThrow();
await expect(store.set("com.example.alpha", "token", "value")).rejects.toThrow();
});
});
+1 -1
View File
@@ -167,7 +167,7 @@ describe("copy pipeline wiring", () => {
expect(source).not.toContain("applyPublishLayout");
expect(source).toContain("noteProseCodeFontSize");
expect(source).toContain("NOTE_PROSE_HEADING_SCALE");
expect(source).toContain("NOTE_PROSE_PARAGRAPH_SPACING_PX");
expect(source).toContain('rhythm["--editor-paragraph-spacing"]');
expect(source).toContain("[data-note-palette]");
expect(source).toContain("preparePublishArticle");
expect(source).not.toContain("editorThemeOverride");
+830
View File
@@ -573,3 +573,833 @@ describe("note duplication in the Web repository", () => {
}
});
});
const SCOPE = "https://demo.edgeever.org|user-1";
const stubApi = (overrides) => {
const original = {};
for (const [key, value] of Object.entries(overrides)) {
original[key] = api[key];
api[key] = value;
}
return () => Object.assign(api, original);
};
// Offline requires navigator.onLine=false on a non-loopback host.
const installNetwork = ({ online }) => {
const previousOnline = globalThis.navigator?.onLine;
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: online });
const restoreWindow = installTestWindow({ hostname: online ? "localhost" : "demo.edgeever.org" });
return () => {
restoreWindow();
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: previousOnline });
};
};
const installFakeCaches = () => {
const previous = globalThis.caches;
const store = new Map();
const cache = {
put: async (url, response) => { store.set(String(url), await response.blob()); },
match: async (url) => (store.has(String(url)) ? new Response(store.get(String(url))) : undefined),
delete: async (url) => store.delete(String(url)),
};
globalThis.caches = { open: async () => cache };
return {
store,
restore: () => {
if (previous === undefined) delete globalThis.caches;
else globalThis.caches = previous;
},
};
};
const notebookFixture = (id, overrides = {}) => ({
id,
parentId: null,
name: id,
slug: null,
icon: null,
color: null,
sortOrder: 0,
memoCount: 0,
lastMemoUpdatedAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
});
const templateFixture = (id, overrides = {}) => ({
id,
name: id,
description: null,
title: `${id} title`,
contentJson: { type: "doc", content: [] },
contentMarkdown: `${id} body`,
tags: ["from-template"],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
});
const resourceFixture = (id, overrides = {}) => ({
id,
memoId: "memo-1",
originalMemoId: null,
kind: "attachment",
mimeType: "text/plain",
filename: `${id}.txt`,
byteSize: 4,
sha256: null,
width: null,
height: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
url: `/api/v1/resources/${id}/blob`,
memoTitle: null,
memoExcerpt: null,
memoDeleted: false,
...overrides,
});
const remoteMemoFixture = (id, overrides = {}) => ({
id,
notebookId: "nb-1",
title: "Remote",
excerpt: "remote",
tags: [],
isPinned: false,
isArchived: false,
isDeleted: false,
revision: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
deletedAt: null,
contentJson: { type: "doc", content: [] },
contentMarkdown: "remote",
contentText: "remote",
contentHash: "remote",
sourceMemoIds: [],
mergeSourceCount: 0,
mergedIntoMemoId: null,
...overrides,
});
const queuedAction = (kind, entityId) => localDb.syncQueue.get(`action:${SCOPE}:${kind}:${entityId}`);
const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
describe("web repository notebooks", () => {
test("loads notebooks from the server on a cold mirror without resurrecting a pending delete", async () => {
const restoreNetwork = installNetwork({ online: true });
await localDb.syncQueue.put({
id: `action:${SCOPE}:notebook.delete:nb-deleted`, kind: "notebook.delete", scope: SCOPE, memoId: "nb-deleted", status: "pending",
payload: { notebookId: "nb-deleted", notebookIds: ["nb-deleted"] }, attemptCount: 0, lastError: null, nextAttemptAt: null,
createdAt: "2026-01-01", updatedAt: "2026-01-01",
});
const restoreApi = stubApi({ listNotebooks: async () => ({ notebooks: [notebookFixture("nb-kept"), notebookFixture("nb-deleted")] }) });
try {
const result = await createWebRepository(SCOPE).listNotebooks();
expect(result.notebooks.map((notebook) => notebook.id)).toEqual(["nb-kept", "nb-deleted"]);
expect(await localDb.notebooks.get([SCOPE, "nb-kept"])).toBeDefined();
expect(await localDb.notebooks.get([SCOPE, "nb-deleted"])).toBeUndefined();
} finally {
restoreApi();
restoreNetwork();
}
});
test("ignores a slower background notebook refresh that was superseded by a newer one", async () => {
const restoreNetwork = installNetwork({ online: true });
await localDb.notebooks.put({ ...notebookFixture("nb-local"), scope: SCOPE });
const responses = [
async () => { await tick(40); return { notebooks: [notebookFixture("nb-stale")] }; },
async () => ({ notebooks: [notebookFixture("nb-fresh")] }),
];
const restoreApi = stubApi({ listNotebooks: () => responses.shift()() });
try {
const repository = createWebRepository(SCOPE);
expect((await repository.listNotebooks()).notebooks.map((notebook) => notebook.id)).toEqual(["nb-local"]);
await repository.listNotebooks();
await tick(80);
const stored = (await localDb.notebooks.where("scope").equals(SCOPE).toArray()).map((notebook) => notebook.id);
expect(stored).toEqual(["nb-fresh"]);
} finally {
restoreApi();
restoreNetwork();
}
});
test("returns local notebooks offline without contacting the server", async () => {
const restoreNetwork = installNetwork({ online: false });
await localDb.notebooks.put({ ...notebookFixture("nb-local"), scope: SCOPE });
let calls = 0;
const restoreApi = stubApi({ listNotebooks: async () => { calls += 1; return { notebooks: [] }; } });
try {
expect((await createWebRepository(SCOPE).listNotebooks()).notebooks.map((notebook) => notebook.id)).toEqual(["nb-local"]);
await tick();
expect(calls).toBe(0);
} finally {
restoreApi();
restoreNetwork();
}
});
test("queues notebook creation under its temporary id and announces the queue change", async () => {
const restoreNetwork = installNetwork({ online: true });
let events = 0;
window.addEventListener("edgeever:sync-queue-changed", () => { events += 1; });
try {
const { notebook } = await createWebRepository(SCOPE).createNotebook({ name: " Projects ", parentId: null });
expect(notebook.id.startsWith("local_")).toBe(true);
expect(notebook.name).toBe("Projects");
expect((await queuedAction("notebook.create", notebook.id))?.payload).toEqual({ name: " Projects ", parentId: null, temporaryId: notebook.id });
expect(events).toBe(1);
} finally {
restoreNetwork();
}
});
test("updates a mirrored notebook locally and queues the change", async () => {
const restoreNetwork = installNetwork({ online: true });
await localDb.notebooks.put({ ...notebookFixture("nb-1"), scope: SCOPE });
const restoreApi = stubApi({ updateNotebook: async () => { throw new Error("must not call the server"); } });
try {
const result = await createWebRepository(SCOPE).updateNotebook("nb-1", { name: "Renamed" });
expect(result.notebook.name).toBe("Renamed");
expect((await queuedAction("notebook.update", "nb-1"))?.payload).toEqual({ notebookId: "nb-1", name: "Renamed" });
} finally {
restoreApi();
restoreNetwork();
}
});
test("updates an unmirrored notebook on the server when online and refuses offline", async () => {
let restoreNetwork = installNetwork({ online: true });
const restoreApi = stubApi({ updateNotebook: async (id, input) => ({ notebook: notebookFixture(id, input) }) });
try {
const result = await createWebRepository(SCOPE).updateNotebook("nb-remote", { name: "Server" });
expect(result.notebook.name).toBe("Server");
expect((await localDb.notebooks.get([SCOPE, "nb-remote"]))?.name).toBe("Server");
expect(await queuedAction("notebook.update", "nb-remote")).toBeUndefined();
restoreNetwork();
restoreNetwork = installNetwork({ online: false });
await expect(createWebRepository(SCOPE).updateNotebook("nb-missing", { name: "x" })).rejects.toThrow("Notebook is not available in the local mirror");
} finally {
restoreApi();
restoreNetwork();
}
});
test("deletes an empty notebook tree leaves-first and refuses a notebook that still has notes", async () => {
const restoreNetwork = installNetwork({ online: true });
await localDb.notebooks.bulkPut([
{ ...notebookFixture("nb-parent"), scope: SCOPE },
{ ...notebookFixture("nb-child", { parentId: "nb-parent" }), scope: SCOPE },
{ ...notebookFixture("nb-full"), scope: SCOPE },
]);
await createLocalMemo(SCOPE, { notebookId: "nb-full" });
try {
const repository = createWebRepository(SCOPE);
expect(await repository.deleteNotebook("nb-parent")).toEqual({ ok: true });
expect((await queuedAction("notebook.delete", "nb-parent"))?.payload).toEqual({ notebookId: "nb-parent", notebookIds: ["nb-child", "nb-parent"] });
expect(await localDb.notebooks.get([SCOPE, "nb-child"])).toBeUndefined();
await expect(repository.deleteNotebook("nb-full")).rejects.toThrow("notebook_not_empty");
expect(await queuedAction("notebook.delete", "nb-full")).toBeUndefined();
expect(await localDb.notebooks.get([SCOPE, "nb-full"])).toBeDefined();
} finally {
restoreNetwork();
}
});
test("restores a notebook only with a connection and mirrors the result", async () => {
let restoreNetwork = installNetwork({ online: false });
const restoreApi = stubApi({ restoreNotebook: async (id) => ({ notebook: notebookFixture(id, { name: "Restored" }) }) });
try {
await expect(createWebRepository(SCOPE).restoreNotebook("nb-1")).rejects.toThrow("Notebook restore requires a connection");
restoreNetwork();
restoreNetwork = installNetwork({ online: true });
expect((await createWebRepository(SCOPE).restoreNotebook("nb-1")).notebook.name).toBe("Restored");
expect((await localDb.notebooks.get([SCOPE, "nb-1"]))?.name).toBe("Restored");
} finally {
restoreApi();
restoreNetwork();
}
});
});
describe("web repository templates", () => {
test("falls back to the local templates when the server list fails", async () => {
const restoreNetwork = installNetwork({ online: true });
await localDb.templates.put({ ...templateFixture("template-local"), scope: SCOPE });
const restoreApi = stubApi({ listTemplates: async () => { throw new Error("network down"); } });
try {
expect((await createWebRepository(SCOPE).listTemplates()).templates.map((template) => template.id)).toEqual(["template-local"]);
expect(await localDb.templates.get([SCOPE, "template-local"])).toBeDefined();
} finally {
restoreApi();
restoreNetwork();
}
});
test("hides a remote template whose deletion is still queued", async () => {
const restoreNetwork = installNetwork({ online: true });
const repository = createWebRepository(SCOPE);
await localDb.templates.put({ ...templateFixture("template-gone"), scope: SCOPE });
await repository.deleteTemplate("template-gone");
const restoreApi = stubApi({ listTemplates: async () => ({ templates: [templateFixture("template-gone"), templateFixture("template-kept")] }) });
try {
expect((await repository.listTemplates()).templates.map((template) => template.id)).toEqual(["template-kept"]);
expect((await queuedAction("template.delete", "template-gone"))?.payload).toEqual({ templateId: "template-gone" });
} finally {
restoreApi();
restoreNetwork();
}
});
test("creates and updates templates locally with queued sync actions", async () => {
const restoreNetwork = installNetwork({ online: true });
try {
const repository = createWebRepository(SCOPE);
const { template } = await repository.createTemplate({ name: "Daily", contentMarkdown: "# Day" });
expect((await queuedAction("template.create", template.id))?.payload).toEqual({ name: "Daily", contentMarkdown: "# Day", temporaryId: template.id });
const updated = await repository.updateTemplate(template.id, { name: "Daily log" });
expect(updated.template.name).toBe("Daily log");
expect(updated.template.contentMarkdown).toBe("# Day");
expect((await queuedAction("template.update", template.id))?.payload).toEqual({ templateId: template.id, name: "Daily log" });
} finally {
restoreNetwork();
}
});
test("updates an unmirrored template on the server online and refuses offline", async () => {
let restoreNetwork = installNetwork({ online: true });
const restoreApi = stubApi({ updateTemplate: async (id, input) => ({ template: templateFixture(id, input) }) });
try {
expect((await createWebRepository(SCOPE).updateTemplate("template-remote", { name: "Server" })).template.name).toBe("Server");
expect((await localDb.templates.get([SCOPE, "template-remote"]))?.name).toBe("Server");
restoreNetwork();
restoreNetwork = installNetwork({ online: false });
await expect(createWebRepository(SCOPE).updateTemplate("template-missing", { name: "x" })).rejects.toThrow("Template is not available in the local mirror");
} finally {
restoreApi();
restoreNetwork();
}
});
test("creates a note from a mirrored template and queues its creation", async () => {
const restoreNetwork = installNetwork({ online: true });
await localDb.templates.put({ ...templateFixture("template-1"), scope: SCOPE });
const restoreApi = stubApi({ useTemplate: async () => { throw new Error("must not call the server"); } });
try {
const { memo } = await createWebRepository(SCOPE).useTemplate("template-1", "nb-1");
expect(memo.title).toBe("template-1 title");
expect(memo.tags).toEqual(["from-template"]);
const queued = await localDb.syncQueue.get(`memo.create:${memo.id}`);
expect(queued?.payload).toMatchObject({ temporaryId: memo.id, notebookId: "nb-1", contentMarkdown: "template-1 body" });
} finally {
restoreApi();
restoreNetwork();
}
});
test("uses an unmirrored template on the server online and refuses offline", async () => {
let restoreNetwork = installNetwork({ online: true });
const calls = [];
const restoreApi = stubApi({ useTemplate: async (...args) => { calls.push(args); return { memo: remoteMemoFixture("memo-from-template") }; } });
try {
expect((await createWebRepository(SCOPE).useTemplate("template-remote", "nb-1", "pl-PL")).memo.id).toBe("memo-from-template");
expect(calls).toEqual([["template-remote", "nb-1", "pl-PL"]]);
expect(await localDb.memos.get([SCOPE, "memo-from-template"])).toBeDefined();
restoreNetwork();
restoreNetwork = installNetwork({ online: false });
await expect(createWebRepository(SCOPE).useTemplate("template-missing", "nb-1")).rejects.toThrow("Template is not available offline");
} finally {
restoreApi();
restoreNetwork();
}
});
});
describe("web repository resources", () => {
test("stores an offline upload locally and queues it for later upload", async () => {
const restoreNetwork = installNetwork({ online: false });
const fakeCaches = installFakeCaches();
let uploads = 0;
const restoreApi = stubApi({ uploadMemoResource: async () => { uploads += 1; } });
try {
const file = new File(["data"], "notes.txt", { type: "text/plain" });
const { resource } = await createWebRepository(SCOPE).uploadMemoResource("memo-1", file);
expect(resource.id.startsWith("local_resource_")).toBe(true);
expect(uploads).toBe(0);
const queued = await queuedAction("resource.create", resource.id);
expect(queued?.memoId).toBe("memo-1");
expect(queued?.payload).toEqual({ resourceId: resource.id, memoId: "memo-1", filename: "notes.txt", mimeType: resource.mimeType, url: resource.url });
const blob = await createWebRepository(SCOPE).readResource(resource.id);
expect(await blob.text()).toBe("data");
} finally {
restoreApi();
fakeCaches.restore();
restoreNetwork();
}
});
test("uploads online and mirrors the server resource", async () => {
const restoreNetwork = installNetwork({ online: true });
const restoreApi = stubApi({ uploadMemoResource: async () => ({ resource: resourceFixture("res-1", { memoTitle: undefined }) }) });
try {
await createWebRepository(SCOPE).uploadMemoResource("memo-1", new File(["x"], "a.txt"));
expect(await localDb.resources.get([SCOPE, "res-1"])).toMatchObject({ id: "res-1", memoTitle: null, memoDeleted: false });
} finally {
restoreApi();
restoreNetwork();
}
});
test("reports offline reads of resources that were never cached", async () => {
const restoreNetwork = installNetwork({ online: false });
const fakeCaches = installFakeCaches();
await localDb.resources.put({ ...resourceFixture("res-uncached"), scope: SCOPE });
try {
const repository = createWebRepository(SCOPE);
await expect(repository.readResource("res-unknown")).rejects.toThrow("The resource is not available offline.");
await expect(repository.readResource("res-uncached")).rejects.toThrow("The resource is not available offline.");
} finally {
fakeCaches.restore();
restoreNetwork();
}
});
test("reads unmirrored resources from the blob endpoint even when caching fails", async () => {
const restoreNetwork = installNetwork({ online: true });
const previousCaches = globalThis.caches;
delete globalThis.caches;
const urls = [];
const restoreApi = stubApi({ getResourceResponse: async (url, init) => { urls.push([url, init]); return new Response("bytes"); } });
try {
const blob = await createWebRepository(SCOPE).readResource("res/1");
expect(await blob.text()).toBe("bytes");
expect(urls).toEqual([["/api/v1/resources/res%2F1/blob", { cache: "no-store" }]]);
} finally {
restoreApi();
if (previousCaches !== undefined) globalThis.caches = previousCaches;
restoreNetwork();
}
});
test("refuses to update, rename or delete an attachment that has not been synced", async () => {
let restoreNetwork = installNetwork({ online: true });
const restoreApi = stubApi({
updateResourceContent: async () => { throw new Error("must not call the server"); },
renameResource: async () => { throw new Error("must not call the server"); },
deleteResource: async () => { throw new Error("must not call the server"); },
});
try {
const repository = createWebRepository(SCOPE);
await expect(repository.updateResource("local_resource_1", new File(["x"], "a.txt"), "hash")).rejects.toThrow("Attachments can only be updated after they are synced.");
await expect(repository.renameResource("local_resource_1", "b.txt")).rejects.toThrow("Attachments can only be renamed after they are synced.");
await expect(repository.deleteResource("local_resource_1")).rejects.toThrow("Attachments can only be deleted after they are synced.");
restoreNetwork();
restoreNetwork = installNetwork({ online: false });
await expect(repository.updateResource("res-1", new File(["x"], "a.txt"), "hash")).rejects.toThrow("Attachments can only be updated after they are synced.");
await expect(repository.renameResource("res-1", "b.txt")).rejects.toThrow("Attachments can only be renamed after they are synced.");
await expect(repository.deleteResource("res-1")).rejects.toThrow("Attachments can only be deleted after they are synced.");
} finally {
restoreApi();
restoreNetwork();
}
});
test("keeps note metadata when replacing the contents of a synced attachment", async () => {
const restoreNetwork = installNetwork({ online: true });
const fakeCaches = installFakeCaches();
await localDb.resources.put({ ...resourceFixture("res-1", { memoTitle: "Plan", memoExcerpt: "excerpt", memoDeleted: true }), scope: SCOPE });
const calls = [];
const restoreApi = stubApi({
updateResourceContent: async (id, file, hash) => {
calls.push([id, file.name, hash]);
return { resource: resourceFixture(id, { byteSize: 9, sha256: "new", memoTitle: undefined, memoExcerpt: undefined, memoDeleted: undefined }) };
},
});
try {
await createWebRepository(SCOPE).updateResource("res-1", new File(["new bytes"], "a.txt", { type: "text/plain" }), "old-hash");
expect(calls).toEqual([["res-1", "a.txt", "old-hash"]]);
expect(await localDb.resources.get([SCOPE, "res-1"])).toMatchObject({ byteSize: 9, memoTitle: "Plan", memoExcerpt: "excerpt", memoDeleted: true });
expect(fakeCaches.store.size).toBe(1);
} finally {
restoreApi();
fakeCaches.restore();
restoreNetwork();
}
});
test("loads resources from the server on a cold mirror and refreshes them in the background later", async () => {
const restoreNetwork = installNetwork({ online: true });
const lists = [
{ resources: [resourceFixture("res-1")], summary: { totalCount: 1, totalBytes: 4, imageCount: 0, attachmentCount: 1 } },
{ resources: [resourceFixture("res-2")], summary: { totalCount: 1, totalBytes: 4, imageCount: 0, attachmentCount: 1 } },
];
const restoreApi = stubApi({ listResources: async () => lists.shift() });
try {
const repository = createWebRepository(SCOPE);
expect((await repository.listResources()).resources.map((resource) => resource.id)).toEqual(["res-1"]);
await localDb.resources.put({ ...resourceFixture("local_resource_pending"), scope: SCOPE });
const cached = await repository.listResources();
expect(cached.resources.map((resource) => resource.id).sort()).toEqual(["local_resource_pending", "res-1"]);
await tick(10);
const stored = (await localDb.resources.where("scope").equals(SCOPE).toArray()).map((resource) => resource.id).sort();
expect(stored).toEqual(["local_resource_pending", "res-2"]);
} finally {
restoreApi();
restoreNetwork();
}
});
test("renames and deletes synced attachments on the server and in the mirror", async () => {
const restoreNetwork = installNetwork({ online: true });
const fakeCaches = installFakeCaches();
await localDb.resources.put({ ...resourceFixture("res-1"), scope: SCOPE });
const restoreApi = stubApi({
renameResource: async (id) => ({ resource: resourceFixture(id, { filename: "" }) }),
deleteResource: async () => ({ ok: true }),
});
try {
const repository = createWebRepository(SCOPE);
await repository.renameResource("res-1", "fallback.txt");
expect((await localDb.resources.get([SCOPE, "res-1"]))?.filename).toBe("fallback.txt");
expect(await repository.deleteResource("res-1")).toEqual({ ok: true });
expect(await localDb.resources.get([SCOPE, "res-1"])).toBeUndefined();
} finally {
restoreApi();
fakeCaches.restore();
restoreNetwork();
}
});
});
describe("web repository tags and batch note actions", () => {
test("lists tags from the mirror and only asks the server on an empty online mirror", async () => {
const restoreNetwork = installNetwork({ online: true });
let calls = 0;
const restoreApi = stubApi({ listTags: async () => { calls += 1; return { tags: [{ name: "remote", memoCount: 1, updatedAt: null }] }; } });
try {
const repository = createWebRepository(SCOPE);
expect((await repository.listTags()).tags.map((tag) => tag.name)).toEqual(["remote"]);
await createLocalMemo(SCOPE, { notebookId: "nb-1", tags: ["local"] });
expect((await repository.listTags()).tags.map((tag) => tag.name)).toEqual(["local"]);
expect(calls).toBe(1);
} finally {
restoreApi();
restoreNetwork();
}
});
test("applies tag renames and deletions locally and queues them", async () => {
const restoreNetwork = installNetwork({ online: true });
const memo = await createLocalMemo(SCOPE, { notebookId: "nb-1", tags: ["old", "keep"] });
try {
const repository = createWebRepository(SCOPE);
expect(await repository.renameTag("old", "new")).toEqual({ ok: true, updated: 1 });
expect((await localDb.memos.get([SCOPE, memo.id]))?.tags).toEqual(["new", "keep"]);
expect((await queuedAction("tag.rename", "old"))?.payload).toEqual({ tag: "old", name: "new" });
expect(await repository.deleteTag("keep")).toEqual({ ok: true, updated: 1 });
expect((await localDb.memos.get([SCOPE, memo.id]))?.tags).toEqual(["new"]);
expect((await queuedAction("tag.delete", "keep"))?.payload).toEqual({ tag: "keep" });
} finally {
restoreNetwork();
}
});
test("moves, pins and deletes notes in batches with one queued action each", async () => {
const restoreNetwork = installNetwork({ online: true });
const first = await createLocalMemo(SCOPE, { notebookId: "nb-1" });
const second = await createLocalMemo(SCOPE, { notebookId: "nb-1" });
const memoIds = [first.id, second.id];
const entityId = memoIds.join(",");
try {
const repository = createWebRepository(SCOPE);
expect(await repository.moveMemos({ memoIds, notebookId: "nb-2" })).toEqual({ ok: true, moved: 2 });
expect((await queuedAction("memo.moveBatch", entityId))?.payload).toEqual({ memoIds, notebookId: "nb-2" });
expect(await repository.pinMemos({ memoIds, isPinned: true })).toEqual({ ok: true, updated: 2 });
expect((await queuedAction("memo.pinBatch", entityId))?.payload).toEqual({ memoIds, isPinned: true });
expect(await repository.deleteMemos({ memoIds, permanent: true })).toEqual({ ok: true, deleted: 2 });
expect((await queuedAction("memo.deleteBatch", entityId))?.payload).toEqual({ memoIds, permanent: true });
expect(await localDb.memos.where("scope").equals(SCOPE).count()).toBe(0);
} finally {
restoreNetwork();
}
});
test("merges mirrored notes locally and queues the merge", async () => {
const restoreNetwork = installNetwork({ online: true });
const first = await createLocalMemo(SCOPE, { notebookId: "nb-1", contentMarkdown: "First" });
const second = await createLocalMemo(SCOPE, { notebookId: "nb-1", contentMarkdown: "Second" });
const restoreApi = stubApi({ mergeMemos: async () => { throw new Error("must not call the server"); } });
try {
const { memo } = await createWebRepository(SCOPE).mergeMemos({ memoIds: [first.id, second.id], title: "Merged" });
expect(memo.sourceMemoIds).toEqual([first.id, second.id]);
expect((await queuedAction("memo.merge", memo.id))?.payload).toEqual({ memoIds: [first.id, second.id], title: "Merged", temporaryId: memo.id });
expect((await localDb.memos.get([SCOPE, first.id]))?.mergedIntoMemoId).toBe(memo.id);
} finally {
restoreApi();
restoreNetwork();
}
});
test("merges unmirrored notes on the server online and refuses offline", async () => {
let restoreNetwork = installNetwork({ online: true });
const restoreApi = stubApi({ mergeMemos: async () => ({ memo: remoteMemoFixture("memo-merged") }) });
try {
expect((await createWebRepository(SCOPE).mergeMemos({ memoIds: ["a", "b"] })).memo.id).toBe("memo-merged");
restoreNetwork();
restoreNetwork = installNetwork({ online: false });
await expect(createWebRepository(SCOPE).mergeMemos({ memoIds: ["a", "b"] })).rejects.toThrow("Memos are not available in the local mirror");
} finally {
restoreApi();
restoreNetwork();
}
});
test("refuses to duplicate a note offline", async () => {
const restoreNetwork = installNetwork({ online: false });
try {
await expect(createWebRepository(SCOPE).duplicateMemo("memo-1")).rejects.toThrow("memo_duplicate_offline");
} finally {
restoreNetwork();
}
});
});
describe("web repository note reads and writes", () => {
test("maps list parameters onto the server query on a cold mirror", async () => {
const restoreNetwork = installNetwork({ online: true });
const received = [];
const restoreApi = stubApi({ listMemos: async (params) => { received.push(params); return { memos: [], nextCursor: null }; } });
try {
const repository = createWebRepository(SCOPE);
await repository.listMemos({ notebookId: "nb-1", notebookIds: ["nb-1", "nb-child"], offset: 40, limit: 20, q: "plan", sort: "updated" });
await repository.listMemos({ notebookId: "nb-1", notebookIds: ["nb-1"], offset: 0 });
expect(received[0]).toMatchObject({ notebookId: "nb-1", includeDescendants: true, cursor: "40", limit: 20, q: "plan", sort: "updated" });
expect(received[1]).toMatchObject({ includeDescendants: false, cursor: null });
} finally {
restoreApi();
restoreNetwork();
}
});
test("lists notes from the mirror once it holds data", async () => {
const restoreNetwork = installNetwork({ online: true });
const memo = await createLocalMemo(SCOPE, { notebookId: "nb-1", contentMarkdown: "Local" });
const restoreApi = stubApi({ listMemos: async () => { throw new Error("must not call the server"); } });
try {
const result = await createWebRepository(SCOPE).listMemos({ limit: 10 });
expect(result.memos.map((item) => item.id)).toEqual([memo.id]);
} finally {
restoreApi();
restoreNetwork();
}
});
test("reads a remapped temporary note id through its server id", async () => {
const restoreNetwork = installNetwork({ online: true });
await localDb.idMappings.put({ scope: SCOPE, temporaryId: "local_temp", remoteId: "memo-server", createdAt: "2026-01-01" });
const requested = [];
const restoreApi = stubApi({ getMemo: async (id) => { requested.push(id); return { memo: remoteMemoFixture(id) }; } });
try {
expect((await createWebRepository(SCOPE).getMemo("local_temp")).memo.id).toBe("memo-server");
expect(requested).toEqual(["memo-server"]);
} finally {
await localDb.idMappings.clear();
restoreApi();
restoreNetwork();
}
});
test("surfaces the server error when no local copy exists", async () => {
const restoreNetwork = installNetwork({ online: true });
const restoreApi = stubApi({ getMemo: async () => { throw new Error("memo_not_found"); } });
try {
await expect(createWebRepository(SCOPE).getMemo("memo-missing")).rejects.toThrow("memo_not_found");
} finally {
restoreApi();
restoreNetwork();
}
});
test("falls back to the local copy when the server read fails", async () => {
const restoreNetwork = installNetwork({ online: true });
const memo = await createLocalMemo(SCOPE, { notebookId: "nb-1", contentMarkdown: "Local only" });
const restoreApi = stubApi({ getMemo: async () => { throw new Error("network down"); } });
try {
expect((await createWebRepository(SCOPE).getMemo(memo.id)).memo.contentMarkdown).toBe("Local only");
} finally {
restoreApi();
restoreNetwork();
}
});
test("ignores a trashed local copy unless deleted notes were requested", async () => {
const restoreNetwork = installNetwork({ online: true });
const memo = await createLocalMemo(SCOPE, { notebookId: "nb-1", contentMarkdown: "Trashed" });
await putLocalMemo(SCOPE, { ...memo, isDeleted: true });
const restoreApi = stubApi({
getMemo: async (id) => {
await tick(20);
return { memo: remoteMemoFixture(id, { title: "Server copy", updatedAt: "2000-01-01T00:00:00.000Z" }) };
},
});
try {
const repository = createWebRepository(SCOPE);
expect((await repository.getMemo(memo.id, true)).memo.contentMarkdown).toBe("Trashed");
expect((await repository.getMemo(memo.id)).memo.title).toBe("Server copy");
// The server copy is cached in the background; let that write finish so
// it cannot land after the mirror is cleared for the next test.
const cachedTitle = async () => (await localDb.memos.toArray()).find((item) => item.id === memo.id)?.title;
for (let attempt = 0; attempt < 50 && (await cachedTitle()) !== "Server copy"; attempt += 1) {
await tick(5);
}
expect(await cachedTitle()).toBe("Server copy");
} finally {
restoreApi();
restoreNetwork();
}
});
test("creates notes locally and announces the queue change asynchronously", async () => {
const restoreNetwork = installNetwork({ online: true });
let events = 0;
window.setTimeout = globalThis.setTimeout.bind(globalThis);
window.addEventListener("edgeever:sync-queue-changed", () => { events += 1; });
try {
const { memo } = await createWebRepository(SCOPE).createMemo({ notebookId: "nb-1", contentMarkdown: "New" });
expect((await localDb.syncQueue.get(`memo.create:${memo.id}`))?.payload).toMatchObject({ temporaryId: memo.id, notebookId: "nb-1", title: "", tags: [] });
expect(events).toBe(0);
await tick(5);
expect(events).toBe(1);
} finally {
restoreNetwork();
}
});
test("adopting the cloud copy replaces the local note and drops the queued update", async () => {
const restoreNetwork = installNetwork({ online: true });
await localDb.memos.put({ ...remoteMemoFixture("memo-1", { title: "Local conflict" }), scope: SCOPE });
await localDb.syncQueue.put({
id: "memo.update:memo-1", kind: "memo.update", scope: SCOPE, memoId: "memo-1", status: "conflict", payload: { memoId: "memo-1" },
attemptCount: 1, lastError: "conflict", nextAttemptAt: null, createdAt: "2026-01-01", updatedAt: "2026-01-01",
});
const restoreApi = stubApi({ getMemo: async (id) => ({ memo: remoteMemoFixture(id, { title: "Cloud" }) }) });
try {
expect((await createWebRepository(SCOPE).adoptCloudMemo("memo-1")).memo.title).toBe("Cloud");
expect((await localDb.memos.get([SCOPE, "memo-1"]))?.title).toBe("Cloud");
expect(await localDb.syncQueue.get("memo.update:memo-1")).toBeUndefined();
} finally {
restoreApi();
restoreNetwork();
}
});
test("trashes and restores notes locally with queued sync actions", async () => {
const restoreNetwork = installNetwork({ online: true });
const memo = await createLocalMemo(SCOPE, { notebookId: "nb-1" });
try {
const repository = createWebRepository(SCOPE);
expect(await repository.deleteMemo(memo.id)).toEqual({ ok: true });
expect((await localDb.memos.get([SCOPE, memo.id]))?.isDeleted).toBe(true);
expect((await localDb.syncQueue.get(`memo.delete:${memo.id}`))?.payload).toEqual({ memoId: memo.id, permanent: false });
const restored = await repository.restoreMemo(memo.id);
expect(restored.memo.isDeleted).toBe(false);
expect(restored.queued).toBe(true);
expect((await localDb.syncQueue.get(`memo.restore:${memo.id}`))?.payload).toEqual({ memoId: memo.id });
} finally {
restoreNetwork();
}
});
test("reports a restore of a note missing from the mirror", async () => {
const restoreNetwork = installNetwork({ online: true });
try {
await expect(createWebRepository(SCOPE).restoreMemo("memo-missing")).rejects.toThrow("Memo is not available in the local mirror");
} finally {
restoreNetwork();
}
});
test("loads revisions from the server on a cold mirror and from the mirror afterwards", async () => {
const restoreNetwork = installNetwork({ online: true });
const revision = { id: "rev-1", memoId: "memo-1", revision: 1, title: "Old", contentMarkdown: "Old body", tags: [], createdAt: "2026-01-01T00:00:00.000Z" };
let calls = 0;
const restoreApi = stubApi({ listMemoRevisions: async () => { calls += 1; return { revisions: [revision] }; } });
try {
const repository = createWebRepository(SCOPE);
expect((await repository.listMemoRevisions("memo-1")).revisions).toEqual([revision]);
expect((await repository.listMemoRevisions("memo-1")).revisions).toEqual([revision]);
await tick();
expect(calls).toBe(2);
} finally {
restoreApi();
restoreNetwork();
}
});
test("restores a mirrored revision locally against the current note revision", async () => {
const restoreNetwork = installNetwork({ online: true });
const memo = await createLocalMemo(SCOPE, { notebookId: "nb-1", contentMarkdown: "Current" });
await localDb.revisions.put({ scope: SCOPE, id: "rev-1", memoId: memo.id, revision: 1, title: null, contentMarkdown: "Earlier", tags: ["old"], createdAt: "2026-01-01T00:00:00.000Z" });
const restoreApi = stubApi({ restoreMemoRevision: async () => { throw new Error("must not call the server"); } });
try {
const result = await createWebRepository(SCOPE).restoreMemoRevision(memo.id, "rev-1");
expect(result.memo.contentMarkdown).toBe("Earlier");
expect(result.memo.tags).toEqual(["old"]);
expect((await localDb.syncQueue.get(`memo.update:${memo.id}`))?.payload).toMatchObject({
memoId: memo.id,
expectedRevision: memo.revision,
expectedContentHash: memo.contentHash,
editSessionId: "offline-revision:rev-1",
title: "",
contentMarkdown: "Earlier",
});
} finally {
restoreApi();
restoreNetwork();
}
});
test("restores an unmirrored revision on the server online and refuses offline", async () => {
let restoreNetwork = installNetwork({ online: true });
const restoreApi = stubApi({ restoreMemoRevision: async (memoId) => ({ memo: remoteMemoFixture(memoId, { title: "Restored" }) }) });
try {
expect((await createWebRepository(SCOPE).restoreMemoRevision("memo-1", "rev-x")).memo.title).toBe("Restored");
restoreNetwork();
restoreNetwork = installNetwork({ online: false });
await expect(createWebRepository(SCOPE).restoreMemoRevision("memo-1", "rev-x")).rejects.toThrow("Revision is not available offline");
} finally {
restoreApi();
restoreNetwork();
}
});
});
describe("repository selection", () => {
test("uses the desktop sidecar when the desktop bridge is available and the local mirror otherwise", async () => {
const { createRepository } = await import("./repository.ts");
const restoreNetwork = installNetwork({ online: false });
const requests = [];
try {
window.edgeeverDesktop = {
isAvailable: true,
sidecarRequest: async (method) => { requests.push(method); return { notebooks: [notebookFixture("nb-desktop")] }; },
};
expect((await createRepository(SCOPE).listNotebooks()).notebooks.map((notebook) => notebook.id)).toEqual(["nb-desktop"]);
expect(requests).toEqual(["notebook.list"]);
window.edgeeverDesktop = undefined;
await localDb.notebooks.put({ ...notebookFixture("nb-web"), scope: SCOPE });
expect((await createRepository(SCOPE).listNotebooks()).notebooks.map((notebook) => notebook.id)).toEqual(["nb-web"]);
expect(requests).toEqual(["notebook.list"]);
} finally {
restoreNetwork();
}
});
});
+52 -1
View File
@@ -237,7 +237,7 @@ describe("preparePublishArticle", () => {
expect(h2?.textContent).toContain("章节标题");
expect(h2?.querySelector("[data-ee-heading-number]")?.innerHTML).toContain("#059669");
const h3 = root.querySelector("h3");
expect(css(h3)).toContain("border-radius:999px");
expect(css(h3)).toContain("border-radius:1em");
expect(h3?.querySelector("[data-ee-pill-dot]")).not.toBeNull();
const quote = root.querySelector("blockquote");
expect(css(quote)).toContain("border:1px dashed #d1d5db");
@@ -256,6 +256,57 @@ describe("preparePublishArticle", () => {
}
});
test("numbered headings follow body size while preserving the wrapping pill", () => {
const container = document.createElement("div");
container.dataset.notePalette = "native";
container.dataset.noteHeadingStyle = "numbered";
const previous = globalThis.getComputedStyle;
try {
for (const size of [14, 16, 18]) {
globalThis.getComputedStyle = () => ({
getPropertyValue: (name) => name === "--editor-body-font-size" ? `${size}px` : "",
});
const root = preparePublishArticle("<h2>章节</h2><h3>包含 <strong>强调</strong> 的长标题</h3><h4>子标题</h4><p>正文</p>", container);
expect(root.querySelector("h3").style.fontSize).toBe(`${size}px`);
expect(root.querySelector("h4").style.fontSize).toBe(`${size}px`);
expect(root.querySelector("p:not([data-ee-publish-chrome])").style.fontSize).toBe(`${size}px`);
const h2Title = Array.from(root.querySelectorAll("h2 p")).find((p) => p.textContent === "章节");
expect(h2Title.style.fontSize).toBe(`${size * 1.4}px`);
expect(css(root.querySelector("h3"))).toContain("max-width:100%");
expect(root.querySelector("h3 strong").textContent).toBe("强调");
expect(root.querySelectorAll("[data-ee-pill-dot]").length).toBe(1);
}
} finally {
globalThis.getComputedStyle = previous;
}
});
test("exports body rhythm independently of headings and keeps table cells compact", () => {
const container = document.createElement("div");
container.dataset.notePalette = "native";
const previous = globalThis.getComputedStyle;
try {
for (const headingStyle of ["default", "bar", "numbered"]) {
container.dataset.noteHeadingStyle = headingStyle;
globalThis.getComputedStyle = () => ({ getPropertyValue: (name) => ({
"--editor-paragraph-spacing": "12px",
"--editor-body-text-align": "justify",
"--editor-body-letter-spacing": "0.5px",
})[name] ?? "" });
const root = preparePublishArticle("<h3>标题</h3><p>正文</p><table><tbody><tr><td><p>单元格</p></td></tr></tbody></table>", container);
const body = root.querySelector(":scope > p");
expect(body.style.margin).toBe("0 0 12px");
expect(body.style.textAlign).toBe("justify");
expect(body.style.letterSpacing).toBe("0.5px");
const cell = root.querySelector("td p");
expect(cell.style.margin).toBe("0");
expect(cell.style.textAlign).toBe("inherit");
}
} finally {
globalThis.getComputedStyle = previous;
}
});
test("renders magazine cover card and toc cards only when showMagazineCover is enabled", () => {
const container = document.createElement("div");
container.dataset.notePalette = "emerald";
+17 -11
View File
@@ -4,8 +4,11 @@ import {
DEFAULT_NOTE_PROSE_LINE_HEIGHT,
flattenDetailsForLinearHtml,
NOTE_PROSE_HEADING_SCALE,
NOTE_PROSE_PARAGRAPH_SPACING_PX,
noteProseCodeFontSize,
noteProseStyleCssVariables,
parseNoteProseParagraphSpacing,
parseNoteProseTextAlign,
parseNoteProseLetterSpacing,
parseNoteProseEmphasisStyle,
parseNoteProseHeadingStyle,
parseNoteProseLineHeight,
@@ -27,7 +30,6 @@ import {
readNoteProseStylePreference,
} from "@/lib/note-prose-style-preference";
const PARAGRAPH_SPACING = `${NOTE_PROSE_PARAGRAPH_SPACING_PX}px`;
const PUBLISH_FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Noto Sans SC', sans-serif";
const PUBLISH_MONO = "'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
const BODY_COLOR = "#27272a";
@@ -62,6 +64,7 @@ const applyLegacyWeChatStyles = (
_accentOnly = false,
proseStyle: NoteProseStylePreferences = DEFAULT_NOTE_PROSE_STYLE_PREFERENCES,
) => {
const rhythm = noteProseStyleCssVariables(proseStyle);
const fontSize = `${metrics.fontSize}px`;
const lineHeight = String(metrics.lineHeight);
const codeSize = `${noteProseCodeFontSize(metrics.fontSize)}px`;
@@ -75,7 +78,7 @@ const applyLegacyWeChatStyles = (
const bold = colors ? `font-weight: 800; color: ${accent};` : "font-weight: 800;";
const thBackground = colors ? colors.accent : "#f3f4f6";
const thColor = colors ? "#ffffff" : "#111827";
const textStyle = `font-family: ${PUBLISH_FONT}; font-size: ${fontSize}; line-height: ${lineHeight}; letter-spacing: -0.005em; color: ${BODY_COLOR};`;
const textStyle = `font-family: ${PUBLISH_FONT}; font-size: ${fontSize}; line-height: ${lineHeight}; letter-spacing: ${rhythm["--editor-body-letter-spacing"]}; color: ${BODY_COLOR};`;
const h2Base = headingStyle("h2", metrics);
const h3Base = headingStyle("h3", metrics);
const h2Style = proseStyle.headingStyle === "bar"
@@ -86,10 +89,10 @@ const applyLegacyWeChatStyles = (
const h3Style = proseStyle.headingStyle === "bar"
? `${h3Base.replace(/padding:\s*0;/, "padding: 0 0 0 8px;")} border-left: 3px solid ${accent};`
: proseStyle.headingStyle === "numbered"
? `display: inline-block; margin: 18px 0 8px; padding: 4px 12px; border-radius: 999px; background: ${surface}; color: ${accent}; font-family: ${PUBLISH_FONT}; font-size: 13px; font-weight: 700; line-height: 1.5;`
? `display: block; width: fit-content; max-width: 100%; box-sizing: border-box; margin: 1.5em 0 0.6em; padding: 0.25em 0.75em 0.25em 1.5em; text-indent: -0.75em; overflow-wrap: anywhere; border-radius: 1em; background: ${surface}; color: ${accent}; font-family: ${PUBLISH_FONT}; font-size: ${fontSize}; font-weight: 700; line-height: 1.5;`
: h3Base;
const h4Style = proseStyle.headingStyle === "numbered"
? `display: inline-block; margin: 16px 0 8px; padding: 1px 6px; border-radius: 3px; background: #fde68a; color: #111827; font-family: ${PUBLISH_FONT}; font-size: 14px; font-weight: 800; line-height: 1.5;`
? `display: inline-block; margin: 16px 0 8px; padding: 1px 6px; border-radius: 3px; background: #fde68a; color: #111827; font-family: ${PUBLISH_FONT}; font-size: ${fontSize}; font-weight: 800; line-height: 1.5;`
: headingStyle("h4", metrics);
const blockquoteStyle = proseStyle.quoteStyle === "dashed"
? `margin: 0 0 20px; padding: 12px 16px; border: 1px dashed #d1d5db; border-radius: 8px; background: #f9fafb; color: ${QUOTE_COLOR}; font-family: ${PUBLISH_FONT}; line-height: ${lineHeight};`
@@ -107,9 +110,9 @@ const applyLegacyWeChatStyles = (
const thStyle = `padding: 8px 13px; border: 1px solid #e5e5e5; background: ${thBackground}; color: ${thColor}; font-weight: 700; font-size: ${tableHeaderSize}; text-align: left; vertical-align: top;`;
const imgStyle =
"display: block; max-width: 100%; height: auto; margin: 16px 0; padding: 6px; box-sizing: border-box; background: #ffffff; border: 1px solid #e5e7eb; border-radius: 12px; box-shadow: 0 4px 12px -2px rgba(0,0,0,0.08);";
const paragraphBottom = proseStyle.headingStyle === "numbered" ? "16px" : PARAGRAPH_SPACING;
const paragraphBottom = rhythm["--editor-paragraph-spacing"];
const styles: Record<string, string> = {
p: `margin: 0 0 ${paragraphBottom}; padding: 0; ${textStyle}`,
p: `margin: 0 0 ${paragraphBottom}; padding: 0; text-align: ${rhythm["--editor-body-text-align"]}; ${textStyle}`,
h1: headingStyle("h1", metrics),
h2: h2Style,
h3: h3Style,
@@ -153,7 +156,7 @@ const applyLegacyWeChatStyles = (
const quoted = (tagName === "p" || tagName === "li") && element.closest("blockquote");
const cell = tagName === "p" ? element.closest("th, td") : null;
const baseStyle = cell
? `margin: 0; padding: 0; font-family: ${PUBLISH_FONT}; font-size: ${cell.tagName.toLowerCase() === "th" ? tableHeaderSize : fontSize}; line-height: 1.45; letter-spacing: inherit; color: ${cell.tagName.toLowerCase() === "th" ? thColor : BODY_COLOR};`
? `margin: 0; padding: 0; font-family: ${PUBLISH_FONT}; font-size: ${cell.tagName.toLowerCase() === "th" ? tableHeaderSize : fontSize}; line-height: 1.45; text-align: inherit; letter-spacing: inherit; color: ${cell.tagName.toLowerCase() === "th" ? thColor : BODY_COLOR};`
: styles[tagName] || "";
// One color declaration. A second `color` later in the attribute is easy for WeChat to drop.
const style = isMergeDivider
@@ -185,7 +188,7 @@ const applyLegacyWeChatStyles = (
`<span data-ee-publish-chrome="true" style="display: inline-block; width: 6px; height: 6px; background: ${accent}; border-radius: 50%; vertical-align: middle;"><span leaf=""><br></span></span>`,
`<span data-ee-publish-chrome="true" style="display: inline-block; font-size: 11px; font-weight: 700; letter-spacing: 3px; color: ${accent}; vertical-align: middle; margin-left: 8px;">FEATURED · 深度实测</span>`,
`</section>`,
`<p data-ee-publish-chrome="true" style="font-size: 23px; font-weight: 900; color: #111827; margin: 0 0 14px; line-height: 1.2; letter-spacing: -0.5px;">${titleHtml}</p>`,
`<p data-ee-publish-chrome="true" style="font-size: ${metrics.fontSize * 1.75}px; font-weight: 900; color: #111827; margin: 0 0 14px; line-height: 1.2; letter-spacing: -0.5px;">${titleHtml}</p>`,
`<section data-ee-publish-chrome="true" style="width: 48px; height: 3px; background: ${accent}; border-radius: 2px;"><span leaf=""><br></span></section>`,
`</section>`,
`<section data-ee-publish-chrome="true" style="${colors ? accentGradient : "background: #f3f4f6;"} padding: 10px 24px; font-size: 0;">`,
@@ -259,7 +262,7 @@ const applyLegacyWeChatStyles = (
`</section>`,
`<span data-ee-publish-chrome="true" style="display: inline-block; width: 1px; height: 36px; background: #e5e7eb; vertical-align: middle; margin: 0 16px;"><span leaf=""><br></span></span>`,
`<section data-ee-publish-chrome="true" style="display: inline-block; vertical-align: middle; max-width: calc(100% - 86px);">`,
`<p data-ee-publish-chrome="true" style="margin: 0; font-size: 17px; font-weight: 900; color: #111827; letter-spacing: 0.3px; line-height: 1.3;">${titleHtml}</p>`,
`<p data-ee-publish-chrome="true" style="margin: 0; font-size: ${metrics.fontSize * 1.4}px; font-weight: 900; color: #111827; letter-spacing: 0.3px; line-height: 1.3;">${titleHtml}</p>`,
`<p data-ee-publish-chrome="true" style="margin: 3px 0 0; font-size: 11px; font-weight: 600; color: #9ca3af; letter-spacing: 1px; line-height: 1.3;">${subLabel}</p>`,
`</section>`,
].join("");
@@ -270,7 +273,7 @@ const applyLegacyWeChatStyles = (
const dot = document.createElement("span");
dot.setAttribute("data-ee-publish-chrome", "true");
dot.setAttribute("data-ee-pill-dot", "true");
dot.style.cssText = `display: inline-block; width: 6px; height: 6px; background: ${accent}; border-radius: 50%; margin-right: 6px; vertical-align: middle;`;
dot.style.cssText = `display: inline-block; width: 0.375em; height: 0.375em; background: ${accent}; border-radius: 50%; margin-right: 0.375em; text-indent: 0; vertical-align: middle;`;
dot.innerHTML = "<span leaf=\"\"><br></span>";
heading.insertBefore(dot, heading.firstChild);
});
@@ -1254,6 +1257,9 @@ export const readEditorCopyContext = (from?: HTMLElement | null) => {
const customStyleTag = container?.querySelector<HTMLStyleElement>("style[data-theme-custom-css]");
const storedStyle = readNoteProseStylePreference();
const proseStyle: NoteProseStylePreferences = {
paragraphSpacing: parseNoteProseParagraphSpacing(readCssNumber(style?.getPropertyValue("--editor-paragraph-spacing"), storedStyle.paragraphSpacing)) ?? storedStyle.paragraphSpacing,
textAlign: parseNoteProseTextAlign(style?.getPropertyValue("--editor-body-text-align").trim()) ?? storedStyle.textAlign,
letterSpacing: parseNoteProseLetterSpacing(style?.getPropertyValue("--editor-body-letter-spacing").trim()) ?? storedStyle.letterSpacing,
headingStyle: parseNoteProseHeadingStyle(container?.dataset.noteHeadingStyle) ?? storedStyle.headingStyle,
quoteStyle: parseNoteProseQuoteStyle(container?.dataset.noteQuoteStyle) ?? storedStyle.quoteStyle,
emphasisStyle: parseNoteProseEmphasisStyle(container?.dataset.noteEmphasisStyle) ?? storedStyle.emphasisStyle,
+8
View File
@@ -106,6 +106,7 @@
}
.edgeever-print-content p {
text-align: var(--editor-body-text-align, left);
margin: 0 0 var(--editor-paragraph-spacing, 8px);
orphans: 3;
widows: 3;
@@ -483,3 +484,10 @@
.edgeever-print-content .edgeever-mermaid-source {
display: none !important;
}
.edgeever-print-content {
letter-spacing: var(--editor-body-letter-spacing, -0.005em);
}
.edgeever-print-content :is(th, td) p {
text-align: inherit;
}
+39 -22
View File
@@ -1,3 +1,22 @@
/* Independent body rhythm applies to every heading style. */
.edgeever-editor .ProseMirror,
.edgeever-editor .markdown-content {
letter-spacing: var(--editor-body-letter-spacing, -0.005em);
}
.edgeever-editor .ProseMirror p,
.edgeever-editor .markdown-content p {
margin-bottom: var(--editor-paragraph-spacing, 8px);
text-align: var(--editor-body-text-align, left);
}
/* Table cells keep their own compact rhythm and alignment. */
.edgeever-editor .ProseMirror :is(th, td) p,
.edgeever-editor .markdown-content :is(th, td) p {
margin: 0;
text-align: inherit;
}
/* Composable reading styles for headings, blockquotes, and inline emphasis. */
/* 1. Heading style: Left bar ("bar") */
@@ -17,13 +36,6 @@
.edgeever-editor[data-note-heading-style="numbered"] .ProseMirror,
.edgeever-editor[data-note-heading-style="numbered"] .markdown-content {
counter-reset: note-prose-h2;
letter-spacing: 0.5px;
}
.edgeever-editor[data-note-heading-style="numbered"] .ProseMirror p,
.edgeever-editor[data-note-heading-style="numbered"] .markdown-content p {
margin-bottom: 16px;
text-align: justify;
}
/* H1: Magazine cover-breaking card */
@@ -36,7 +48,7 @@
border: 1.5px solid color-mix(in srgb, var(--note-palette-accent, #374151) 18%, transparent);
border-radius: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
font-size: 24px;
font-size: calc(var(--editor-body-font-size, 16px) * 1.75);
font-weight: 900;
line-height: 1.25;
letter-spacing: -0.5px;
@@ -114,7 +126,7 @@
min-height: 40px;
display: block;
border: 0;
font-size: 17px;
font-size: calc(var(--editor-body-font-size, 16px) * 1.4);
font-weight: 900;
color: #111827;
letter-spacing: 0.3px;
@@ -179,20 +191,23 @@
content: "SUMMARY";
}
/* H3: Green pill badge with solid dot */
/* H3: Body-relative pill heading; bounded corners also suit wrapped titles. */
.edgeever-editor[data-note-heading-style="numbered"] .ProseMirror h3,
.edgeever-editor[data-note-heading-style="numbered"] .markdown-content h3 {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 1.1rem;
margin-bottom: 0.5rem;
padding: 4px 12px;
position: relative;
display: block;
width: fit-content;
max-width: 100%;
box-sizing: border-box;
margin-top: 1.5em;
margin-bottom: 0.6em;
padding: 0.25em 0.75em 0.25em 1.5em;
overflow-wrap: anywhere;
border: 0;
border-radius: 999px;
border-radius: 1em;
background: color-mix(in srgb, var(--note-palette-accent, #374151) 9%, transparent);
color: var(--note-palette-accent, #374151);
font-size: 13px;
font-size: var(--editor-body-font-size, 16px);
font-weight: 700;
line-height: 1.5;
}
@@ -200,11 +215,13 @@
.edgeever-editor[data-note-heading-style="numbered"] .ProseMirror h3::before,
.edgeever-editor[data-note-heading-style="numbered"] .markdown-content h3::before {
content: "";
width: 6px;
height: 6px;
position: absolute;
left: 0.75em;
top: 0.8125em;
width: 0.375em;
height: 0.375em;
border-radius: 50%;
background: var(--note-palette-accent, #374151);
flex-shrink: 0;
pointer-events: none;
}
@@ -218,7 +235,7 @@
border-radius: 3px;
background: #fde68a;
color: #111827;
font-size: 14px;
font-size: var(--editor-body-font-size, 16px);
font-weight: 800;
}