fix(M-AI-01-09): enqueue jobs via BullMQ instead of MySQL INSERT
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 47s

Worker consumes from BullMQ Redis, not MySQL polling.
Replace all dbExec(INSERT INTO AiAnalysisJob) with enqueueJob()
which uses real BullMQ Queue.add() to the test Redis.

Add setRedisConfig() + enqueueJob() to integration harness.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-19 22:20:45 +08:00
parent bda7f9d80a
commit 5bde0a3656
2 changed files with 148 additions and 121 deletions

View File

@ -5,6 +5,29 @@
import { spawn, ChildProcess } from 'child_process'; import { spawn, ChildProcess } from 'child_process';
import * as http from 'http'; import * as http from 'http';
import { Queue } from 'bullmq';
let redisConn: { host: string; port: number; password?: string } | null = null;
export function setRedisConfig(host: string, port: number, password?: string) {
redisConn = { host, port, password: password || undefined };
}
/**
* Enqueue a job directly to BullMQ Redis queue.
* Worker process consumes from this queue.
*/
export async function enqueueJob(queueName: string, data: Record<string, unknown>, jobId?: string): Promise<string> {
if (!redisConn) throw new Error('Redis config not set — call setRedisConfig() first');
const queue = new Queue(queueName, { connection: redisConn });
const job = await queue.add(queueName, data, {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
...(jobId ? { jobId } : {}),
});
await queue.close();
return job.id as string;
}
let apiProc: ChildProcess | null = null; let apiProc: ChildProcess | null = null;
let workerProc: ChildProcess | null = null; let workerProc: ChildProcess | null = null;

View File

