Skip to content

Commit 1190ec4

Browse files
committed
add integration tests
1 parent f73e401 commit 1190ec4

File tree

2 files changed

+178
-3
lines changed

2 files changed

+178
-3
lines changed

packages/ai/integration/constants.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ import {
2222
BackendType,
2323
GoogleAIBackend,
2424
VertexAIBackend,
25-
getAI
25+
getAI,
26+
getGenerativeModel
2627
} from '../src';
2728
import { FIREBASE_CONFIG } from './firebase-config';
2829

@@ -54,6 +55,16 @@ const backendNames: Map<BackendType, string> = new Map([
5455

5556
const modelNames: readonly string[] = ['gemini-2.0-flash', 'gemini-2.5-flash'];
5657

58+
// Used for testing non-AI behavior (e.g. Network requests). Configured to minimize cost.
59+
export const cheapestModel = 'gemini-2.0-flash';
60+
export const defaultAIInstance = getAI(app, { backend: new VertexAIBackend() });
61+
export const defaultGenerativeModel = getGenerativeModel(defaultAIInstance, {
62+
model: cheapestModel,
63+
generationConfig: {
64+
maxOutputTokens: 10 // Just enough to confirm we actually get something back.
65+
}
66+
});
67+
5768
// The Live API requires a different set of models, and they're different for each backend.
5869
const liveModelNames: Map<BackendType, string[]> = new Map([
5970
[BackendType.GOOGLE_AI, ['gemini-live-2.5-flash-preview']],

packages/ai/integration/generate-content.test.ts

Lines changed: 166 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515
* limitations under the License.
1616
*/
1717

18-
import { expect } from 'chai';
18+
import chai, { AssertionError } from 'chai';
19+
import chaiAsPromised from 'chai-as-promised';
20+
chai.use(chaiAsPromised);
21+
const expect = chai.expect;
22+
1923
import {
2024
BackendType,
2125
Content,
@@ -29,7 +33,17 @@ import {
2933
URLRetrievalStatus,
3034
getGenerativeModel
3135
} from '../src';
32-
import { testConfigs, TOKEN_COUNT_DELTA } from './constants';
36+
import {
37+
cheapestModel,
38+
defaultAIInstance,
39+
defaultGenerativeModel,
40+
testConfigs,
41+
TOKEN_COUNT_DELTA
42+
} from './constants';
43+
import {
44+
TIMEOUT_EXPIRED_MESSAGE
45+
} from '../src/requests/request';
46+
import { isNode } from '@firebase/util';
3347

3448
describe('Generate Content', function () {
3549
this.timeout(20_000);
@@ -370,4 +384,154 @@ describe('Generate Content', function () {
370384
});
371385
});
372386
});
387+
388+
describe('Request Options', async () => {
389+
const defaultAbortReason = isNode()
390+
? 'This operation was aborted'
391+
: 'signal is aborted without reason';
392+
describe('unary', async () => {
393+
it('timeout cancels request', async () => {
394+
await expect(
395+
defaultGenerativeModel.generateContent('hello', { timeout: 100 })
396+
).to.be.rejectedWith(DOMException, TIMEOUT_EXPIRED_MESSAGE);
397+
});
398+
399+
it('long timeout does not cancel request', async () => {
400+
const result = await defaultGenerativeModel.generateContent('hello', {
401+
timeout: 50_000
402+
});
403+
expect(result.response.text().length).to.be.greaterThan(0);
404+
});
405+
406+
it('abort signal with no reason causes request to throw AbortError', async () => {
407+
const abortController = new AbortController();
408+
const responsePromise = defaultGenerativeModel.generateContent(
409+
'hello',
410+
{ signal: abortController.signal }
411+
);
412+
abortController.abort();
413+
await expect(responsePromise)
414+
.to.be.rejectedWith(DOMException, defaultAbortReason)
415+
.and.eventually.have.property('name', 'AbortError');
416+
});
417+
418+
it('abort signal with string reason causes request to throw reason string', async () => {
419+
const abortController = new AbortController();
420+
const responsePromise = defaultGenerativeModel.generateContent(
421+
'hello',
422+
{ signal: abortController.signal }
423+
);
424+
const reason = 'Cancelled';
425+
abortController.abort(reason);
426+
await expect(responsePromise).to.be.rejectedWith(reason);
427+
});
428+
429+
it('abort signal with error reason causes request to throw reason error', async () => {
430+
const abortController = new AbortController();
431+
const responsePromise = defaultGenerativeModel.generateContent(
432+
'hello',
433+
{ signal: abortController.signal }
434+
);
435+
abortController.abort(new Error('Cancelled'));
436+
// `fetch()` will reject with the exact object we passed to `abort()`. Since we throw a generic
437+
// Error, we cannot differentiate between this error and other generic fetch errors, which
438+
// we wrap in an AIError.
439+
await expect(responsePromise)
440+
.to.be.rejectedWith(Error, 'Cancelled')
441+
.and.eventually.have.property('name', 'FirebaseError');
442+
});
443+
});
444+
445+
describe('streaming', async () => {
446+
it('timeout cancels initial request', async () => {
447+
await expect(
448+
defaultGenerativeModel.generateContent('hello', { timeout: 50 })
449+
).to.be.rejectedWith(DOMException, TIMEOUT_EXPIRED_MESSAGE);
450+
});
451+
452+
it('timeout does not cancel request once streaming has begun', async () => {
453+
const generativeModel = getGenerativeModel(defaultAIInstance, {
454+
model: cheapestModel
455+
});
456+
// Setting a timeout that will be in the interval between the stream starting and ending.
457+
// Since the timeout will expire once the stream has begun, it should have already been
458+
// cleared, and so it shouldn't abort the stream.
459+
const { stream, response } =
460+
await generativeModel.generateContentStream(
461+
'tell me a short story with 200 words.',
462+
{ timeout: 1_000 }
463+
);
464+
465+
// We should be able to get through the entire stream without an error being thrown
466+
// from the async generator.
467+
for await (const chunk of stream) {
468+
expect(chunk.text().length).to.be.greaterThan(0);
469+
}
470+
471+
expect((await response).text().length).to.be.greaterThan(0);
472+
});
473+
474+
it('abort signal without reason should cancel stream with default abort reason', async () => {
475+
const abortController = new AbortController();
476+
const generativeModel = getGenerativeModel(defaultAIInstance, {
477+
model: cheapestModel
478+
});
479+
const { stream, response } =
480+
await generativeModel.generateContentStream(
481+
'tell me a short story with 200 words.',
482+
{ signal: abortController.signal }
483+
);
484+
485+
// As soon as the initial request resolves and the stream starts, abort the stream.
486+
abortController.abort();
487+
488+
try {
489+
for await (const _ of stream) {
490+
expect.fail('Expected stream to throw an error');
491+
}
492+
expect.fail('Expected stream to throw an error');
493+
} catch (err) {
494+
if ((err as Error) instanceof AssertionError) {
495+
throw err;
496+
}
497+
expect(err).to.be.instanceof(DOMException);
498+
expect((err as Error).name).to.equal('AbortError');
499+
expect((err as Error).message).to.equal(defaultAbortReason);
500+
}
501+
502+
await expect(response)
503+
.to.be.rejectedWith(DOMException, defaultAbortReason)
504+
.and.to.eventually.have.property('name', 'AbortError');
505+
});
506+
507+
it('abort signal with reason string should cancel stream with string abort reason', async () => {
508+
const abortController = new AbortController();
509+
const generativeModel = getGenerativeModel(defaultAIInstance, {
510+
model: cheapestModel
511+
});
512+
const { stream, response } =
513+
await generativeModel.generateContentStream(
514+
'tell me a short story with 200 words.',
515+
{ signal: abortController.signal }
516+
);
517+
518+
// As soon as the initial request resolves and the stream starts, abort the stream.
519+
abortController.abort('Cancelled');
520+
521+
try {
522+
for await (const _ of stream) {
523+
expect.fail('Expected stream to throw an error');
524+
}
525+
expect.fail('Expected stream to throw an error');
526+
} catch (err) {
527+
if ((err as Error) instanceof AssertionError) {
528+
throw err;
529+
}
530+
expect(err).to.equal('Cancelled');
531+
}
532+
533+
await expect(response).to.be.rejectedWith('Cancelled');
534+
});
535+
});
536+
});
373537
});

0 commit comments

Comments
 (0)