Files
paseo/packages/server/src/utils/project-custom-icon.test.ts
T
Mohamed BoudraandGitHub 373a98c64d Restore host directories instantly before reconnect (#3259)
* feat(sync): restore host directories before reconnect

Persist complete project, workspace, and active-agent replicas so every registered host can paint immediately, including offline hosts. Reconcile those replicas through monotonic per-entity cursors on the existing directory RPCs without retaining an event journal.

* fix(sync): preserve active turn identity during catch-up

Own Agent-to-wire projection beside the existing snapshot normalizer so replica persistence does not reach through directory-sync internals. This also keeps identified active turns intact when unchanged agents are folded into an incremental directory response.

* test(e2e): inspect the real IndexedDB replica

Keep cache setup, reload, and hydration coverage on the production browser storage backend after the replica moved off localStorage. Measure writes at the actual IndexedDB put boundary as well.

* fix warm reconnect cache paths

* feat(sync): persist project icons and deepen directory sync

* fix(sync): preserve project update order

* fix(cache): preserve validated replica persistence

* fix(sync): retain cached agents during catch-up
2026-08-13 23:10:44 +02:00

178 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { ProjectIconSource } from "@getpaseo/protocol/messages";
import {
createPersistedProjectRecord,
type PersistedProjectRecord,
type ProjectRegistry,
} from "../server/workspace-registry.js";
import {
ProjectIconReader,
readProjectIcon,
readProjectIconSnapshot,
removeProjectCustomIcon,
setProjectCustomIcon,
} from "./project-custom-icon.js";
const PNG_1X1 = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00,
]);
const PNG_1X1_ICON = { data: PNG_1X1.toString("base64"), mimeType: "image/png" };
const cleanup: Array<() => Promise<void>> = [];
afterEach(async () => {
await Promise.all(cleanup.splice(0).map((fn) => fn()));
});
async function tempDir(prefix: string): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), prefix));
cleanup.push(() => rm(directory, { recursive: true, force: true }));
return directory;
}
/** A project whose root holds no discoverable icon, over an in-memory registry. */
async function project() {
const rootPath = await tempDir("paseo-project-root-");
const paseoHome = await tempDir("paseo-home-");
let record = createPersistedProjectRecord({
projectId: "project-a",
rootPath,
kind: "git",
displayName: "repo",
createdAt: "2026-07-22T12:00:00.000Z",
updatedAt: "2026-07-22T12:00:00.000Z",
});
const projects = {
get: async () => record,
update: async (_id: string, updater: (r: PersistedProjectRecord) => PersistedProjectRecord) => {
record = updater(record);
return record;
},
} as unknown as ProjectRegistry;
const reader = new ProjectIconReader(paseoHome);
return {
paseoHome,
rootPath,
set: (source: ProjectIconSource) =>
setProjectCustomIcon({ paseoHome, projectId: "project-a", source, projects }),
read: () => readProjectIcon({ paseoHome, project: record }),
snapshot: () => readProjectIconSnapshot({ paseoHome, project: record }),
advertisedSnapshot: () => reader.snapshot(record),
readAdvertised: () => reader.read(record),
revision: () => record.customIconRevision,
remove: () => removeProjectCustomIcon({ paseoHome, projectId: "project-a" }),
};
}
function nonSquarePng(): Buffer {
const wide = Buffer.from(PNG_1X1);
wide.writeUInt32BE(2, 16);
return wide;
}
describe("project custom icon", () => {
it("stores uploaded bytes as the custom icon", async () => {
const target = await project();
await target.set({ type: "upload", data: PNG_1X1.toString("base64") });
expect(target.revision()).toEqual(expect.any(String));
await expect(target.read()).resolves.toEqual(PNG_1X1_ICON);
});
it("scans the project directory when no custom icon is stored", async () => {
const target = await project();
await mkdir(join(target.rootPath, "public"));
await writeFile(join(target.rootPath, "public", "favicon.png"), PNG_1X1);
await expect(target.read()).resolves.toEqual(PNG_1X1_ICON);
});
it("fingerprints automatic icons and explicit no-icon results", async () => {
const target = await project();
const missing = await target.snapshot();
expect(missing).toEqual({ icon: null, revision: "automatic:none:v1" });
await mkdir(join(target.rootPath, "public"));
await writeFile(join(target.rootPath, "public", "favicon.png"), PNG_1X1);
const first = await target.snapshot();
await writeFile(
join(target.rootPath, "public", "favicon.png"),
Buffer.concat([PNG_1X1, Buffer.from([1])]),
);
const changed = await target.snapshot();
expect(first.icon).toEqual(PNG_1X1_ICON);
expect(first.revision).toMatch(/^automatic:[a-f0-9]{64}$/);
expect(changed.revision).not.toBe(first.revision);
});
it("serves the bytes that match the advertised revision", async () => {
const target = await project();
await mkdir(join(target.rootPath, "public"));
await writeFile(join(target.rootPath, "public", "favicon.png"), PNG_1X1);
const advertised = await target.advertisedSnapshot();
await writeFile(
join(target.rootPath, "public", "favicon.png"),
Buffer.concat([PNG_1X1, Buffer.from([1])]),
);
await expect(target.readAdvertised()).resolves.toEqual(advertised.icon);
});
it("drops the stored image when the project goes back to automatic", async () => {
const target = await project();
await target.set({ type: "upload", data: PNG_1X1.toString("base64") });
await target.set({ type: "automatic" });
expect(target.revision()).toBeNull();
await expect(target.read()).resolves.toBeNull();
});
it("drops the stored image when the project is removed", async () => {
const target = await project();
const stored = await target.set({ type: "upload", data: PNG_1X1.toString("base64") });
await target.remove();
await expect(
readProjectIcon({ paseoHome: target.paseoHome, project: stored }),
).resolves.toBeNull();
});
it.each([
["data that is not an image", Buffer.from("nope"), "Unsupported or invalid icon file"],
["a non-square image", nonSquarePng(), "square image up to 1024×1024"],
["a file over 512 KB", Buffer.alloc(512 * 1024 + 1), "512 KB or smaller"],
])("rejects %s", async (_name, bytes, message) => {
const target = await project();
await expect(target.set({ type: "upload", data: bytes.toString("base64") })).rejects.toThrow(
message,
);
expect(target.revision()).toBeNull();
});
it("serializes concurrent saves for one project", async () => {
const target = await project();
const [first, second] = await Promise.all([
target.set({ type: "upload", data: PNG_1X1.toString("base64") }),
target.set({ type: "automatic" }),
]);
expect(first.customIconRevision).toEqual(expect.any(String));
expect(second.customIconRevision).toBeNull();
await expect(target.read()).resolves.toBeNull();
});
});