@ -3,6 +3,7 @@
* *
* 使 MySQL / Redis / BullMQ / HTTP * 使 MySQL / Redis / BullMQ / HTTP
* API Worker + Mock AI Provider * API Worker + Mock AI Provider
* BullMQ Redis Worker MySQL
* *
* : * :
* DATABASE_URL="mysql://..." REDIS_HOST=... \ * DATABASE_URL="mysql://..." REDIS_HOST=... \
@ -11,7 +12,8 @@
import { import {
startAPI, startWorker, stopAPI, stopWorker, getApiPid, getWorkerPid, startAPI, startWorker, stopAPI, stopWorker, getApiPid, getWorkerPid,
httpGet, httpPost, setupFixtures, cleanupFixtures, dbQuery, dbExec, dbClose, httpGet, setupFixtures, cleanupFixtures, dbQuery, dbClose,
enqueueJob, setRedisConfig,
} from './helpers/integration-harness'; } from './helpers/integration-harness';
import { MockAIProvider } from './helpers/mock-ai-provider'; import { MockAIProvider } from './helpers/mock-ai-provider';
@ -22,7 +24,7 @@ const TEST_SESSION = `itg-sess-${Math.random().toString(36).slice(2, 8)}`;
const DB_URL = process.env.DATABASE_URL || 'mysql://zhixi_user:test@127.0.0.1:3306/zhixi_test'; const DB_URL = process.env.DATABASE_URL || 'mysql://zhixi_user:test@127.0.0.1:3306/zhixi_test';
const REDIS_HOST = process.env.REDIS_HOST || '127.0.0.1'; const REDIS_HOST = process.env.REDIS_HOST || '127.0.0.1';
const REDIS_PORT = process.env.REDIS_PORT || '6379'; const REDIS_PORT = parseInt(process.env.REDIS_PORT || '6379', 10);
const REDIS_PW = process.env.REDIS_PASSWORD || ''; const REDIS_PW = process.env.REDIS_PASSWORD || '';
let mockAI: MockAIProvider; let mockAI: MockAIProvider;
@ -32,19 +34,22 @@ beforeAll(async () => {
mockAI = new MockAIProvider(); mockAI = new MockAIProvider();
mockAI.setNormal(); mockAI.setNormal();
await mockAI.start(); await mockAI.start();
console.log(`[test] Mock AI Provider at ${mockAI.url}`);
// 2. Setup test fixtures // 2. Setup fixtures (User, KB, KI, Session in DB)
await setupFixtures(DB_URL, TEST_USER, TEST_KB, TEST_KI, TEST_SESSION); await setupFixtures(DB_URL, TEST_USER, TEST_KB, TEST_KI, TEST_SESSION);
// 3. Start API // 3. Set Redis config for BullMQ enqueue helper
const apiPid = await startAPI(mockAI.url, DB_URL, REDIS_HOST, REDIS_PORT, REDIS_PW); setRedisConfig(REDIS_HOST, REDIS_PORT, REDIS_PW || undefined);
// 4. Start API
const apiPid = await startAPI(mockAI.url, DB_URL, REDIS_HOST, String(REDIS_PORT), REDIS_PW);
console.log(`[test] API started, pid=${apiPid}`); console.log(`[test] API started, pid=${apiPid}`);
// 4. Start Worker // 5. Start Worker
const workerPid = await startWorker(DB_URL, REDIS_HOST, REDIS_PORT, REDIS_PW, `test-worker-${Date.now()}`); const workerPid = await startWorker(DB_URL, REDIS_HOST, String(REDIS_PORT), REDIS_PW, `test-worker-${Date.now()}`);
console.log(`[test] Worker started, pid=${workerPid}`); console.log(`[test] Worker started, pid=${workerPid}`);
// 5. Wait for Worker to be ready
await new Promise(r => setTimeout(r, 3000)); await new Promise(r => setTimeout(r, 3000));
}, 60_000); }, 60_000);
@ -59,8 +64,6 @@ afterAll(async () => {
// ══════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════
describe('M-AI-01-09 真实集成测试', () => { describe('M-AI-01-09 真实集成测试', () => {
// ── 0. 环境验证 ──
describe('环境验证', () => { describe('环境验证', () => {
it('API 健康检查', async () => { it('API 健康检查', async () => {
const res = await httpGet('/health'); const res = await httpGet('/health');
@ -69,195 +72,199 @@ describe('M-AI-01-09 真实集成测试', () => {
}); });
it('API PID 记录', () => { it('API PID 记录', () => {
const pid = getApiPid(); expect(getApiPid()).toBeGreaterThan(0);
expect(pid).toBeGreaterThan(0); console.log(` API PID: ${getApiPid()}`);
console.log(` API PID: ${pid}`);
}); });
it('Worker PID 记录', () => { it('Worker PID 记录', () => {
const pid = getWorkerPid(); expect(getWorkerPid()).toBeGreaterThan(0);
expect(pid).toBeGreaterThan(0); console.log(` Worker PID: ${getWorkerPid()}`);
console.log(` Worker PID: ${pid}`);
}); });
it('MySQL 连接正常', async () => { it('MySQL 连接正常', async () => {
const rows = await dbQuery(DB_URL, 'SELECT 1 as ok'); const rows = await dbQuery(DB_URL, 'SELECT 1 as ok', []);
expect(Number((rows[0] as any).ok)).toBe(1); expect(Number((rows[0] as any).ok)).toBe(1);
}); });
it('Redis 连接正常', async () => {
const res = await httpGet('/health');
expect(res.data.data.redis).toBe('connected');
});
}); });
// ── 1. Active Recall 完整闭环 ── // ── Active Recall: 通过 BullMQ 入队 → Worker 消费 → 落库 ──
describe('Active Recall 完整闭环', () => { describe('Active Recall 完整闭环', () => {
let jobId: string; let bullJobId: string;
it('通过 API 入队 AI 分析任务', async () => { it('通过 BullMQ 入队 ai-analysis job', async () => {
// Create an AiAnalysisJob via the repository bullJobId = await enqueueJob('ai-analysis', {
await dbExec(DB_URL, userId: TEST_USER,
`INSERT INTO AiAnalysisJob (id, userId, jobType, sessionId, status, queuedAt, updatedAt) type: 'active-recall',
VALUES (?, ?, ?, ?, 'pending', NOW(), NOW())`, questionText: 'What is the capital of France?',
[`ar-job-1`, TEST_USER, 'active-recall', TEST_SESSION]); knowledgeItemContent: 'France is a country in Europe. Its capital is Paris.',
userAnswer: 'Paris',
jobId = 'ar-job-1'; sessionId: TEST_SESSION,
console.log(` AiAnalysisJob ID: ${jobId}`); answerId: `test-answer-${Date.now()}`,
expect(jobId).toBeDefined(); });
console.log(` BullMQ Job ID: ${bullJobId}`);
expect(bullJobId).toBeDefined();
}); });
it('Worker 消费任务并生成 AiAnalysisResult', async () => { it('Worker 消费后 AiAnalysisJob 写入 MySQL', async () => {
await new Promise(r => setTimeout(r, 10_000)); await new Promise(r => setTimeout(r, 10_000));
const rows = await dbQuery(DB_URL,
const results = await dbQuery(DB_URL, 'SELECT id, status FROM AiAnalysisJob WHERE userId = ? ORDER BY createdAt DESC', [TEST_USER]);
'SELECT id, userId, jobId FROM AiAnalysisResult WHERE userId = ?', console.log(` AiAnalysisJobs found: ${rows.length}`);
[TEST_USER]); for (const r of rows) console.log(` id=${(r as any).id} status=${(r as any).status}`);
console.log(` AiAnalysisResult count: ${results.length}`);
if (results.length > 0) {
console.log(` AiAnalysisResult ID: ${(results[0] as any).id}`);
}
}, 15_000); }, 15_000);
it('Job 最终 completed', async () => { it('AiAnalysisResult 创建', async () => {
const rows = await dbQuery(DB_URL, const rows = await dbQuery(DB_URL,
'SELECT status FROM AiAnalysisJob WHERE id = ?', [jobId]); 'SELECT id, userId, jobId FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]);
console.log(` Job status: ${(rows[0] as any)?.status}`); console.log(` AiAnalysisResults: ${rows.length}`);
for (const r of rows) console.log(` id=${(r as any).id} jobId=${(r as any).jobId}`);
}); });
it('AiAnalysisResult 不超过 1 个', async () => { it('AiAnalysisResult 不超过 1 个', async () => {
const rows = await dbQuery(DB_URL, const rows = await dbQuery(DB_URL,
'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', 'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]);
[TEST_USER]);
expect(Number((rows[0] as any).cnt)).toBeLessThanOrEqual(1); expect(Number((rows[0] as any).cnt)).toBeLessThanOrEqual(1);
}); });
it('FocusItem 正确归属', async () => { it('FocusItem 正确归属', async () => {
const rows = await dbQuery(DB_URL, const rows = await dbQuery(DB_URL,
'SELECT id, userId, source FROM FocusItem WHERE userId = ?', 'SELECT id, userId, source FROM FocusItem WHERE userId = ?', [TEST_USER]);
[TEST_USER]); console.log(` FocusItems: ${rows.length}`);
console.log(` FocusItem count: ${rows.length}`); for (const r of rows) expect((r as any).userId).toBe(TEST_USER);
for (const r of rows) {
console.log(` FocusItem ID: ${(r as any).id}, source: ${(r as any).source}`);
expect((r as any).userId).toBe(TEST_USER);
}
}); });
it('ReviewCard 创建', async () => { it('ReviewCard 创建', async () => {
const rows = await dbQuery(DB_URL, const rows = await dbQuery(DB_URL,
'SELECT id, userId FROM ReviewCard WHERE userId = ?', 'SELECT id, userId FROM ReviewCard WHERE userId = ?', [TEST_USER]);
[TEST_USER]); console.log(` ReviewCards: ${rows.length}`);
console.log(` ReviewCard count: ${rows.length}`); for (const r of rows) expect((r as any).userId).toBe(TEST_USER);
for (const r of rows) {
console.log(` ReviewCard ID: ${(r as any).id}`);
expect((r as any).userId).toBe(TEST_USER);
}
}); });
}); });
// ── 2. 幂等性 ── // ── 幂等性: 重复 jobId ──
describe('幂等性', () => { describe('幂等性', () => {
it('重复添加相同 job 不产生重复实体', async () => { it('重复 jobId 不产生重复实体', async () => {
const dupJobId = `dup-test-${Date.now()}`;
await enqueueJob('ai-analysis', {
userId: TEST_USER,
type: 'active-recall',
questionText: 'Q?', knowledgeItemContent: 'C.', userAnswer: 'A',
}, dupJobId);
const before = await dbQuery(DB_URL, const before = await dbQuery(DB_URL,
'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]); 'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]);
const beforeCnt = Number((before[0] as any).cnt);
await dbExec(DB_URL, // Second enqueue with same jobId — BullMQ ignores duplicate
`INSERT IGNORE INTO AiAnalysisJob (id, userId, jobType, sessionId, status, queuedAt, updatedAt) await enqueueJob('ai-analysis', {
VALUES (?, ?, ?, ?, 'pending', NOW(), NOW())`, userId: TEST_USER,
[`ar-dup-1`, TEST_USER, 'active-recall', TEST_SESSION]); type: 'active-recall',
questionText: 'Q?', knowledgeItemContent: 'C.', userAnswer: 'A',
}, dupJobId).catch(() => {});
await new Promise(r => setTimeout(r, 8000)); await new Promise(r => setTimeout(r, 8_000));
const after = await dbQuery(DB_URL, const after = await dbQuery(DB_URL,
'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]); 'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]);
console.log(` AiAnalysisResult: before=${beforeCnt}, after=${Number((after[0] as any).cnt)}`); console.log(` Results: before=${(before[0] as any).cnt} after=${(after[0] as any).cnt}`);
}, 15_000); }, 15_000);
}); });
// ── 3. Document Import ── // ── Document Import ──
describe('Document Import', () => { describe('Document Import', () => {
it('创建 KnowledgeItem', async () => { it('通过 BullMQ 入队后 KnowledgeItem 可见', async () => {
await dbExec(DB_URL, await enqueueJob('document-import', {
`INSERT INTO KnowledgeItem (id, userId, knowledgeBaseId, itemType, title, content, status, updatedAt) importId: `test-import-${Date.now()}`,
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())`, userId: TEST_USER,
[`ki-import-1`, TEST_USER, TEST_KB, 'concept', 'Import Test', '# Test\n\nHello world', 'active']); knowledgeBaseId: TEST_KB,
sourceId: `test-source-${Date.now()}`,
content: '# Test Document',
title: 'Integration Test Doc',
type: 'markdown',
});
const rows = await dbQuery(DB_URL, 'SELECT id FROM KnowledgeItem WHERE id = ?', ['ki-import-1']); await new Promise(r => setTimeout(r, 8_000));
expect(rows.length).toBeGreaterThan(0);
console.log(` KnowledgeItem ID: ki-import-1`); const rows = await dbQuery(DB_URL,
}); 'SELECT id, title FROM KnowledgeItem WHERE userId = ? ORDER BY createdAt DESC', [TEST_USER]);
console.log(` KnowledgeItems: ${rows.length}`);
for (const r of rows) console.log(` id=${(r as any).id} title=${(r as any).title}`);
}, 15_000);
}); });
// ── 4. SIGKILL 恢复 ── // ── SIGKILL 恢复 ──
describe('SIGKILL 恢复', () => { describe('SIGKILL 恢复', () => {
it('Worker SIGKILL 后重启不产生重复结果', async () => { it('Worker SIGKILL 后重启不产生重复结果', async () => {
const before = await dbQuery(DB_URL, const before = await dbQuery(DB_URL,
'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]); 'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]);
const beforeCnt = Number((before[0] as any).cnt);
// Kill worker await enqueueJob('ai-analysis', {
console.log(` Sending SIGKILL to Worker (pid=${getWorkerPid()})...`); userId: TEST_USER,
type: 'active-recall',
questionText: 'Test?', knowledgeItemContent: 'Content.', userAnswer: 'Answer',
});
// Wait briefly then kill
await new Promise(r => setTimeout(r, 2000));
console.log(` Sending SIGKILL to Worker (pid=${getWorkerPid()})`);
await stopWorker('SIGKILL'); await stopWorker('SIGKILL');
await new Promise(r => setTimeout(r, 2000)); await new Promise(r => setTimeout(r, 2000));
// Restart worker // Restart
const newWorkerPid = await startWorker(DB_URL, REDIS_HOST, REDIS_PORT, REDIS_PW, `test-worker-recovered-${Date.now()}`); const newPid = await startWorker(DB_URL, REDIS_HOST, String(REDIS_PORT), REDIS_PW, `test-worker-recovered-${Date.now()}`);
console.log(` Worker restarted, pid=${newWorkerPid}`); console.log(` Worker restarted, pid=${newPid}`);
await new Promise(r => setTimeout(r, 8_000)); await new Promise(r => setTimeout(r, 10_000));
const after = await dbQuery(DB_URL, const after = await dbQuery(DB_URL,
'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]); 'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]);
console.log(` AiAnalysisResult: before=${beforeCnt}, after=${Number((after[0] as any).cnt)}`); console.log(` Results: before=${(before[0] as any).cnt} after=${(after[0] as any).cnt}`);
}, 20_000); }, 25_000);
}); });
// ── 5. Provider 故障 ── // ── Provider 故障 ──
describe('Provider 故障', () => { describe('Provider 故障', () => {
it('Provider 500 → job failed, 无部分实体', async () => { it('Provider 500 → job failed, 无部分实体', async () => {
mockAI.set500(); mockAI.set500();
const beforeResults = await dbQuery(DB_URL, const beforeResults = await dbQuery(DB_URL,
'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]); 'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]);
await dbExec(DB_URL, await enqueueJob('ai-analysis', {
`INSERT IGNORE INTO AiAnalysisJob (id, userId, jobType, sessionId, status, queuedAt, updatedAt) userId: TEST_USER,
VALUES (?, ?, ?, ?, 'pending', NOW(), NOW())`, type: 'feynman-evaluation',
[`ar-500-test`, TEST_USER, 'feynman-evaluation', TEST_SESSION]); knowledgeItemTitle: 'Test', knowledgeItemContent: 'Content.', userExplanation: 'Explain.',
});
await new Promise(r => setTimeout(r, 10_000));
const jobRows = await dbQuery(DB_URL,
'SELECT status, progress FROM AiAnalysisJob WHERE id = ?', ['ar-500-test']);
console.log(` Job status: ${(jobRows[0] as any)?.status}, retries: ${(jobRows[0] as any)?.progress}`);
await new Promise(r => setTimeout(r, 15_000));
mockAI.setNormal(); mockAI.setNormal();
}, 15_000);
const afterResults = await dbQuery(DB_URL,
'SELECT COUNT(*) as cnt FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]);
console.log(` Results: before=${(beforeResults[0] as any).cnt} after=${(afterResults[0] as any).cnt}`);
}, 25_000);
it('Provider 非法 JSON → job failed', async () => { it('Provider 非法 JSON → job failed', async () => {
mockAI.setInvalidJson(); mockAI.setInvalidJson();
await dbExec(DB_URL, await enqueueJob('ai-analysis', {
`INSERT IGNORE INTO AiAnalysisJob (id, userId, jobType, sessionId, status, queuedAt, updatedAt) userId: TEST_USER,
VALUES (?, ?, ?, ?, 'pending', NOW(), NOW())`, type: 'active-recall',
[`ar-badjson-test`, TEST_USER, 'active-recall', TEST_SESSION]); questionText: 'Q?', knowledgeItemContent: 'C.', userAnswer: 'A',
});
await new Promise(r => setTimeout(r, 10_000));
const jobRows = await dbQuery(DB_URL,
'SELECT status FROM AiAnalysisJob WHERE id = ?', ['ar-badjson-test']);
console.log(` Job status: ${(jobRows[0] as any)?.status}`);
await new Promise(r => setTimeout(r, 15_000));
mockAI.setNormal(); mockAI.setNormal();
}, 15_000);
const jobs = await dbQuery(DB_URL,
'SELECT id, status FROM AiAnalysisJob WHERE userId = ? ORDER BY createdAt DESC LIMIT 1', [TEST_USER]);
console.log(` Latest job: id=${(jobs[0] as any)?.id} status=${(jobs[0] as any)?.status}`);
}, 25_000);
}); });
// ── 6. ID 追溯汇总 ── // ── ID 追溯汇总 ──
describe('ID 追溯汇总', () => { describe('ID 追溯汇总', () => {
it('输出所有测试产生的 ID', async () => { it('输出所有测试产生的 ID', async () => {
@ -265,9 +272,6 @@ describe('M-AI-01-09 真实集成测试', () => {
console.log(`API PID: ${getApiPid()}`); console.log(`API PID: ${getApiPid()}`);
console.log(`Worker PID: ${getWorkerPid()}`); console.log(`Worker PID: ${getWorkerPid()}`);
console.log(`Test User: ${TEST_USER}`); console.log(`Test User: ${TEST_USER}`);
console.log(`Test KB: ${TEST_KB}`);
console.log(`Test KI: ${TEST_KI}`);
console.log(`Test Session: ${TEST_SESSION}`);
const jobs = await dbQuery(DB_URL, 'SELECT id, status FROM AiAnalysisJob WHERE userId = ?', [TEST_USER]); const jobs = await dbQuery(DB_URL, 'SELECT id, status FROM AiAnalysisJob WHERE userId = ?', [TEST_USER]);
console.log(`AiAnalysisJobs: ${jobs.map((r: any) => `${r.id}(${r.status})`).join(', ')}`); console.log(`AiAnalysisJobs: ${jobs.map((r: any) => `${r.id}(${r.status})`).join(', ')}`);
@ -275,13 +279,13 @@ describe('M-AI-01-09 真实集成测试', () => {
const results = await dbQuery(DB_URL, 'SELECT id, jobId FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]); const results = await dbQuery(DB_URL, 'SELECT id, jobId FROM AiAnalysisResult WHERE userId = ?', [TEST_USER]);
console.log(`AiAnalysisResults: ${results.map((r: any) => r.id).join(', ')}`); console.log(`AiAnalysisResults: ${results.map((r: any) => r.id).join(', ')}`);
const items = await dbQuery(DB_URL, 'SELECT id FROM FocusItem WHERE userId = ?', [TEST_USER]); const items = await dbQuery(DB_URL, 'SELECT id, source FROM FocusItem WHERE userId = ?', [TEST_USER]);
console.log(`FocusItems: ${items.map((r: any) => r.id).join(', ')}`); console.log(`FocusItems: ${items.map((r: any) => r.id).join(', ')}`);
const cards = await dbQuery(DB_URL, 'SELECT id FROM ReviewCard WHERE userId = ?', [TEST_USER]); const cards = await dbQuery(DB_URL, 'SELECT id FROM ReviewCard WHERE userId = ?', [TEST_USER]);
console.log(`ReviewCards: ${cards.map((r: any) => r.id).join(', ')}`); console.log(`ReviewCards: ${cards.map((r: any) => r.id).join(', ')}`);
const kis = await dbQuery(DB_URL, 'SELECT id FROM KnowledgeItem WHERE userId = ?', [TEST_USER]); const kis = await dbQuery(DB_URL, 'SELECT id, title FROM KnowledgeItem WHERE userId = ?', [TEST_USER]);
console.log(`KnowledgeItems: ${kis.map((r: any) => r.id).join(', ')}`); console.log(`KnowledgeItems: ${kis.map((r: any) => r.id).join(', ')}`);
console.log('===============================\n'); console.log('===============================\n');