feat: add xAI Grok adapters
This commit is contained in:
@@ -5,6 +5,7 @@ BoxBrain is an IdentityDB-backed TypeScript framework for creating synthetic per
|
|||||||
The project is framework-first rather than product-first. The current core library provides:
|
The project is framework-first rather than product-first. The current core library provides:
|
||||||
|
|
||||||
- provider-agnostic text, structured-output, image, conversation-memory, and special-date adapter contracts
|
- provider-agnostic text, structured-output, image, conversation-memory, and special-date adapter contracts
|
||||||
|
- ready-made xAI Grok text, structured-output, and image adapters
|
||||||
- one IdentityDB memory space per persona
|
- one IdentityDB memory space per persona
|
||||||
- persona initialization from personality, history, values, preferences, and relationships
|
- persona initialization from personality, history, values, preferences, and relationships
|
||||||
- LLM-generated biography ingestion into IdentityDB fact drafts
|
- LLM-generated biography ingestion into IdentityDB fact drafts
|
||||||
@@ -21,7 +22,7 @@ The project is framework-first rather than product-first. The current core libra
|
|||||||
Still planned:
|
Still planned:
|
||||||
|
|
||||||
- HTTP/RPC wrappers around the core library APIs
|
- HTTP/RPC wrappers around the core library APIs
|
||||||
- ready-made provider adapter packages for specific AI vendors
|
- ready-made provider adapter packages for additional AI vendors
|
||||||
- production-focused persistence/runtime integrations beyond the in-process core library
|
- production-focused persistence/runtime integrations beyond the in-process core library
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
@@ -35,6 +36,6 @@ bun run build
|
|||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
The repository now contains the framework core for persona initialization, schedule/status management, and conversation orchestration. See the implementation plan:
|
The repository now contains the framework core for persona initialization, schedule/status management, conversation orchestration, and a ready-made Grok adapter set. See the implementation plan:
|
||||||
|
|
||||||
- `docs/plans/2026-05-11-boxbrain-foundation.md`
|
- `docs/plans/2026-05-11-boxbrain-foundation.md`
|
||||||
|
|||||||
274
src/grok.ts
Normal file
274
src/grok.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import type {
|
||||||
|
ImageGenerationRequest,
|
||||||
|
ImageModelAdapter,
|
||||||
|
StructuredGenerationRequest,
|
||||||
|
StructuredModelAdapter,
|
||||||
|
TextGenerationRequest,
|
||||||
|
TextModelAdapter,
|
||||||
|
} from './adapters';
|
||||||
|
|
||||||
|
type GrokFetch = typeof fetch;
|
||||||
|
|
||||||
|
type JsonObject = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface GrokAdapterOptions {
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
baseUrl?: string | undefined;
|
||||||
|
fetch?: GrokFetch | undefined;
|
||||||
|
extraHeaders?: Record<string, string> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrokAdapterBundleOptions {
|
||||||
|
apiKey: string;
|
||||||
|
textModel: string;
|
||||||
|
structuredModel: string;
|
||||||
|
imageModel: string;
|
||||||
|
baseUrl?: string | undefined;
|
||||||
|
fetch?: GrokFetch | undefined;
|
||||||
|
extraHeaders?: Record<string, string> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL = 'https://api.x.ai/v1';
|
||||||
|
const GROK_PROVIDER = 'xai-grok';
|
||||||
|
|
||||||
|
export function createGrokTextModelAdapter(options: GrokAdapterOptions): TextModelAdapter {
|
||||||
|
const runtime = createRuntime(options);
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: GROK_PROVIDER,
|
||||||
|
model: options.model,
|
||||||
|
async generateText(request: TextGenerationRequest): Promise<string> {
|
||||||
|
const response = await runtime.postJson('/chat/completions', {
|
||||||
|
model: request.model ?? options.model,
|
||||||
|
messages: buildMessages(request.system, request.prompt),
|
||||||
|
temperature: request.temperature,
|
||||||
|
metadata: request.metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
return extractChatCompletionText(response);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGrokStructuredModelAdapter<TSchema = unknown>(options: GrokAdapterOptions): StructuredModelAdapter<TSchema> {
|
||||||
|
const runtime = createRuntime(options);
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: GROK_PROVIDER,
|
||||||
|
model: options.model,
|
||||||
|
async generateObject<TOutput>(request: StructuredGenerationRequest<TSchema>): Promise<TOutput> {
|
||||||
|
const response = await runtime.postJson('/chat/completions', {
|
||||||
|
model: request.model ?? options.model,
|
||||||
|
messages: buildMessages(request.system, request.prompt),
|
||||||
|
temperature: request.temperature,
|
||||||
|
metadata: request.metadata,
|
||||||
|
response_format: request.schema
|
||||||
|
? {
|
||||||
|
type: 'json_schema',
|
||||||
|
json_schema: {
|
||||||
|
name: 'boxbrain_structured_output',
|
||||||
|
schema: request.schema,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { type: 'json_object' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return parseStructuredOutput<TOutput>(extractChatCompletionText(response));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGrokImageModelAdapter(options: GrokAdapterOptions): ImageModelAdapter {
|
||||||
|
const runtime = createRuntime(options);
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: GROK_PROVIDER,
|
||||||
|
model: options.model,
|
||||||
|
async generateImage(request: ImageGenerationRequest) {
|
||||||
|
const response = await runtime.postJson('/images/generations', {
|
||||||
|
model: request.model ?? options.model,
|
||||||
|
prompt: request.prompt,
|
||||||
|
aspect_ratio: mapAspectRatio(request.aspectRatio),
|
||||||
|
metadata: request.metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
const image = extractFirstImage(response);
|
||||||
|
return {
|
||||||
|
url: image.url,
|
||||||
|
revisedPrompt: image.revised_prompt,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGrokAdapters(options: GrokAdapterBundleOptions): {
|
||||||
|
text: TextModelAdapter;
|
||||||
|
structured: StructuredModelAdapter;
|
||||||
|
image: ImageModelAdapter;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
text: createGrokTextModelAdapter({
|
||||||
|
apiKey: options.apiKey,
|
||||||
|
model: options.textModel,
|
||||||
|
baseUrl: options.baseUrl,
|
||||||
|
fetch: options.fetch,
|
||||||
|
extraHeaders: options.extraHeaders,
|
||||||
|
}),
|
||||||
|
structured: createGrokStructuredModelAdapter({
|
||||||
|
apiKey: options.apiKey,
|
||||||
|
model: options.structuredModel,
|
||||||
|
baseUrl: options.baseUrl,
|
||||||
|
fetch: options.fetch,
|
||||||
|
extraHeaders: options.extraHeaders,
|
||||||
|
}),
|
||||||
|
image: createGrokImageModelAdapter({
|
||||||
|
apiKey: options.apiKey,
|
||||||
|
model: options.imageModel,
|
||||||
|
baseUrl: options.baseUrl,
|
||||||
|
fetch: options.fetch,
|
||||||
|
extraHeaders: options.extraHeaders,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRuntime(options: Pick<GrokAdapterOptions, 'apiKey' | 'baseUrl' | 'fetch' | 'extraHeaders'>): {
|
||||||
|
postJson: (path: string, body: JsonObject) => Promise<JsonObject>;
|
||||||
|
} {
|
||||||
|
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, '');
|
||||||
|
const fetchImpl = options.fetch ?? globalThis.fetch;
|
||||||
|
if (!fetchImpl) {
|
||||||
|
throw new Error('Grok adapter requires a fetch implementation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async postJson(path, body) {
|
||||||
|
const response = await fetchImpl(`${baseUrl}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${options.apiKey}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
...(options.extraHeaders ?? {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(removeUndefined(body)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
const parsed = text.length > 0 ? tryParseJson(text) : {};
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Grok API request failed (${response.status}): ${text}`);
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
throw new Error('Grok API response must be a JSON object.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessages(system: string | undefined, prompt: string): Array<{ role: 'system' | 'user'; content: string }> {
|
||||||
|
const messages: Array<{ role: 'system' | 'user'; content: string }> = [];
|
||||||
|
if (system) {
|
||||||
|
messages.push({ role: 'system', content: system });
|
||||||
|
}
|
||||||
|
messages.push({ role: 'user', content: prompt });
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractChatCompletionText(response: JsonObject): string {
|
||||||
|
const choices = response.choices;
|
||||||
|
if (!Array.isArray(choices) || choices.length === 0) {
|
||||||
|
throw new Error('Grok chat completion response did not include choices.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstChoice = choices[0];
|
||||||
|
if (!firstChoice || typeof firstChoice !== 'object' || Array.isArray(firstChoice)) {
|
||||||
|
throw new Error('Grok chat completion response choice was invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = (firstChoice as JsonObject).message;
|
||||||
|
if (!message || typeof message !== 'object' || Array.isArray(message)) {
|
||||||
|
throw new Error('Grok chat completion response did not include a message.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (message as JsonObject).content;
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content
|
||||||
|
.map((part) => {
|
||||||
|
if (!part || typeof part !== 'object' || Array.isArray(part)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const text = (part as JsonObject).text;
|
||||||
|
return typeof text === 'string' ? text : '';
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Grok chat completion response did not include text content.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStructuredOutput<TOutput>(text: string): TOutput {
|
||||||
|
const parsed = tryParseJson(text);
|
||||||
|
return parsed as TOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFirstImage(response: JsonObject): { url?: string; revised_prompt?: string } {
|
||||||
|
const data = response.data;
|
||||||
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
|
throw new Error('Grok image generation response did not include image data.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = data[0];
|
||||||
|
if (!first || typeof first !== 'object' || Array.isArray(first)) {
|
||||||
|
throw new Error('Grok image generation response image entry was invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = (first as JsonObject).url;
|
||||||
|
const revisedPrompt = (first as JsonObject).revised_prompt;
|
||||||
|
const result: { url?: string; revised_prompt?: string } = {};
|
||||||
|
|
||||||
|
if (typeof url === 'string') {
|
||||||
|
result.url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof revisedPrompt === 'string') {
|
||||||
|
result.revised_prompt = revisedPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAspectRatio(aspectRatio: ImageGenerationRequest['aspectRatio']): string | undefined {
|
||||||
|
if (aspectRatio === 'square') {
|
||||||
|
return '1:1';
|
||||||
|
}
|
||||||
|
if (aspectRatio === 'portrait') {
|
||||||
|
return '3:4';
|
||||||
|
}
|
||||||
|
if (aspectRatio === 'landscape') {
|
||||||
|
return '16:9';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeUndefined(value: JsonObject): JsonObject {
|
||||||
|
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseJson(text: string): JsonObject {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
throw new Error('JSON value was not an object.');
|
||||||
|
}
|
||||||
|
return parsed as JsonObject;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`Failed to parse Grok JSON response: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from './adapters';
|
export * from './adapters';
|
||||||
export * from './availability';
|
export * from './availability';
|
||||||
export * from './conversation';
|
export * from './conversation';
|
||||||
|
export * from './grok';
|
||||||
export * from './memory';
|
export * from './memory';
|
||||||
export * from './persona';
|
export * from './persona';
|
||||||
export * from './schedule';
|
export * from './schedule';
|
||||||
|
|||||||
147
tests/grok.test.ts
Normal file
147
tests/grok.test.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
createGrokAdapters,
|
||||||
|
createGrokImageModelAdapter,
|
||||||
|
createGrokStructuredModelAdapter,
|
||||||
|
createGrokTextModelAdapter,
|
||||||
|
} from '../src';
|
||||||
|
|
||||||
|
describe('Grok adapters', () => {
|
||||||
|
it('creates a text adapter that calls xAI chat completions', async () => {
|
||||||
|
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||||
|
const adapter = createGrokTextModelAdapter({
|
||||||
|
apiKey: 'test-key',
|
||||||
|
model: 'grok-4.3-mini',
|
||||||
|
fetch: async (url, init) => {
|
||||||
|
calls.push({ url: String(url), ...(init ? { init } : {}) });
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: 'hello from grok',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(adapter.generateText({
|
||||||
|
system: 'You are a helpful persona engine.',
|
||||||
|
prompt: 'Say hi.',
|
||||||
|
temperature: 0.3,
|
||||||
|
})).resolves.toBe('hello from grok');
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]?.url).toBe('https://api.x.ai/v1/chat/completions');
|
||||||
|
expect(calls[0]?.init?.method).toBe('POST');
|
||||||
|
expect(calls[0]?.init?.headers).toMatchObject({
|
||||||
|
authorization: 'Bearer test-key',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = JSON.parse(String(calls[0]?.init?.body));
|
||||||
|
expect(body.model).toBe('grok-4.3-mini');
|
||||||
|
expect(body.temperature).toBe(0.3);
|
||||||
|
expect(body.messages).toEqual([
|
||||||
|
{ role: 'system', content: 'You are a helpful persona engine.' },
|
||||||
|
{ role: 'user', content: 'Say hi.' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a structured adapter that sends json_schema response_format and parses JSON', async () => {
|
||||||
|
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||||
|
const adapter = createGrokStructuredModelAdapter({
|
||||||
|
apiKey: 'test-key',
|
||||||
|
model: 'grok-4.3',
|
||||||
|
fetch: async (url, init) => {
|
||||||
|
calls.push({ url: String(url), ...(init ? { init } : {}) });
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: JSON.stringify({ biography: 'Born in Busan.' }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const schema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['biography'],
|
||||||
|
properties: { biography: { type: 'string' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(adapter.generateObject<{ biography: string }>({
|
||||||
|
prompt: 'Write a biography.',
|
||||||
|
system: 'Return only JSON.',
|
||||||
|
schema,
|
||||||
|
})).resolves.toEqual({ biography: 'Born in Busan.' });
|
||||||
|
|
||||||
|
const body = JSON.parse(String(calls[0]?.init?.body));
|
||||||
|
expect(body.response_format).toEqual({
|
||||||
|
type: 'json_schema',
|
||||||
|
json_schema: {
|
||||||
|
name: 'boxbrain_structured_output',
|
||||||
|
schema,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates an image adapter that calls xAI image generation with mapped aspect ratios', async () => {
|
||||||
|
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||||
|
const adapter = createGrokImageModelAdapter({
|
||||||
|
apiKey: 'test-key',
|
||||||
|
model: 'grok-imagine-image-quality',
|
||||||
|
fetch: async (url, init) => {
|
||||||
|
calls.push({ url: String(url), ...(init ? { init } : {}) });
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
data: [{ url: 'https://cdn.x.ai/generated.png', revised_prompt: 'revised prompt' }],
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(adapter.generateImage({
|
||||||
|
prompt: 'A quiet portrait photo.',
|
||||||
|
aspectRatio: 'portrait',
|
||||||
|
})).resolves.toEqual({
|
||||||
|
url: 'https://cdn.x.ai/generated.png',
|
||||||
|
revisedPrompt: 'revised prompt',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(calls[0]?.url).toBe('https://api.x.ai/v1/images/generations');
|
||||||
|
const body = JSON.parse(String(calls[0]?.init?.body));
|
||||||
|
expect(body.aspect_ratio).toBe('3:4');
|
||||||
|
expect(body.model).toBe('grok-imagine-image-quality');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can create a bundled Grok adapter set with shared defaults', async () => {
|
||||||
|
const adapters = createGrokAdapters({
|
||||||
|
apiKey: 'bundle-key',
|
||||||
|
textModel: 'grok-4.3-mini',
|
||||||
|
structuredModel: 'grok-4.3',
|
||||||
|
imageModel: 'grok-imagine-image-quality',
|
||||||
|
fetch: async (_url, _init) => new Response(JSON.stringify({
|
||||||
|
choices: [{ message: { content: 'ok' } }],
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(adapters.text.provider).toBe('xai-grok');
|
||||||
|
expect(adapters.structured.model).toBe('grok-4.3');
|
||||||
|
expect(adapters.image.model).toBe('grok-imagine-image-quality');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
createGrokAdapters,
|
||||||
createReplyDelay,
|
createReplyDelay,
|
||||||
createTypingDelay,
|
createTypingDelay,
|
||||||
generateSchedule,
|
generateSchedule,
|
||||||
@@ -38,7 +39,7 @@ describe('public API', () => {
|
|||||||
expect(fact.topics.map((topic) => topic.name)).toEqual(['Mina', 'quiet cafés']);
|
expect(fact.topics.map((topic) => topic.name)).toEqual(['Mina', 'quiet cafés']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exports schedule, conversation, and external special-date adapter contracts', () => {
|
it('exports schedule, conversation, Grok, and external special-date adapter contracts', () => {
|
||||||
const specialDateProvider: SpecialDateProvider = {
|
const specialDateProvider: SpecialDateProvider = {
|
||||||
async listSpecialDates() {
|
async listSpecialDates() {
|
||||||
return [{ date: '2026-05-08', title: 'Parents Day' }];
|
return [{ date: '2026-05-08', title: 'Parents Day' }];
|
||||||
@@ -47,6 +48,7 @@ describe('public API', () => {
|
|||||||
|
|
||||||
expect(typeof generateSchedule).toBe('function');
|
expect(typeof generateSchedule).toBe('function');
|
||||||
expect(typeof replyToConversation).toBe('function');
|
expect(typeof replyToConversation).toBe('function');
|
||||||
|
expect(typeof createGrokAdapters).toBe('function');
|
||||||
expect(specialDateProvider.listSpecialDates).toBeTypeOf('function');
|
expect(specialDateProvider.listSpecialDates).toBeTypeOf('function');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user