Skip to content

Commit 8d91f20

Browse files
committed
test: harden AG-UI schema drift test parsing and guards
Fix skipIf guard to check both canonicalExists and aimockExists. Fix field regex to handle multi-arg Zod types (comma truncation). Add recursive resolveParentFields for multi-level inheritance. Strip comment lines before field regex matching. Parse base fields dynamically from canonical BaseEventSchema and aimock AGUIBaseEvent interface with fallback to hardcoded values.
1 parent 3cc149d commit 8d91f20

1 file changed

Lines changed: 64 additions & 22 deletions

File tree

src/__tests__/drift/agui-schema.drift.ts

Lines changed: 64 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,12 @@ function parseCanonicalEventTypes(source: string): string[] {
4848
* Extract field definitions from a Zod `.extend({...})` block body.
4949
*/
5050
function extractExtendFields(extendBody: string): FieldInfo[] {
51+
// Strip comment lines so they don't match as field definitions
52+
const cleanBody = extendBody.replace(/^\s*\/\/.*$/gm, "");
5153
const fields: FieldInfo[] = [];
52-
for (const fieldMatch of extendBody.matchAll(/(\w+)\s*:\s*([^\n,]+)/g)) {
54+
for (const fieldMatch of cleanBody.matchAll(/(\w+)\s*:\s*(.+)/g)) {
5355
const fieldName = fieldMatch[1];
54-
const fieldDef = fieldMatch[2].trim();
55-
if (fieldDef.startsWith("//")) continue;
56+
const fieldDef = fieldMatch[2].replace(/,\s*$/, "").trim();
5657
const optional = fieldDef.includes(".optional()") || fieldDef.includes(".default(");
5758
fields.push({ name: fieldName, optional });
5859
}
@@ -70,15 +71,30 @@ function extractExtendFields(extendBody: string): FieldInfo[] {
7071
* TextMessageContentEventSchema.omit({...}).extend({...})
7172
* where ThinkingTextMessageContentEventSchema inherits delta from TextMessageContent.
7273
*/
74+
75+
/**
76+
* Parse base fields from `BaseEventSchema = z.object({...})` in canonical source.
77+
* Falls back to hardcoded defaults if parsing fails.
78+
*/
79+
function parseCanonicalBaseFields(source: string): FieldInfo[] {
80+
const baseMatch = source.match(
81+
/export const BaseEventSchema\s*=\s*z\s*\.\s*object\(\{([\s\S]*?)\}\)/,
82+
);
83+
if (!baseMatch) {
84+
return [
85+
{ name: "type", optional: false },
86+
{ name: "timestamp", optional: true },
87+
{ name: "rawEvent", optional: true },
88+
];
89+
}
90+
return extractExtendFields(baseMatch[1]);
91+
}
92+
7393
function parseCanonicalSchemas(source: string): Map<string, SchemaInfo> {
7494
const schemas = new Map<string, SchemaInfo>();
7595

76-
// Base event fields (always inherited)
77-
const baseFields: FieldInfo[] = [
78-
{ name: "type", optional: false },
79-
{ name: "timestamp", optional: true },
80-
{ name: "rawEvent", optional: true },
81-
];
96+
// Parse base event fields dynamically from BaseEventSchema
97+
const baseFields = parseCanonicalBaseFields(source);
8298

8399
// Pass 1: collect raw schema definitions keyed by schema name
84100
interface RawSchema {
@@ -123,6 +139,14 @@ function parseCanonicalSchemas(source: string): Map<string, SchemaInfo> {
123139
fieldsBySchemaName.set(schemaName, ownFields);
124140
}
125141

142+
// Recursive parent field resolver for multi-level inheritance chains
143+
function resolveParentFields(schemaName: string): FieldInfo[] {
144+
const entry = rawSchemas.get(schemaName);
145+
if (!entry) return [];
146+
const parentFields = entry.parentSchemaName ? resolveParentFields(entry.parentSchemaName) : [];
147+
return [...parentFields, ...(fieldsBySchemaName.get(schemaName) || [])];
148+
}
149+
126150
// Pass 2: resolve full field sets with parent inheritance
127151
for (const [, raw] of rawSchemas) {
128152
const fields = new Map<string, FieldInfo>();
@@ -132,13 +156,10 @@ function parseCanonicalSchemas(source: string): Map<string, SchemaInfo> {
132156
fields.set(f.name, { ...f });
133157
}
134158

135-
// If there's a parent schema (not BaseEventSchema), inherit its extend fields
159+
// Resolve full parent chain (handles multi-level inheritance)
136160
if (raw.parentSchemaName) {
137-
const parentFields = fieldsBySchemaName.get(raw.parentSchemaName);
138-
if (parentFields) {
139-
for (const f of parentFields) {
140-
fields.set(f.name, { ...f });
141-
}
161+
for (const f of resolveParentFields(raw.parentSchemaName)) {
162+
fields.set(f.name, { ...f });
142163
}
143164
}
144165

@@ -179,9 +200,34 @@ function parseAimockEventTypes(source: string): string[] {
179200
return members;
180201
}
181202

203+
/**
204+
* Parse base fields from `AGUIBaseEvent` interface in aimock source.
205+
* Falls back to hardcoded defaults if parsing fails.
206+
*/
207+
function parseAimockBaseFields(source: string): FieldInfo[] {
208+
const baseMatch = source.match(/export interface AGUIBaseEvent\s*\{([\s\S]*?)\}/);
209+
if (!baseMatch) {
210+
return [
211+
{ name: "type", optional: false },
212+
{ name: "timestamp", optional: true },
213+
{ name: "rawEvent", optional: true },
214+
];
215+
}
216+
const fields: FieldInfo[] = [];
217+
for (const fieldMatch of baseMatch[1].matchAll(/(\w+)(\??)\s*:\s*([^;]+);/g)) {
218+
const fieldName = fieldMatch[1];
219+
const optional = fieldMatch[2] === "?";
220+
fields.push({ name: fieldName, optional });
221+
}
222+
return fields;
223+
}
224+
182225
function parseAimockInterfaces(source: string): Map<string, SchemaInfo> {
183226
const interfaces = new Map<string, SchemaInfo>();
184227

228+
// Parse base fields dynamically from AGUIBaseEvent interface
229+
const baseFields = parseAimockBaseFields(source);
230+
185231
// Match interface blocks
186232
const interfacePattern = /export interface AGUI(\w+Event)\s+extends\s+\w+\s*\{([\s\S]*?)\}/g;
187233

@@ -193,12 +239,8 @@ function parseAimockInterfaces(source: string): Map<string, SchemaInfo> {
193239
if (!typeMatch) continue;
194240
const eventType = typeMatch[1];
195241

196-
// Start with base fields (all extend AGUIBaseEvent)
197-
const fields: FieldInfo[] = [
198-
{ name: "type", optional: false },
199-
{ name: "timestamp", optional: true },
200-
{ name: "rawEvent", optional: true },
201-
];
242+
// Start with dynamically-parsed base fields
243+
const fields: FieldInfo[] = baseFields.map((f) => ({ ...f }));
202244

203245
// Parse fields from the interface body
204246
for (const fieldMatch of body.matchAll(/(\w+)(\??)\s*:\s*([^;]+);/g)) {
@@ -235,7 +277,7 @@ interface DriftItem {
235277
const canonicalExists = fs.existsSync(CANONICAL_EVENTS_PATH);
236278
const aimockExists = fs.existsSync(AIMOCK_TYPES_PATH);
237279

238-
describe.skipIf(!canonicalExists)("AG-UI schema drift", () => {
280+
describe.skipIf(!canonicalExists || !aimockExists)("AG-UI schema drift", () => {
239281
let canonicalSource: string;
240282
let aimockSource: string;
241283
let canonicalTypes: string[];

0 commit comments

Comments
 (0)