chore: update EdgeEver from v2.19.0
EdgeEver-Upstream-Commit: a80d9bfda1aad846e20882813758a59fe27bf8ca
This commit is contained in:
1 parent
67cf5297de
commit
507cb892ad
69 files changed
+9768
-587
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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
@@ -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 });
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: "" } }),
|
||||
}, 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("");
|
||||
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: "" } }),
|
||||
}, async ({ calls, events }) => {
|
||||
const { memo } = await createDesktopRepository().restoreMemoRevision("memo-1", "rev-1");
|
||||
expect(memo.contentMarkdown).toBe("");
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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: "" })],
|
||||
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: "" });
|
||||
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: ``, contentJson: { type: "doc", content: [{ type: "image", attrs: { src: stagedUrl } }] } } }],
|
||||
localMemos: { "memo-1": { id: "memo-1", contentMarkdown: ``, 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(``);
|
||||
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: `` } }],
|
||||
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: `` } }],
|
||||
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 = ``;
|
||||
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: ``, 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: `` } }],
|
||||
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: `` } };
|
||||
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: ``, 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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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(
|
||||
"<a href="x" onclick='y'>&</a>",
|
||||
);
|
||||
});
|
||||
|
||||
test("escapes ampersands first so entities are not double-decoded", () => {
|
||||
expect(mod.escapeHtml("<script>")).toBe("&lt;script&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("<script>alert(1)</script>");
|
||||
expect(markup).toContain("#"><svg onload=alert(3)>");
|
||||
|
||||
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" onload="alert(1)">');
|
||||
expect(html).toContain("<title></title><script>alert(1)</script></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><Trip></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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user