Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/code/src/renderer/desktop-contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { autoresearchCoreModule } from "@posthog/core/autoresearch/autoresearch.
import { billingCoreModule } from "@posthog/core/billing/billing.module";
import { inboxCoreModule } from "@posthog/core/inbox/inbox.module";
import { githubConnectModule } from "@posthog/core/integrations/githubConnect.module";
import { notebooksCoreModule } from "@posthog/core/notebooks/notebooks.module";
import { onboardingModule } from "@posthog/core/onboarding/onboarding.module";
import { setupCoreModule } from "@posthog/core/setup/setup.module";
import { skillsCoreModule } from "@posthog/core/skills/skills.module";
Expand Down Expand Up @@ -44,6 +45,7 @@ export function registerDesktopContributions(): void {
focusUiModule,
githubConnectModule,
inboxCoreModule,
notebooksCoreModule,
notificationsUiModule,
onboardingModule,
provisioningUiModule,
Expand Down
282 changes: 282 additions & 0 deletions packages/api-client/src/posthog-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1696,3 +1696,285 @@ describe("PostHogAPIClient", () => {
});
});
});

describe("PostHogAPIClient.notebookMarkdownSave", () => {
function makeClient(fetch: ReturnType<typeof vi.fn>) {
const client = new PostHogAPIClient(
"http://localhost:8000",
async () => "token",
async () => "token",
123,
);
(
client as unknown as {
api: { baseUrl: string; fetcher: { fetch: typeof fetch } };
}
).api = { baseUrl: "http://localhost:8000", fetcher: { fetch } };
return client;
}

const saveBody = {
client_id: "client-1",
version: 3,
content: { type: "doc", content: [] },
text_content: "# Hi",
};

it("maps 200 to saved with the updated notebook", async () => {
const updated = { short_id: "abc123", version: 4 };
const fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => updated,
});
const client = makeClient(fetch);

const result = await client.notebookMarkdownSave("abc123", saveBody);

expect(result).toEqual({ status: "saved", notebook: updated });
const call = fetch.mock.calls[0][0];
expect(call.method).toBe("post");
expect(call.path).toBe(
"/api/projects/123/notebooks/abc123/collab/markdown_save/",
);
expect(JSON.parse(call.overrides.body)).toEqual(saveBody);
});

it("maps 409 to conflict with the server version and missed updates", async () => {
const conflictBody = {
version: 5,
updates: [
{ version: 4, diff: [{ start: 0, end: 1, text: "x" }], base_crc: null },
],
};
const fetch = vi.fn().mockResolvedValue({
ok: false,
status: 409,
json: async () => conflictBody,
});
const client = makeClient(fetch);

const result = await client.notebookMarkdownSave("abc123", saveBody);

expect(result).toEqual({
status: "conflict",
serverVersion: 5,
updates: conflictBody.updates,
});
});

it("maps 410 to gone", async () => {
const fetch = vi.fn().mockResolvedValue({
ok: false,
status: 410,
json: async () => ({}),
});
const client = makeClient(fetch);

await expect(
client.notebookMarkdownSave("abc123", saveBody),
).resolves.toEqual({ status: "gone" });
});

// The real shared fetcher (buildApiFetcher) throws on any non-2xx instead
// of returning the response — these cover the recovery path it exercises.
it("recovers a conflict from the fetcher's thrown 409 error", async () => {
const conflictBody = {
version: 7,
updates: [
{ version: 6, diff: [{ start: 2, end: 2, text: "y" }], base_crc: 42 },
],
};
const fetch = vi
.fn()
.mockRejectedValue(
new Error(`Failed request: [409] ${JSON.stringify(conflictBody)}`),
);
const client = makeClient(fetch);

await expect(
client.notebookMarkdownSave("abc123", saveBody),
).resolves.toEqual({
status: "conflict",
serverVersion: 7,
updates: conflictBody.updates,
});
});

it("recovers gone from the fetcher's thrown 410 error", async () => {
const fetch = vi
.fn()
.mockRejectedValue(new Error('Failed request: [410] {"detail":"gone"}'));
const client = makeClient(fetch);

await expect(
client.notebookMarkdownSave("abc123", saveBody),
).resolves.toEqual({ status: "gone" });
});

