@node-llm/testing 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +541 -0
  3. package/dist/Mocker.d.ts +58 -0
  4. package/dist/Mocker.d.ts.map +1 -0
  5. package/dist/Mocker.js +247 -0
  6. package/dist/Scrubber.d.ts +18 -0
  7. package/dist/Scrubber.d.ts.map +1 -0
  8. package/dist/Scrubber.js +68 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +2 -0
  12. package/dist/vcr.d.ts +57 -0
  13. package/dist/vcr.d.ts.map +1 -0
  14. package/dist/vcr.js +291 -0
  15. package/package.json +19 -0
  16. package/src/Mocker.ts +311 -0
  17. package/src/Scrubber.ts +85 -0
  18. package/src/index.ts +2 -0
  19. package/src/vcr.ts +377 -0
  20. package/test/cassettes/custom-scrub-config.json +33 -0
  21. package/test/cassettes/defaults-plus-custom.json +33 -0
  22. package/test/cassettes/explicit-sugar-test.json +33 -0
  23. package/test/cassettes/feature-1-vcr.json +33 -0
  24. package/test/cassettes/global-config-keys.json +33 -0
  25. package/test/cassettes/global-config-merge.json +33 -0
  26. package/test/cassettes/global-config-patterns.json +33 -0
  27. package/test/cassettes/global-config-reset.json +33 -0
  28. package/test/cassettes/global-config-test.json +33 -0
  29. package/test/cassettes/streaming-chunks.json +18 -0
  30. package/test/cassettes/testunitdxtestts-vcr-feature-5-6-dx-sugar-auto-naming-automatically-names-and-records-cassettes.json +33 -0
  31. package/test/cassettes/vcr-feature-5-6-dx-sugar-auto-naming-automatically-names-and-records-cassettes.json +28 -0
  32. package/test/cassettes/vcr-streaming.json +17 -0
  33. package/test/helpers/MockProvider.ts +75 -0
  34. package/test/unit/ci.test.ts +36 -0
  35. package/test/unit/dx.test.ts +86 -0
  36. package/test/unit/mocker-debug.test.ts +68 -0
  37. package/test/unit/mocker.test.ts +46 -0
  38. package/test/unit/multimodal.test.ts +46 -0
  39. package/test/unit/scoping.test.ts +54 -0
  40. package/test/unit/scrubbing.test.ts +110 -0
  41. package/test/unit/streaming.test.ts +51 -0
  42. package/test/unit/strict-mode.test.ts +112 -0
  43. package/test/unit/tools.test.ts +58 -0
  44. package/test/unit/vcr-global-config.test.ts +87 -0
  45. package/test/unit/vcr-mismatch.test.ts +172 -0
  46. package/test/unit/vcr-passthrough.test.ts +68 -0
  47. package/test/unit/vcr-streaming.test.ts +86 -0
  48. package/test/unit/vcr.test.ts +34 -0
  49. package/tsconfig.json +9 -0
  50. package/vitest.config.ts +12 -0
