Skip to content

Commit b3ad0d0

Browse files
committed
test(webapp): full-stack session-stream e2e over s2-lite
Boots the real webapp plus Postgres, Redis, and s2-lite via testcontainers and drives the session `.out` wire protocol directly: a producer appends records straight to S2 while the client subscribes through the webapp SSE proxy using the real stream-subscription code. Covers basic delivery, multi-turn continuation, resume without duplication, trim and command-record filtering, a quiescent reconnect settling at the tail, and a backlog that reaches the tail delivering every record before the stream closes.
1 parent 6af71a2 commit b3ad0d0

5 files changed

Lines changed: 571 additions & 0 deletions

File tree

.github/workflows/e2e-webapp.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ jobs:
8080
docker pull postgres:14
8181
docker pull redis:7.2
8282
docker pull testcontainers/ryuk:0.11.0
83+
docker pull ghcr.io/s2-streamstore/s2:0.40.0@sha256:b26249e2ede0949755f5af8028185dc2bcfc3aa2db21eb9610543d144eb6ee9d
8384
echo "Image pre-pull complete"
8485
8586
- name: 📥 Download deps
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { AppendInput, AppendRecord, S2 } from "@s2-dev/streamstore";
2+
import { generateJWT } from "@trigger.dev/core/v3/jwt";
3+
import { SSEStreamSubscription } from "@trigger.dev/core/v3";
4+
5+
export type SessionAddressing = {
6+
orgId: string;
7+
envSlug: string;
8+
envId: string;
9+
addressingKey: string;
10+
io?: "out" | "in";
11+
};
12+
13+
/**
14+
* The full, prefixed S2 stream name for a session channel on the shared basin
15+
* (per-org basins disabled), matching `toSessionStreamName` +
16+
* `streamPrefixFor` in the webapp. A test appending with the root S2 token uses
17+
* this literal name.
18+
*/
19+
export function sessionStreamName(p: SessionAddressing): string {
20+
return `org/${p.orgId}/env/${p.envSlug}/${p.envId}/sessions/${p.addressingKey}/${p.io ?? "out"}`;
21+
}
22+
23+
/**
24+
* Mint a session-scoped public access token the way `mintSessionToken.server.ts`
25+
* does: a JWT signed with the environment secret, `sub` = env id, `pub` true,
26+
* scoped to read/write the given addressing key.
27+
*/
28+
export function mintSessionToken(p: {
29+
apiKey: string;
30+
envId: string;
31+
addressingKey: string;
32+
}): Promise<string> {
33+
return generateJWT({
34+
secretKey: p.apiKey,
35+
payload: {
36+
pub: true,
37+
sub: p.envId,
38+
scopes: [`read:sessions:${p.addressingKey}`, `write:sessions:${p.addressingKey}`],
39+
},
40+
expirationTime: "1h",
41+
});
42+
}
43+
44+
/**
45+
* Writes `.out` records straight to S2 (the "agent simulator"). Uses the same
46+
* `@s2-dev/streamstore` primitives the real agent runtime uses, so data,
47+
* `trigger-control` and `trim` command records land in exactly the shapes the
48+
* client + proxy expect.
49+
*/
50+
export class SessionStreamProducer {
51+
private stream;
52+
53+
constructor(p: { endpoint: string; basin: string; streamName: string; accessToken?: string }) {
54+
const s2 = new S2({
55+
accessToken: p.accessToken ?? "ignored",
56+
endpoints: { account: p.endpoint, basin: p.endpoint },
57+
});
58+
this.stream = s2.basin(p.basin).stream(p.streamName);
59+
}
60+
61+
/** Append one data record (`{data, id}` envelope). Returns its seq_num. */
62+
async appendData(data: unknown, id: string): Promise<number> {
63+
const ack = await this.stream.append(
64+
AppendInput.create([AppendRecord.string({ body: JSON.stringify({ data, id }) })])
65+
);
66+
return Number(ack.start.seqNum);
67+
}
68+
69+
/** Append a `trigger-control: turn-complete` record (empty body). */
70+
async appendTurnComplete(publicAccessToken?: string): Promise<number> {
71+
const headers: Array<[string, string]> = [["trigger-control", "turn-complete"]];
72+
if (publicAccessToken) headers.push(["public-access-token", publicAccessToken]);
73+
const ack = await this.stream.append(
74+
AppendInput.create([AppendRecord.string({ body: "", headers })])
75+
);
76+
return Number(ack.start.seqNum);
77+
}
78+
79+
/** Append an S2 `trim` command record, trimming below `earliestSeqNum`. */
80+
async trim(earliestSeqNum: number): Promise<void> {
81+
await this.stream.append(AppendInput.create([AppendRecord.trim(earliestSeqNum)]));
82+
}
83+
}
84+
85+
export type CollectedPart = {
86+
id: string;
87+
chunk: unknown;
88+
headers?: ReadonlyArray<readonly [string, string]>;
89+
};
90+
91+
export function isTurnComplete(part: CollectedPart): boolean {
92+
return (part.headers ?? []).some(([k, v]) => k === "trigger-control" && v === "turn-complete");
93+
}
94+
95+
export type SubscribeOptions = {
96+
baseUrl: string;
97+
addressingKey: string;
98+
token: string;
99+
lastEventId?: string;
100+
timeoutInSeconds?: number;
101+
peekSettled?: boolean;
102+
};
103+
104+
export function subscribeSessionOut(opts: SubscribeOptions): SSEStreamSubscription {
105+
const url = `${opts.baseUrl}/realtime/v1/sessions/${encodeURIComponent(opts.addressingKey)}/out`;
106+
return new SSEStreamSubscription(url, {
107+
headers: {
108+
Authorization: `Bearer ${opts.token}`,
109+
...(opts.peekSettled ? { "X-Peek-Settled": "1" } : {}),
110+
},
111+
timeoutInSeconds: opts.timeoutInSeconds ?? 30,
112+
lastEventId: opts.lastEventId,
113+
maxRetries: 0,
114+
});
115+
}
116+
117+
/**
118+
* Subscribe + drain parts into an array, stopping when `until(parts)` is true,
119+
* the stream closes, or `maxMs` elapses. Cancels the reader on exit. Returns
120+
* the parts plus how many distinct SSE connections/opens the subscription made
121+
* (for asserting the round-trip count on a reconnect).
122+
*/
123+
export async function collectSessionOut(
124+
opts: SubscribeOptions & { until?: (parts: CollectedPart[]) => boolean; maxMs?: number }
125+
): Promise<{ parts: CollectedPart[]; durationMs: number; subscription: SSEStreamSubscription }> {
126+
const subscription = subscribeSessionOut(opts);
127+
const stream = await subscription.subscribe();
128+
const reader = stream.getReader();
129+
const parts: CollectedPart[] = [];
130+
const started = performance.now();
131+
const deadline = started + (opts.maxMs ?? 30_000);
132+
133+
try {
134+
while (true) {
135+
if (opts.until && opts.until(parts)) break;
136+
const remaining = deadline - performance.now();
137+
if (remaining <= 0) break;
138+
const next = await Promise.race([
139+
reader.read(),
140+
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), remaining)),
141+
]);
142+
if (next === "timeout") break;
143+
if (next.done) break;
144+
parts.push(next.value as CollectedPart);
145+
}
146+
} finally {
147+
await reader.cancel().catch(() => {});
148+
}
149+
150+
return { parts, durationMs: performance.now() - started, subscription };
151+
}
152+
153+
/**
154+
* Subscribe + drain while awaiting the client's `caughtUp()`. Resolves once the
155+
* client reports it has drained to the live tail (or `maxMs` elapses). Used by
156+
* the GREEN legs: the drain is what lets the wrapper mark the tail boundary, so
157+
* `parts` holds everything delivered up to the moment caught-up fires — the
158+
* data-loss guard.
159+
*/
160+
export async function collectUntilCaughtUp(opts: SubscribeOptions & { maxMs?: number }): Promise<{
161+
parts: CollectedPart[];
162+
caughtUp: boolean;
163+
tailSeqNum?: number;
164+
settleMs: number;
165+
}> {
166+
const subscription = subscribeSessionOut(opts);
167+
const stream = await subscription.subscribe();
168+
const reader = stream.getReader();
169+
const parts: CollectedPart[] = [];
170+
const started = performance.now();
171+
172+
let caughtUp = false;
173+
let tailSeqNum: number | undefined;
174+
let settleMs = 0;
175+
subscription
176+
.caughtUp()
177+
.then((tail) => {
178+
caughtUp = true;
179+
tailSeqNum = tail.seqNum;
180+
settleMs = performance.now() - started;
181+
})
182+
.catch(() => {});
183+
184+
const deadline = started + (opts.maxMs ?? 30_000);
185+
try {
186+
while (!caughtUp) {
187+
const remaining = deadline - performance.now();
188+
if (remaining <= 0) break;
189+
const next = await Promise.race([
190+
reader.read(),
191+
new Promise<"tick">((r) => setTimeout(() => r("tick"), Math.min(remaining, 250))),
192+
]);
193+
if (next === "tick") continue;
194+
if (next.done) break;
195+
parts.push(next.value as CollectedPart);
196+
}
197+
} finally {
198+
await reader.cancel().catch(() => {});
199+
}
200+
201+
return { parts, caughtUp, tailSeqNum, settleMs: settleMs || performance.now() - started };
202+
}

0 commit comments

Comments
 (0)