chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
// Plan F.3: integration test that round-trips a `ChatSnapshotV1` blob
|
||||
// through the SDK's snapshot helpers + a real MinIO backing store. Mirrors
|
||||
// the testcontainer pattern from `objectStore.test.ts`.
|
||||
//
|
||||
// What this verifies end-to-end:
|
||||
// - SDK's `writeChatSnapshot` calls `apiClient.createUploadPayloadUrl`
|
||||
// to mint a presigned PUT, then PUTs JSON to it.
|
||||
// - SDK's `readChatSnapshot` calls `apiClient.getPayloadUrl` to mint a
|
||||
// presigned GET, then fetches and parses.
|
||||
// - The webapp's `generatePresignedUrl` produces URLs MinIO accepts.
|
||||
// - The blob round-trips with `version: 1` shape preserved.
|
||||
// - 404 (no snapshot for a fresh session) returns `undefined`, not an
|
||||
// error.
|
||||
//
|
||||
// This is the integration safety net behind the unit tests in
|
||||
// `packages/trigger-sdk/test/chat-snapshot.test.ts` — those tests mock
|
||||
// `fetch`; this one drives a real S3-compatible backend.
|
||||
|
||||
import { postgresAndMinioTest } from "@internal/testcontainers";
|
||||
import { apiClientManager } from "@trigger.dev/core/v3";
|
||||
import {
|
||||
__readChatSnapshotProductionPathForTests as readChatSnapshot,
|
||||
__writeChatSnapshotProductionPathForTests as writeChatSnapshot,
|
||||
type ChatSnapshotV1,
|
||||
} from "@trigger.dev/sdk/ai";
|
||||
import type { UIMessage } from "ai";
|
||||
import { afterEach, describe, expect, vi } from "vitest";
|
||||
import { env } from "~/env.server";
|
||||
import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server";
|
||||
import { generatePresignedUrl } from "~/v3/objectStore.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function makeSnapshot(
|
||||
opts: { messages?: UIMessage[]; lastOutEventId?: string } = {}
|
||||
): ChatSnapshotV1 {
|
||||
return {
|
||||
version: 1,
|
||||
savedAt: 1_700_000_000_000,
|
||||
messages: opts.messages ?? [
|
||||
{
|
||||
id: "u-1",
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
{
|
||||
id: "a-1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "world" }],
|
||||
},
|
||||
],
|
||||
lastOutEventId: opts.lastOutEventId ?? "evt-42",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub `apiClientManager.clientOrThrow()` so the SDK helpers see a fake
|
||||
* api client. Mirrors the snapshot-url route: derive the canonical
|
||||
* `sessions/{id}/snapshot.json` key (with optional default-protocol
|
||||
* prefix) and sign it via `generatePresignedUrl` against MinIO.
|
||||
*/
|
||||
function stubApiClient(opts: { projectRef: string; envSlug: string }) {
|
||||
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
|
||||
async getChatSnapshotUrl(sessionId: string) {
|
||||
const key = chatSnapshotStoragePathForSession(sessionId);
|
||||
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "GET");
|
||||
if (!result.success) throw new Error(result.error);
|
||||
return { presignedUrl: result.url };
|
||||
},
|
||||
async createChatSnapshotUploadUrl(sessionId: string) {
|
||||
const key = chatSnapshotStoragePathForSession(sessionId);
|
||||
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "PUT");
|
||||
if (!result.success) throw new Error(result.error);
|
||||
return { presignedUrl: result.url };
|
||||
},
|
||||
} as never);
|
||||
}
|
||||
|
||||
// Suppress noisy warnings from logger.warn during error-path tests.
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
warnSpy?.mockRestore();
|
||||
});
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("chat snapshot integration (MinIO + SDK helpers)", () => {
|
||||
postgresAndMinioTest("round-trips a snapshot through real MinIO", async ({ minioConfig }) => {
|
||||
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
|
||||
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
|
||||
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
|
||||
env.OBJECT_STORE_REGION = minioConfig.region;
|
||||
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
|
||||
|
||||
stubApiClient({ projectRef: "proj_snap_rt", envSlug: "dev" });
|
||||
|
||||
const sessionId = "sess_round_trip_1";
|
||||
const snapshot = makeSnapshot();
|
||||
|
||||
// Write through the SDK helper — should land in MinIO at
|
||||
// `packets/proj_snap_rt/dev/sessions/sess_round_trip_1/snapshot.json`.
|
||||
await writeChatSnapshot(sessionId, snapshot);
|
||||
|
||||
// Read back through the SDK helper — should reconstruct the original.
|
||||
const result = await readChatSnapshot(sessionId);
|
||||
|
||||
expect(result).toEqual(snapshot);
|
||||
});
|
||||
|
||||
postgresAndMinioTest(
|
||||
"returns undefined for a fresh session with no snapshot",
|
||||
async ({ minioConfig }) => {
|
||||
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
|
||||
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
|
||||
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
|
||||
env.OBJECT_STORE_REGION = minioConfig.region;
|
||||
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
|
||||
|
||||
stubApiClient({ projectRef: "proj_snap_404", envSlug: "dev" });
|
||||
|
||||
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
// Session never had a snapshot written — read returns undefined.
|
||||
const result = await readChatSnapshot("sess_never_existed");
|
||||
expect(result).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
postgresAndMinioTest(
|
||||
"overwrites a prior snapshot in place (single-writer)",
|
||||
async ({ minioConfig }) => {
|
||||
// The runtime guarantees one attempt alive at a time, and
|
||||
// `writeChatSnapshot` runs awaited after `onTurnComplete`. Verify
|
||||
// that a second write to the same key replaces the first cleanly —
|
||||
// the read-after-write reflects the latest blob.
|
||||
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
|
||||
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
|
||||
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
|
||||
env.OBJECT_STORE_REGION = minioConfig.region;
|
||||
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
|
||||
|
||||
stubApiClient({ projectRef: "proj_snap_overwrite", envSlug: "dev" });
|
||||
|
||||
const sessionId = "sess_overwrite";
|
||||
|
||||
const turn1 = makeSnapshot({
|
||||
messages: [{ id: "u-1", role: "user", parts: [{ type: "text", text: "first" }] }],
|
||||
lastOutEventId: "evt-turn1",
|
||||
});
|
||||
const turn2 = makeSnapshot({
|
||||
messages: [
|
||||
{ id: "u-1", role: "user", parts: [{ type: "text", text: "first" }] },
|
||||
{ id: "a-1", role: "assistant", parts: [{ type: "text", text: "reply-1" }] },
|
||||
{ id: "u-2", role: "user", parts: [{ type: "text", text: "second" }] },
|
||||
{ id: "a-2", role: "assistant", parts: [{ type: "text", text: "reply-2" }] },
|
||||
],
|
||||
lastOutEventId: "evt-turn2",
|
||||
});
|
||||
|
||||
await writeChatSnapshot(sessionId, turn1);
|
||||
await writeChatSnapshot(sessionId, turn2);
|
||||
|
||||
const result = await readChatSnapshot(sessionId);
|
||||
expect(result).toEqual(turn2);
|
||||
expect(result?.messages).toHaveLength(4);
|
||||
expect(result?.lastOutEventId).toBe("evt-turn2");
|
||||
}
|
||||
);
|
||||
|
||||
postgresAndMinioTest(
|
||||
"isolates snapshots by sessionId (no cross-talk)",
|
||||
async ({ minioConfig }) => {
|
||||
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
|
||||
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
|
||||
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
|
||||
env.OBJECT_STORE_REGION = minioConfig.region;
|
||||
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
|
||||
|
||||
stubApiClient({ projectRef: "proj_snap_iso", envSlug: "dev" });
|
||||
|
||||
const sessA = "sess_iso_A";
|
||||
const sessB = "sess_iso_B";
|
||||
const snapA = makeSnapshot({ lastOutEventId: "evt-A" });
|
||||
const snapB = makeSnapshot({ lastOutEventId: "evt-B" });
|
||||
|
||||
await writeChatSnapshot(sessA, snapA);
|
||||
await writeChatSnapshot(sessB, snapB);
|
||||
|
||||
const readA = await readChatSnapshot(sessA);
|
||||
const readB = await readChatSnapshot(sessB);
|
||||
|
||||
expect(readA?.lastOutEventId).toBe("evt-A");
|
||||
expect(readB?.lastOutEventId).toBe("evt-B");
|
||||
// Distinct objects — modifying one shouldn't affect the other.
|
||||
expect(readA?.lastOutEventId).not.toBe(readB?.lastOutEventId);
|
||||
}
|
||||
);
|
||||
|
||||
postgresAndMinioTest(
|
||||
"handles snapshots with large message lists (~50 messages)",
|
||||
async ({ minioConfig }) => {
|
||||
// Stress test: a 50-turn chat snapshot. Plan F.4 mentions the
|
||||
// pre-change baseline grew past 512 KiB around turn 10-30 with tool
|
||||
// use; the post-slim wire keeps wire payloads small but the snapshot
|
||||
// itself can still get large. Verify the helpers handle a realistic
|
||||
// payload size.
|
||||
env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl;
|
||||
env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId;
|
||||
env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey;
|
||||
env.OBJECT_STORE_REGION = minioConfig.region;
|
||||
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
|
||||
|
||||
stubApiClient({ projectRef: "proj_snap_big", envSlug: "dev" });
|
||||
|
||||
const messages: UIMessage[] = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
messages.push({
|
||||
id: `u-${i}`,
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: `user message ${i}: ${"x".repeat(200)}` }],
|
||||
});
|
||||
messages.push({
|
||||
id: `a-${i}`,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: `assistant reply ${i}: ${"y".repeat(500)}` }],
|
||||
});
|
||||
}
|
||||
const snapshot = makeSnapshot({ messages, lastOutEventId: "evt-50" });
|
||||
|
||||
await writeChatSnapshot("sess_big_chat", snapshot);
|
||||
const result = await readChatSnapshot("sess_big_chat");
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.messages).toHaveLength(100);
|
||||
expect(result!.lastOutEventId).toBe("evt-50");
|
||||
// Spot-check ordering integrity — the messages array round-tripped
|
||||
// in the same order.
|
||||
expect(result!.messages[0]!.id).toBe("u-0");
|
||||
expect(result!.messages[99]!.id).toBe("a-49");
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user