it("rethrows non-protocol thrown failures", async () => {
const fetch = vi
.fn()
.mockRejectedValue(new Error('Failed request: [500] {"error":"boom"}'));
const client = makeClient(fetch);

await expect(
client.notebookMarkdownSave("abc123", saveBody),
).rejects.toThrow("[500]");
});

it("throws on any other failure status", async () => {
const fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: "Internal Server Error",
json: async () => ({}),
});
const client = makeClient(fetch);

await expect(
client.notebookMarkdownSave("abc123", saveBody),
).rejects.toThrow("Failed to save notebook");
});
});

describe("PostHogAPIClient.notebookCollabStream", () => {
function makeClient(fetch: ReturnType<typeof vi.fn>) {
const client = new PostHogAPIClient(
"http://localhost:8000",
async () => "token",
async () => "token",
123,
);
(
client as unknown as {
api: { baseUrl: string; fetcher: { fetch: typeof fetch } };
}
).api = { baseUrl: "http://localhost:8000", fetcher: { fetch } };
return client;
}

function sseResponse(chunks: string[]): Response {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}

it("parses SSE frames and passes the Last-Event-ID header", async () => {
const fetch = vi.fn().mockResolvedValue(
sseResponse([
": keepalive\n\n",
'id: 12-1\nevent: update\ndata: {"type":"update",\ndata: "version":12}\n\n',
// Frame split across chunks: the parser must buffer partial lines.
'event: presence\ndata: {"type":"pres',
'ence"}\n\n',
]),
);
const client = makeClient(fetch);

const events: { id?: string; event: string; data: string }[] = [];
await client.notebookCollabStream("abc123", {
lastEventId: "11-0",
signal: new AbortController().signal,
onEvent: (event) => events.push(event),
});

expect(fetch).toHaveBeenCalledWith(
expect.objectContaining({
method: "get",
path: "/api/projects/123/notebooks/abc123/collab/stream",
parameters: {
header: {
Accept: "text/event-stream",
"Last-Event-ID": "11-0",
},
},
}),
);
// The keepalive comment produced no event; multi-line data joined with \n;
// presence frames have no id.
expect(events).toEqual([
{ id: "12-1", event: "update", data: '{"type":"update",\n"version":12}' },
{ id: undefined, event: "presence", data: '{"type":"presence"}' },
]);
});

it("omits the Last-Event-ID header on a fresh connection", async () => {
const fetch = vi.fn().mockResolvedValue(sseResponse([]));
const client = makeClient(fetch);

await client.notebookCollabStream("abc123", {
signal: new AbortController().signal,
onEvent: () => {},
});

expect(fetch.mock.calls[0][0].parameters.header).toEqual({
Accept: "text/event-stream",
});
});

it("resolves when the stream ends and discards a trailing partial frame", async () => {
const fetch = vi.fn().mockResolvedValue(
sseResponse([
'id: 3-1\nevent: update\ndata: {"version":3}\n\n',
// No terminating blank line: incomplete, discarded per the SSE spec.
"id: 4-1\nevent: update",
]),
);
const client = makeClient(fetch);

const events: { id?: string; event: string; data: string }[] = [];
await client.notebookCollabStream("abc123", {
signal: new AbortController().signal,
onEvent: (event) => events.push(event),
});

expect(events).toEqual([
{ id: "3-1", event: "update", data: '{"version":3}' },
]);
});
});

describe("PostHogAPIClient.notebookPublishPresence", () => {
it("posts the caret to the collab presence endpoint", async () => {
const fetch = vi
.fn()
.mockResolvedValue(new Response(null, { status: 204 }));
const client = new PostHogAPIClient(
"http://localhost:8000",
async () => "token",
async () => "token",
123,
);
(
client as unknown as {
api: { baseUrl: string; fetcher: { fetch: typeof fetch } };
}
).api = { baseUrl: "http://localhost:8000", fetcher: { fetch } };

const body = {
client_id: "client-1",
version: 3,
cursor: { node_index: 1, offset: 2 },
};
await client.notebookPublishPresence("abc123", body);

expect(fetch).toHaveBeenCalledWith(
expect.objectContaining({
method: "post",
path: "/api/projects/123/notebooks/abc123/collab/presence/",
overrides: { body: JSON.stringify(body) },
}),
);
});
});
Loading