package/dist/Mocker.js ADDED
@@ -0,0 +1,247 @@
1
+ import { providerRegistry } from "@node-llm/core";
2
+ const EXECUTION_METHODS = ["chat", "stream", "paint", "transcribe", "moderate", "embed"];
3
+ export class Mocker {
4
+ mocks = [];
5
+ strict = false;
6
+ constructor() {
7
+ this.setupInterceptor();
8
+ }
9
+ chat(query) {
10
+ return this.addMock("chat", (req) => {
11
+ const chatReq = req;
12
+ if (!query)
13
+ return true;
14
+ const lastMessage = [...chatReq.messages].reverse().find((m) => m.role === "user");
15
+ if (!lastMessage)
16
+ return false;
17
+ const content = this.getContentString(lastMessage.content);
18
+ if (typeof query === "string")
19
+ return content === query;
20
+ if (query instanceof RegExp)
21
+ return typeof content === "string" && query.test(content);
22
+ return false;
23
+ });
24
+ }
25
+ stream(chunks) {
26
+ const lastMock = this.mocks[this.mocks.length - 1];
27
+ if (!lastMock || (lastMock.method !== "chat" && lastMock.method !== "stream")) {
28
+ throw new Error("Mocker: .stream() must follow a .chat() or .addMock('stream') definition.");
29
+ }
30
+ lastMock.method = "stream";
31
+ lastMock.response = { chunks };
32
+ return this;
33
+ }
34
+ placeholder(query) {
35
+ return this.addMock("chat", (req) => {
36
+ const chatReq = req;
37
+ return chatReq.messages.some((m) => {
38
+ const content = this.getContentString(m.content);
39
+ if (typeof query === "string")
40
+ return content === query;
41
+ return typeof content === "string" && query.test(content);
42
+ });
43
+ });
44
+ }
45
+ callsTool(name, args = {}) {
46
+ const lastMock = this.mocks[this.mocks.length - 1];
47
+ if (!lastMock || lastMock.method !== "chat") {
48
+ throw new Error("Mocker: .callsTool() must follow a .chat() definition.");
49
+ }
50
+ lastMock.response = {
51
+ content: null,
52
+ tool_calls: [
53
+ {
54
+ id: `call_${Math.random().toString(36).slice(2, 9)}`,
55
+ type: "function",
56
+ function: { name, arguments: JSON.stringify(args) }
57
+ }
58
+ ],
59
+ finish_reason: "tool_calls"
60
+ };
61
+ return this;
62
+ }
63
+ embed(input) {
64
+ return this.addMock("embed", (req) => {
65
+ const embReq = req;
66
+ if (!input)
67
+ return true;
68
+ return JSON.stringify(embReq.input) === JSON.stringify(input);
69
+ });
70
+ }
71
+ paint(prompt) {
72
+ return this.addMock("paint", (req) => {
73
+ const paintReq = req;
74
+ if (!prompt)
75
+ return true;
76
+ if (typeof prompt === "string")
77
+ return paintReq.prompt === prompt;
78
+ return prompt.test(paintReq.prompt);
79
+ });
80
+ }
81
+ transcribe(file) {
82
+ return this.addMock("transcribe", (req) => {
83
+ const transReq = req;
84
+ if (!file)
85
+ return true;
86
+ if (typeof file === "string")
87
+ return transReq.file === file;
88
+ return file.test(transReq.file);
89
+ });
90
+ }
91
+ moderate(input) {
92
+ return this.addMock("moderate", (req) => {
93
+ const modReq = req;
94
+ if (!input)
95
+ return true;
96
+ const content = Array.isArray(modReq.input) ? modReq.input.join(" ") : modReq.input;
97
+ if (input instanceof RegExp)
98
+ return input.test(content);
99
+ return JSON.stringify(modReq.input) === JSON.stringify(input);
100
+ });
101
+ }
102
+ respond(response) {
103
+ const lastMock = this.mocks[this.mocks.length - 1];
104
+ if (!lastMock)
105
+ throw new Error("Mocker: No mock definition started.");
106
+ if (typeof response === "string") {
107
+ lastMock.response = { content: response, text: response };
108
+ }
109
+ else {
110
+ lastMock.response = response;
111
+ }
112
+ return this;
113
+ }
114
+ /**
115
+ * Returns debug information about defined mocks.
116
+ * Useful for troubleshooting what mocks are defined.
117
+ */
118
+ getDebugInfo() {
119
+ const methods = this.mocks.map((m) => m.method);
120
+ return {
121
+ totalMocks: this.mocks.length,
122
+ methods: [...new Set(methods)]
123
+ };
124
+ }
125
+ clear() {
126
+ this.mocks = [];
127
+ providerRegistry.setInterceptor(undefined);
128
+ }
129
+ addMock(method, matcher) {
130
+ this.mocks.push({ method, match: matcher, response: { content: "Mock response" } });
131
+ return this;
132
+ }
133
+ getContentString(content) {
134
+ if (content === null || content === undefined)
135
+ return null;
136
+ if (typeof content === "string")
137
+ return content;
138
+ if (Array.isArray(content)) {
139
+ return content.map((part) => (part.type === "text" ? part.text : "")).join("");
140
+ }
141
+ return String(content);
142
+ }
143
+ setupInterceptor() {
144
+ providerRegistry.setInterceptor((provider) => {
145
+ return new Proxy(provider, {
146
+ get: (target, prop) => {
147
+ const originalValue = Reflect.get(target, prop);
148
+ const methodName = prop.toString();
149
+ if (methodName === "id")
150
+ return target.id;
151
+ if (EXECUTION_METHODS.includes(methodName)) {
152
+ if (methodName === "stream") {
153
+ return async function* (request) {
154
+ const matchingMocks = this.mocks.filter((m) => m.method === methodName && m.match(request));
155
+ const mock = matchingMocks[matchingMocks.length - 1];
156
+ if (mock) {
157
+ const res = typeof mock.response === "function" ? mock.response(request) : mock.response;
158
+ if (res.error)
159
+ throw res.error;
160
+ const chunks = res.chunks || [];
161
+ for (let i = 0; i < chunks.length; i++) {
162
+ const chunk = chunks[i];
163
+ yield typeof chunk === "string"
164
+ ? { content: chunk, done: i === chunks.length - 1 }
165
+ : chunk;
166
+ }
167
+ return;
168
+ }
169
+ if (this.strict)
170
+ throw new Error("Mocker: Unexpected LLM call to 'stream'");
171
+ const original = originalValue
172
+ ? originalValue.apply(target, [request])
173
+ : undefined;
174
+ if (original)
175
+ yield* original;
176
+ }.bind(this);
177
+ }
178
+ // Promise-based methods
179
+ return (async (request) => {
180
+ const matchingMocks = this.mocks.filter((m) => m.method === methodName && m.match(request));
181
+ const mock = matchingMocks[matchingMocks.length - 1];
182
+ if (mock) {
183
+ const res = typeof mock.response === "function" ? mock.response(request) : mock.response;
184
+ if (res.error)
185
+ throw res.error;
186
+ switch (methodName) {
187
+ case "chat": {
188
+ return {
189
+ content: res.content !== undefined && res.content !== null
190
+ ? String(res.content)
191
+ : null,
192
+ tool_calls: res.tool_calls || [],
193
+ usage: res.usage || { input_tokens: 10, output_tokens: 10, total_tokens: 20 },
194
+ finish_reason: res.finish_reason || (res.tool_calls?.length ? "tool_calls" : "stop")
195
+ };
196
+ }
197
+ case "embed": {
198
+ const embReq = request;
199
+ return {
200
+ vectors: res.vectors || [[0.1, 0.2, 0.3]],
201
+ model: embReq.model || "mock-embed",
202
+ input_tokens: 10,
203
+ dimensions: res.vectors?.[0]?.length || 3
204
+ };
205
+ }
206
+ case "paint": {
207
+ const paintReq = request;
208
+ return {
209
+ url: res.url || "http://mock.com/image.png",
210
+ revised_prompt: res.revised_prompt || paintReq.prompt
211
+ };
212
+ }
213
+ case "transcribe": {
214
+ const transReq = request;
215
+ return {
216
+ text: res.text || "Mock transcript",
217
+ model: transReq.model || "mock-whisper"
218
+ };
219
+ }
220
+ case "moderate": {
221
+ const modReq = request;
222
+ return {
223
+ id: res.id || "mod-123",
224
+ model: modReq.model || "mock-mod",
225
+ results: res.results || [
226
+ { flagged: false, categories: {}, category_scores: {} }
227
+ ]
228
+ };
229
+ }
230
+ default:
231
+ return res;
232
+ }
233
+ }
234
+ if (this.strict)
235
+ throw new Error(`Mocker: Unexpected LLM call to '${methodName}'`);
236
+ return originalValue ? originalValue.apply(target, [request]) : undefined;
237
+ }).bind(this);
238
+ }
239
+ return originalValue;
240
+ }
241
+ });
242
+ });
243
+ }
244
+ }
245
+ export function mockLLM() {
246
+ return new Mocker();
247
+ }
@@ -0,0 +1,18 @@
1
+ export declare const DEFAULT_SECRET_PATTERNS: RegExp[];
2
+ export interface ScrubberOptions {
3
+ customScrubber?: (data: unknown) => unknown;
4
+ sensitivePatterns?: RegExp[];
5
+ sensitiveKeys?: string[];
6
+ }
7
+ export declare class Scrubber {
8
+ private customScrubber?;
9
+ private sensitivePatterns;
10
+ private sensitiveKeys;
11
+ constructor(options?: ScrubberOptions);
12
+ /**
13
+ * Applies scrubbing to the data using default patterns and custom logic.
14
+ */
15
+ scrub(data: unknown): unknown;
16
+ private deepScrub;
17
+ }
18
+ //# sourceMappingURL=Scrubber.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Scrubber.d.ts","sourceRoot":"","sources":["../src/Scrubber.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,uBAAuB,UAInC,CAAC;AAEF,MAAM,WAAW,eAAe;IAC9B,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC;IAC5C,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC7B,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,qBAAa,QAAQ;IACnB,OAAO,CAAC,cAAc,CAAC,CAA6B;IACpD,OAAO,CAAC,iBAAiB,CAAW;IACpC,OAAO,CAAC,aAAa,CAAc;gBAEvB,OAAO,GAAE,eAAoB;IAazC;;OAEG;IACI,KAAK,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO;IAYpC,OAAO,CAAC,SAAS;CAuClB"}
@@ -0,0 +1,68 @@
1
+ export const DEFAULT_SECRET_PATTERNS = [
2
+ /sk-[a-zA-Z0-9]{20,}/g, // OpenAI/Anthropic likely patterns
3
+ /x-[a-zA-Z0-9]{20,}/g, // Generic API keys
4
+ /[a-zA-Z0-9]{32,}/g // Long hashes/keys
5
+ ];
6
+ export class Scrubber {
7
+ customScrubber;
8
+ sensitivePatterns;
9
+ sensitiveKeys;
10
+ constructor(options = {}) {
11
+ this.customScrubber = options.customScrubber;
12
+ this.sensitivePatterns = [...DEFAULT_SECRET_PATTERNS, ...(options.sensitivePatterns || [])];
13
+ this.sensitiveKeys = new Set([
14
+ "key",
15
+ "api_key",
16
+ "token",
17
+ "auth",
18
+ "authorization",
19
+ ...(options.sensitiveKeys || []).map((k) => k.toLowerCase())
20
+ ]);
21
+ }
22
+ /**
23
+ * Applies scrubbing to the data using default patterns and custom logic.
24
+ */
25
+ scrub(data) {
26
+ // 1. Perform deep regex scrubbing and key-based scrubbing
27
+ let result = this.deepScrub(data);
28
+ // 2. Run custom hook on the scrubbed data if provided
29
+ if (this.customScrubber) {
30
+ result = this.customScrubber(result);
31
+ }
32
+ return result;
33
+ }
34
+ deepScrub(val) {
35
+ if (typeof val === "string") {
36
+ let scrubbed = val;
37
+ for (const pattern of this.sensitivePatterns) {
38
+ scrubbed = scrubbed.replace(pattern, "[REDACTED]");
39
+ }
40
+ return scrubbed;
41
+ }
42
+ if (Array.isArray(val)) {
43
+ return val.map((v) => this.deepScrub(v));
44
+ }
45
+ if (val !== null && typeof val === "object") {
46
+ const obj = val;
47
+ const newObj = {};
48
+ for (const key in obj) {
49
+ if (!Object.prototype.hasOwnProperty.call(obj, key))
50
+ continue;
51
+ const lowerKey = key.toLowerCase();
52
+ const value = obj[key];
53
+ // SENSITIVE KEY LOGIC:
54
+ // Only redact if the key looks like a credential AND the value is a string.
55
+ // We don't want to redact 'total_tokens' or 'input_tokens' which are numbers.
56
+ const isCredentialKey = this.sensitiveKeys.has(lowerKey);
57
+ if (isCredentialKey && typeof value === "string") {
58
+ newObj[key] = "[REDACTED]";
59
+ }
60
+ else {
61
+ newObj[key] = this.deepScrub(value);
62
+ }
63
+ }
64
+ return newObj;
65
+ }
66
+ return val;
67
+ }
68
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./vcr.js";
2
+ export * from "./Mocker.js";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./vcr.js";
2
+ export * from "./Mocker.js";
package/dist/vcr.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ export type VCRMode = "record" | "replay" | "auto" | "passthrough";
2
+ export interface VCRInteraction {
3
+ method: string;
4
+ request: any;
5
+ response: any;
6
+ chunks?: any[];
7
+ }
8
+ export interface VCRCassette {
9
+ name: string;
10
+ version?: "1.0";
11
+ metadata?: {
12
+ recordedAt?: string;
13
+ recordedFrom?: string;
14
+ provider?: string;
15
+ duration?: number;
16
+ };
17
+ interactions: VCRInteraction[];
18
+ }
19
+ export interface VCROptions {
20
+ mode?: VCRMode;
21
+ scrub?: (data: any) => any;
22
+ cassettesDir?: string;
23
+ scope?: string | string[];
24
+ sensitivePatterns?: RegExp[];
25
+ sensitiveKeys?: string[];
26
+ }
27
+ export declare class VCR {
28
+ private cassette;
29
+ private interactionIndex;
30
+ private mode;
31
+ private filePath;
32
+ private scrubber;
33
+ private recordStartTime;
34
+ constructor(name: string, options?: VCROptions);
35
+ get currentMode(): VCRMode;
36
+ stop(): Promise<void>;
37
+ private get interactionsCount();
38
+ execute(method: string, originalMethod: (...args: any[]) => Promise<any>, request: any): Promise<any>;
39
+ executeStream(method: string, originalMethod: (...args: any[]) => AsyncIterable<any>, request: any): AsyncIterable<any>;
40
+ private clone;
41
+ private slugify;
42
+ }
43
+ export declare function setupVCR(name: string, options?: VCROptions): VCR;
44
+ /**
45
+ * One-line DX Sugar for VCR testing.
46
+ */
47
+ export declare function withVCR(fn: () => Promise<void>): () => Promise<void>;
48
+ export declare function withVCR(name: string, fn: () => Promise<void>): () => Promise<void>;
49
+ export declare function withVCR(options: VCROptions, fn: () => Promise<void>): () => Promise<void>;
50
+ export declare function withVCR(name: string, options: VCROptions, fn: () => Promise<void>): () => Promise<void>;
51
+ export declare function configureVCR(options: VCROptions): void;
52
+ export declare function resetVCRConfig(): void;
53
+ /**
54
+ * Organizes cassettes by hierarchical subdirectories.
55
+ */
56
+ export declare function describeVCR(name: string, fn: () => void | Promise<void>): void | Promise<void>;
57
+ //# sourceMappingURL=vcr.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vcr.d.ts","sourceRoot":"","sources":["../src/vcr.ts"],"names":[],"mappings":"AAmBA,MAAM,MAAM,OAAO,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,GAAG,aAAa,CAAC;AAEnE,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,GAAG,CAAC;IACb,QAAQ,EAAE,GAAG,CAAC;IACd,MAAM,CAAC,EAAE,GAAG,EAAE,CAAC;CAChB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,KAAK,CAAC;IAChB,QAAQ,CAAC,EAAE;QACT,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,CAAC;IACF,YAAY,EAAE,cAAc,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,GAAG,KAAK,GAAG,CAAC;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC1B,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC7B,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,qBAAa,GAAG;IACd,OAAO,CAAC,QAAQ,CAAc;IAC9B,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,IAAI,CAAU;IACtB,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,QAAQ,CAAW;IAC3B,OAAO,CAAC,eAAe,CAAa;gBAExB,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,UAAe;IA+FlD,IAAI,WAAW,YAEd;IAEK,IAAI;IAkBV,OAAO,KAAK,iBAAiB,GAE5B;IAEY,OAAO,CAClB,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,OAAO,CAAC,GAAG,CAAC,EAChD,OAAO,EAAE,GAAG,GACX,OAAO,CAAC,GAAG,CAAC;IAwBD,aAAa,CACzB,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,aAAa,CAAC,GAAG,CAAC,EACtD,OAAO,EAAE,GAAG,GACX,aAAa,CAAC,GAAG,CAAC;IAgCrB,OAAO,CAAC,KAAK;IAQb,OAAO,CAAC,OAAO;CAShB;AAID,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,UAAe,OAuB9D;AAED;;GAEG;AACH,wBAAgB,OAAO,CAAC,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;AACtE,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;AACpF,wBAAgB,OAAO,CAAC,OAAO,EAAE,UAAU,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;AAC3F,wBAAgB,OAAO,CACrB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,UAAU,EACnB,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GACtB,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;AAiDvB,wBAAgB,YAAY,CAAC,OAAO,EAAE,UAAU,QAE/C;AAED,wBAAgB,cAAc,IAAI,IAAI,CAErC;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAkB9F"}