test_ChatUtils.js (15838B)
1 /** 2 * This Source Code Form is subject to the terms of the Mozilla Public 3 * License, v. 2.0. If a copy of the MPL was not distributed with this 4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 */ 6 7 do_get_profile(); 8 9 const { 10 constructRealTimeInfoInjectionMessage, 11 getLocalIsoTime, 12 getCurrentTabMetadata, 13 constructRelevantMemoriesContextMessage, 14 parseContentWithTokens, 15 detectTokens, 16 } = ChromeUtils.importESModule( 17 "moz-src:///browser/components/aiwindow/models/ChatUtils.sys.mjs" 18 ); 19 const { MemoriesManager } = ChromeUtils.importESModule( 20 "moz-src:///browser/components/aiwindow/models/memories/MemoriesManager.sys.mjs" 21 ); 22 const { MemoryStore } = ChromeUtils.importESModule( 23 "moz-src:///browser/components/aiwindow/services/MemoryStore.sys.mjs" 24 ); 25 const { sinon } = ChromeUtils.importESModule( 26 "resource://testing-common/Sinon.sys.mjs" 27 ); 28 29 /** 30 * Constants for test memories 31 */ 32 const TEST_MEMORIES = [ 33 { 34 memory_summary: "Loves drinking coffee", 35 category: "Food & Drink", 36 intent: "Plan / Organize", 37 score: 3, 38 }, 39 { 40 memory_summary: "Buys dog food online", 41 category: "Pets & Animals", 42 intent: "Buy / Acquire", 43 score: 4, 44 }, 45 ]; 46 47 /** 48 * Helper function bulk-add memories 49 */ 50 async function clearAndAddMemories() { 51 const memories = await MemoryStore.getMemories(); 52 for (const memory of memories) { 53 await MemoryStore.hardDeleteMemory(memory.id); 54 } 55 for (const memory of TEST_MEMORIES) { 56 await MemoryStore.addMemory(memory); 57 } 58 } 59 60 /** 61 * Constants for preference keys and test values 62 */ 63 const PREF_API_KEY = "browser.aiwindow.apiKey"; 64 const PREF_ENDPOINT = "browser.aiwindow.endpoint"; 65 const PREF_MODEL = "browser.aiwindow.model"; 66 67 const API_KEY = "fake-key"; 68 const ENDPOINT = "https://api.fake-endpoint.com/v1"; 69 const MODEL = "fake-model"; 70 71 add_setup(async function () { 72 // Setup prefs used across multiple tests 73 Services.prefs.setStringPref(PREF_API_KEY, API_KEY); 74 Services.prefs.setStringPref(PREF_ENDPOINT, ENDPOINT); 75 Services.prefs.setStringPref(PREF_MODEL, MODEL); 76 77 // Clear prefs after testing 78 registerCleanupFunction(() => { 79 for (let pref of [PREF_API_KEY, PREF_ENDPOINT, PREF_MODEL]) { 80 if (Services.prefs.prefHasUserValue(pref)) { 81 Services.prefs.clearUserPref(pref); 82 } 83 } 84 }); 85 }); 86 87 add_task(function test_getLocalIsoTime_returns_offset_timestamp() { 88 const sb = sinon.createSandbox(); 89 const clock = sb.useFakeTimers({ now: Date.UTC(2025, 11, 27, 14, 0, 0) }); 90 try { 91 const iso = getLocalIsoTime(); 92 Assert.ok( 93 typeof iso === "string" && !!iso.length, 94 "Should return a non-empty string" 95 ); 96 Assert.ok( 97 /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(iso), 98 "Should include date, time (up to seconds), without timezone offset" 99 ); 100 } finally { 101 clock.restore(); 102 sb.restore(); 103 } 104 }); 105 106 add_task(async function test_getCurrentTabMetadata_fetch_fallback() { 107 const sb = sinon.createSandbox(); 108 const tracker = { getTopWindow: sb.stub() }; 109 const pageData = { 110 getCached: sb.stub(), 111 }; 112 const fakeActor = { 113 collectPageData: sb.stub().resolves({ 114 description: "Collected description", 115 }), 116 }; 117 const fakeBrowser = { 118 currentURI: { spec: "https://example.com/article" }, 119 contentTitle: "", 120 documentTitle: "Example Article", 121 browsingContext: { 122 currentWindowGlobal: { 123 getActor: sb.stub().returns(fakeActor), 124 }, 125 }, 126 }; 127 128 tracker.getTopWindow.returns({ 129 gBrowser: { selectedBrowser: fakeBrowser }, 130 }); 131 pageData.getCached.returns(null); 132 133 try { 134 const result = await getCurrentTabMetadata({ 135 BrowserWindowTracker: tracker, 136 PageDataService: pageData, 137 }); 138 Assert.deepEqual(result, { 139 url: "https://example.com/article", 140 title: "Example Article", 141 description: "Collected description", 142 }); 143 Assert.ok( 144 fakeActor.collectPageData.calledOnce, 145 "Should collect page data from actor when not cached" 146 ); 147 Assert.ok( 148 fakeBrowser.browsingContext.currentWindowGlobal.getActor.calledWith( 149 "PageData" 150 ), 151 "Should get PageData actor" 152 ); 153 } finally { 154 sb.restore(); 155 } 156 }); 157 158 add_task( 159 async function test_constructRealTimeInfoInjectionMessage_with_tab_info() { 160 const sb = sinon.createSandbox(); 161 const tracker = { getTopWindow: sb.stub() }; 162 const pageData = { 163 getCached: sb.stub(), 164 }; 165 const locale = Services.locale.appLocaleAsBCP47; 166 const fakeActor = { 167 collectPageData: sb.stub(), 168 }; 169 const fakeBrowser = { 170 currentURI: { spec: "https://mozilla.org" }, 171 contentTitle: "Mozilla", 172 documentTitle: "Mozilla", 173 browsingContext: { 174 currentWindowGlobal: { 175 getActor: sb.stub().returns(fakeActor), 176 }, 177 }, 178 }; 179 180 tracker.getTopWindow.returns({ 181 gBrowser: { selectedBrowser: fakeBrowser }, 182 }); 183 pageData.getCached.returns({ 184 description: "Internet for people", 185 }); 186 const clock = sb.useFakeTimers({ now: Date.UTC(2025, 11, 27, 14, 0, 0) }); 187 188 try { 189 const message = await constructRealTimeInfoInjectionMessage({ 190 BrowserWindowTracker: tracker, 191 PageDataService: pageData, 192 }); 193 Assert.equal(message.role, "system", "Should return system role"); 194 Assert.ok( 195 message.content.includes(`Locale: ${locale}`), 196 "Should include locale" 197 ); 198 Assert.ok( 199 message.content.includes("Current active browser tab details:"), 200 "Should include tab details heading" 201 ); 202 Assert.ok( 203 message.content.includes("- URL: https://mozilla.org"), 204 "Should include tab URL" 205 ); 206 Assert.ok( 207 message.content.includes("- Title: Mozilla"), 208 "Should include tab title" 209 ); 210 Assert.ok( 211 message.content.includes("- Description: Internet for people"), 212 "Should include tab description" 213 ); 214 Assert.ok( 215 fakeActor.collectPageData.notCalled, 216 "Should not collect page data when cached data exists" 217 ); 218 } finally { 219 clock.restore(); 220 sb.restore(); 221 } 222 } 223 ); 224 225 add_task( 226 async function test_constructRealTimeInfoInjectionMessage_without_tab_info() { 227 const sb = sinon.createSandbox(); 228 const tracker = { getTopWindow: sb.stub() }; 229 const pageData = { 230 getCached: sb.stub(), 231 fetchPageData: sb.stub(), 232 }; 233 const locale = Services.locale.appLocaleAsBCP47; 234 235 tracker.getTopWindow.returns(null); 236 const clock = sb.useFakeTimers({ now: Date.UTC(2025, 11, 27, 14, 0, 0) }); 237 238 try { 239 const message = await constructRealTimeInfoInjectionMessage({ 240 BrowserWindowTracker: tracker, 241 PageDataService: pageData, 242 }); 243 Assert.ok( 244 message.content.includes("No active browser tab."), 245 "Should mention missing tab info" 246 ); 247 Assert.ok( 248 !message.content.includes("- URL:"), 249 "Should not include empty tab fields" 250 ); 251 Assert.ok( 252 message.content.includes(`Locale: ${locale}`), 253 "Should include system locale" 254 ); 255 } finally { 256 clock.restore(); 257 sb.restore(); 258 } 259 } 260 ); 261 262 add_task(async function test_constructRelevantMemoriesContextMessage() { 263 await clearAndAddMemories(); 264 265 const sb = sinon.createSandbox(); 266 try { 267 const fakeEngine = { 268 run() { 269 return { 270 finalOutput: `{ 271 "categories": ["Food & Drink"], 272 "intents": ["Plan / Organize"] 273 }`, 274 }; 275 }, 276 }; 277 278 // Stub the `ensureOpenAIEngine` method in MemoriesManager 279 const stub = sb 280 .stub(MemoriesManager, "ensureOpenAIEngine") 281 .returns(fakeEngine); 282 283 const relevantMemoriesContextMessage = 284 await constructRelevantMemoriesContextMessage("I love drinking coffee"); 285 Assert.ok(stub.calledOnce, "ensureOpenAIEngine should be called once"); 286 287 // Check relevantMemoriesContextMessage's top level structure 288 Assert.strictEqual( 289 typeof relevantMemoriesContextMessage, 290 "object", 291 "Should return an object" 292 ); 293 Assert.equal( 294 Object.keys(relevantMemoriesContextMessage).length, 295 2, 296 "Should have 2 keys" 297 ); 298 299 // Check specific fields 300 Assert.equal( 301 relevantMemoriesContextMessage.role, 302 "system", 303 "Should have role 'system'" 304 ); 305 Assert.ok( 306 typeof relevantMemoriesContextMessage.content === "string" && 307 relevantMemoriesContextMessage.content.length, 308 "Content should be a non-empty string" 309 ); 310 311 const content = relevantMemoriesContextMessage.content; 312 Assert.ok( 313 content.includes( 314 "Use them to personalized your response using the following guidelines:" 315 ), 316 "Relevant memories context prompt should pull from the correct base" 317 ); 318 Assert.ok( 319 content.includes("- Loves drinking coffee"), 320 "Content should include relevant memory" 321 ); 322 Assert.ok( 323 !content.includes("- Buys dog food online"), 324 "Content should not include non-relevant memory" 325 ); 326 } finally { 327 sb.restore(); 328 } 329 }); 330 331 add_task( 332 async function test_constructRelevantMemoriesContextMessage_no_relevant_memories() { 333 await clearAndAddMemories(); 334 335 const sb = sinon.createSandbox(); 336 try { 337 const fakeEngine = { 338 run() { 339 return { 340 finalOutput: `{ 341 "categories": ["Health & Fitness"], 342 "intents": ["Plan / Organize"] 343 }`, 344 }; 345 }, 346 }; 347 348 // Stub the `ensureOpenAIEngine` method in MemoriesManager 349 const stub = sb 350 .stub(MemoriesManager, "ensureOpenAIEngine") 351 .returns(fakeEngine); 352 353 const relevantMemoriesContextMessage = 354 await constructRelevantMemoriesContextMessage("I love drinking coffee"); 355 Assert.ok(stub.calledOnce, "ensureOpenAIEngine should be called once"); 356 357 // No relevant memories, so returned value should be null 358 Assert.equal( 359 relevantMemoriesContextMessage, 360 null, 361 "Should return null when there are no relevant memories" 362 ); 363 } finally { 364 sb.restore(); 365 } 366 } 367 ); 368 369 add_task(async function test_parseContentWithTokens_no_tokens() { 370 const content = "This is a regular message with no special tokens."; 371 const result = await parseContentWithTokens(content); 372 373 Assert.equal( 374 result.cleanContent, 375 content, 376 "Clean content should match original when no tokens present" 377 ); 378 Assert.equal(result.searchQueries.length, 0, "Should have no search queries"); 379 Assert.equal(result.usedMemories.length, 0, "Should have no used memories"); 380 }); 381 382 add_task(async function test_parseContentWithTokens_single_search_token() { 383 const content = 384 "You can find great coffee in the downtown area.§search: best coffee shops near me§"; 385 const result = await parseContentWithTokens(content); 386 387 Assert.equal( 388 result.cleanContent, 389 "You can find great coffee in the downtown area.", 390 "Should remove search token from content" 391 ); 392 Assert.equal(result.searchQueries.length, 1, "Should have one search query"); 393 Assert.equal( 394 result.searchQueries[0], 395 "best coffee shops near me", 396 "Should extract correct search query" 397 ); 398 Assert.equal(result.usedMemories.length, 0, "Should have no used memories"); 399 }); 400 401 add_task(async function test_parseContentWithTokens_single_memory_token() { 402 const content = 403 "I recommend trying herbal tea blends.§existing_memory: likes tea§"; 404 const result = await parseContentWithTokens(content); 405 406 Assert.equal( 407 result.cleanContent, 408 "I recommend trying herbal tea blends.", 409 "Should remove memory token from content" 410 ); 411 Assert.equal(result.searchQueries.length, 0, "Should have no search queries"); 412 Assert.equal(result.usedMemories.length, 1, "Should have one used memory"); 413 Assert.equal( 414 result.usedMemories[0], 415 "likes tea", 416 "Should extract correct memory" 417 ); 418 }); 419 420 add_task(async function test_parseContentWithTokens_multiple_mixed_tokens() { 421 const content = 422 "I recommend checking out organic coffee options.§existing_memory: prefers organic§ They have great flavor profiles.§search: organic coffee beans reviews§§search: best organic cafes nearby§"; 423 const result = await parseContentWithTokens(content); 424 425 Assert.equal( 426 result.cleanContent, 427 "I recommend checking out organic coffee options. They have great flavor profiles.", 428 "Should remove all tokens from content" 429 ); 430 Assert.equal( 431 result.searchQueries.length, 432 2, 433 "Should have two search queries" 434 ); 435 Assert.deepEqual( 436 result.searchQueries, 437 ["organic coffee beans reviews", "best organic cafes nearby"], 438 "Should extract search queries in correct order" 439 ); 440 Assert.equal(result.usedMemories.length, 1, "Should have one used memory"); 441 Assert.equal( 442 result.usedMemories[0], 443 "prefers organic", 444 "Should extract correct memory" 445 ); 446 }); 447 448 add_task(async function test_parseContentWithTokens_tokens_with_whitespace() { 449 const content = 450 "You can find more details online.§search: coffee brewing methods §"; 451 const result = await parseContentWithTokens(content); 452 453 Assert.equal( 454 result.cleanContent, 455 "You can find more details online.", 456 "Should remove token with whitespace" 457 ); 458 Assert.equal(result.searchQueries.length, 1, "Should have one search query"); 459 Assert.equal( 460 result.searchQueries[0], 461 "coffee brewing methods", 462 "Should trim whitespace from extracted query" 463 ); 464 }); 465 466 add_task(async function test_parseContentWithTokens_adjacent_tokens() { 467 const content = 468 "Here are some great Italian dining options.§existing_memory: prefers italian food§§search: local italian restaurants§"; 469 const result = await parseContentWithTokens(content); 470 471 Assert.equal( 472 result.cleanContent, 473 "Here are some great Italian dining options.", 474 "Should remove adjacent tokens" 475 ); 476 Assert.equal(result.searchQueries.length, 1, "Should have one search query"); 477 Assert.equal( 478 result.searchQueries[0], 479 "local italian restaurants", 480 "Should extract search query" 481 ); 482 Assert.equal(result.usedMemories.length, 1, "Should have one memory"); 483 Assert.equal( 484 result.usedMemories[0], 485 "prefers italian food", 486 "Should extract memory" 487 ); 488 }); 489 490 add_task(function test_detectTokens_basic_pattern() { 491 const content = 492 "There are many great options available.§search: coffee shops near downtown§§search: best rated restaurants§"; 493 const searchRegex = /§search:\s*([^§]+)§/gi; 494 const result = detectTokens(content, searchRegex, "query"); 495 496 Assert.equal(result.length, 2, "Should find two matches"); 497 Assert.equal( 498 result[0].query, 499 "coffee shops near downtown", 500 "First match should extract correct query" 501 ); 502 Assert.equal( 503 result[0].fullMatch, 504 "§search: coffee shops near downtown§", 505 "First match should include full match" 506 ); 507 Assert.equal( 508 result[0].startIndex, 509 39, 510 "First match should have correct start index" 511 ); 512 Assert.equal( 513 result[1].query, 514 "best rated restaurants", 515 "Second match should extract correct query" 516 ); 517 }); 518 519 add_task(function test_detectTokens_custom_key() { 520 const content = 521 "I recommend trying the Thai curry.§memory: prefers spicy food§"; 522 const memoryRegex = /§memory:\s*([^§]+)§/gi; 523 const result = detectTokens(content, memoryRegex, "customKey"); 524 525 Assert.equal(result.length, 1, "Should find one match"); 526 Assert.equal( 527 result[0].customKey, 528 "prefers spicy food", 529 "Should use custom key for extracted value" 530 ); 531 Assert.ok( 532 result[0].hasOwnProperty("customKey"), 533 "Result should have the custom key property" 534 ); 535 Assert.ok( 536 !result[0].hasOwnProperty("query"), 537 "Result should not have default 'query' property" 538 ); 539 });