插件
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { loadConfig, saveConfig } from '../push/config.js';
|
||||
|
||||
describe('config', () => {
|
||||
beforeEach(() => {
|
||||
// 内存版 chrome.storage.local,验证 load/save 往返(不依赖真实 chrome)
|
||||
const store = {};
|
||||
globalThis.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: vi.fn(async key => {
|
||||
const k = typeof key === 'string' ? key : key[0];
|
||||
return k in store ? { [k]: store[k] } : {};
|
||||
}),
|
||||
set: vi.fn(async obj => { Object.assign(store, obj); }),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('loadConfig returns disabled default when unset', async () => {
|
||||
const cfg = await loadConfig();
|
||||
expect(cfg.enabled).toBe(false);
|
||||
expect(cfg.serverUrl).toBe('');
|
||||
});
|
||||
|
||||
it('saveConfig then loadConfig round-trips', async () => {
|
||||
await saveConfig({ enabled: true, serverUrl: 'http://x', token: 't' });
|
||||
const cfg = await loadConfig();
|
||||
expect(cfg.enabled).toBe(true);
|
||||
expect(cfg.serverUrl).toBe('http://x');
|
||||
expect(cfg.token).toBe('t');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildIngestPayload } from '../push/payload.js';
|
||||
|
||||
describe('buildIngestPayload', () => {
|
||||
it('maps record + messages to ingest schema', () => {
|
||||
const record = {
|
||||
inquiryId: '13776617920',
|
||||
manager: 'cara Hu',
|
||||
createTimeFromApi: '2026-03-13T10:11:50Z',
|
||||
updateTimeFromApi: '2026-07-13T10:11:50Z',
|
||||
status: '洽谈中',
|
||||
msgPreview: 'hi',
|
||||
isFlagged: false,
|
||||
customerId: 'cid-1',
|
||||
detailBuyerName: 'Carlos',
|
||||
detailCountry: 'PE',
|
||||
company: 'World Shop',
|
||||
registerTime: '2025-06-23T00:00:00Z',
|
||||
behavior: { productViews: '0', validRFQ: '39' },
|
||||
};
|
||||
const messages = [
|
||||
{
|
||||
messageId: 'm1',
|
||||
sendTime: '2026-07-13T02:11:50Z',
|
||||
sender: 'cara Hu',
|
||||
original: 'hello',
|
||||
direction: 'outgoing',
|
||||
},
|
||||
];
|
||||
const out = buildIngestPayload(record, messages);
|
||||
expect(out.inquiry.inquiryId).toBe('13776617920');
|
||||
expect(out.inquiry.createTime).toBe('2026-03-13T10:11:50Z');
|
||||
expect(out.customer.customerId).toBe('cid-1');
|
||||
expect(out.customer.country).toBe('PE');
|
||||
expect(out.customer.behavior.productViews).toBe(0); // numeric string -> number
|
||||
expect(out.customer.behavior.validRfq).toBe(39);
|
||||
expect(out.messages[0].messageId).toBe('m1');
|
||||
expect(out.messages[0].content).toBe('hello'); // original -> content
|
||||
});
|
||||
|
||||
it('null customer when no customerId', () => {
|
||||
const out = buildIngestPayload(
|
||||
{
|
||||
inquiryId: 'X',
|
||||
createTimeFromApi: '2026-01-01T00:00:00Z',
|
||||
updateTimeFromApi: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
[
|
||||
{
|
||||
messageId: 'm',
|
||||
sendTime: '2026-01-01T00:00:00Z',
|
||||
direction: 'outgoing',
|
||||
content: 'c',
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(out.customer).toBeNull();
|
||||
});
|
||||
|
||||
it('allows empty messages (chat deferred to later phase)', () => {
|
||||
const out = buildIngestPayload(
|
||||
{ inquiryId: 'X', createTimeFromApi: '2026-01-01T00:00:00Z', updateTimeFromApi: '2026-01-01T00:00:00Z' },
|
||||
[],
|
||||
);
|
||||
expect(out.messages).toEqual([]);
|
||||
expect(out.inquiry.inquiryId).toBe('X');
|
||||
});
|
||||
|
||||
it('non-ISO createTime (list page "11:58") falls back to current ISO', () => {
|
||||
const out = buildIngestPayload({ inquiryId: 'X', createTime: '11:58', updateTime: '13:39' }, []);
|
||||
expect(out.inquiry.createTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/);
|
||||
expect(out.inquiry.updateTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
it('empty string -> null for optional fields', () => {
|
||||
const out = buildIngestPayload(
|
||||
{
|
||||
inquiryId: 'X',
|
||||
createTimeFromApi: '2026-01-01T00:00:00Z',
|
||||
updateTimeFromApi: '2026-01-01T00:00:00Z',
|
||||
customerId: 'c',
|
||||
email: '',
|
||||
company: '',
|
||||
},
|
||||
[
|
||||
{
|
||||
messageId: 'm',
|
||||
sendTime: '2026-01-01T00:00:00Z',
|
||||
direction: 'outgoing',
|
||||
content: 'c',
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(out.customer.email).toBeNull();
|
||||
expect(out.customer.company).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { addItem, getPending, markStatus, remove, getStats } from '../push/queue.js';
|
||||
|
||||
const DB_NAME = 'inquiry_push';
|
||||
|
||||
describe('queue', () => {
|
||||
beforeEach(async () => {
|
||||
// Each test starts with a clean DB. fake-indexeddb exposes indexedDB
|
||||
// globally via the setupFiles entry. databases() may be missing on
|
||||
// some builds, so fall back to the known DB name we use in queue.js.
|
||||
const dbs = (await indexedDB.databases?.().catch(() => null)) || [];
|
||||
const names = dbs.length ? dbs.map(d => d.name) : [DB_NAME];
|
||||
await Promise.all(names.map(name => new Promise(r => {
|
||||
if (!name) { r(); return; }
|
||||
const req = indexedDB.deleteDatabase(name);
|
||||
req.onsuccess = req.onerror = req.onblocked = () => r();
|
||||
})));
|
||||
});
|
||||
|
||||
it('addItem then getPending returns it', async () => {
|
||||
const id = await addItem({
|
||||
payload: { record: { inquiryId: 'X1' }, messages: [{ messageId: 'm1' }] },
|
||||
inquiryId: 'X1',
|
||||
});
|
||||
const pending = await getPending(5);
|
||||
expect(pending.length).toBe(1);
|
||||
expect(pending[0].id).toBe(id);
|
||||
expect(pending[0].status).toBe('pending');
|
||||
});
|
||||
|
||||
it('markStatus pending->sent then not in pending', async () => {
|
||||
const id = await addItem({
|
||||
payload: { record: {}, messages: [] },
|
||||
inquiryId: 'X',
|
||||
});
|
||||
await markStatus(id, 'sent');
|
||||
expect((await getPending(5)).length).toBe(0);
|
||||
});
|
||||
|
||||
it('getStats counts by status', async () => {
|
||||
await addItem({ payload: { record: {}, messages: [] }, inquiryId: 'A' });
|
||||
const id2 = await addItem({ payload: { record: {}, messages: [] }, inquiryId: 'B' });
|
||||
await markStatus(id2, 'failed', { retries: 8 });
|
||||
const stats = await getStats();
|
||||
expect(stats.pending).toBe(1);
|
||||
expect(stats.failed).toBe(1);
|
||||
});
|
||||
|
||||
it('remove deletes item', async () => {
|
||||
const id = await addItem({ payload: { record: {}, messages: [] }, inquiryId: 'A' });
|
||||
await remove(id);
|
||||
expect((await getPending(5)).length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
// 复刻函数漂移防护:content.js / popup.js 的内联函数块必须与 scripts/verify-chat-range.mjs
|
||||
// 基准照搬区逐字一致。比对前统一 CRLF→LF(content.js 全文件 CRLF,基准/popup 为 LF,
|
||||
// 行尾风格非漂移信号)并去尾部空白(切块端点的空行/缩进差异非漂移信号);
|
||||
// 除此之外必须逐字节一致 —— 改基准或实现任一侧后此测试变红 = 你忘了同步另一侧。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const read = p => readFileSync(new URL(p, import.meta.url), 'utf8');
|
||||
const verify = read('../scripts/verify-chat-range.mjs');
|
||||
const content = read('../content.js');
|
||||
const popup = read('../popup.js');
|
||||
|
||||
// CRLF→LF + 去尾部空白(仅此两项豁免,内容仍须逐字一致)
|
||||
const norm = s => s.replace(/\r\n/g, '\n').replace(/\s+$/, '');
|
||||
|
||||
// 从 src 中切出 [startMarker, endMarker) 之间的文本(含 start,不含 end)
|
||||
function sliceBetween(src, startMarker, endMarker) {
|
||||
const s = src.indexOf(startMarker);
|
||||
if (s < 0) return null;
|
||||
const e = endMarker ? src.indexOf(endMarker, s) : -1;
|
||||
return src.slice(s, e < 0 ? undefined : e);
|
||||
}
|
||||
|
||||
describe('replica sync drift guard', () => {
|
||||
it('content.js 5-function block byte-matches verify baseline region', () => {
|
||||
const base = sliceBetween(verify, '// 范围过滤(null sendTime 防御性保留', '// ---- 用例');
|
||||
const inl = sliceBetween(content, '// 范围过滤(null sendTime 防御性保留', '// ============ 安全层:风控关键词');
|
||||
expect(base).toBeTruthy();
|
||||
expect(inl).toBeTruthy();
|
||||
expect(norm(inl)).toBe(norm(base));
|
||||
});
|
||||
|
||||
it('popup.js minusMonths+computeChatRange byte-matches verify baseline region', () => {
|
||||
const base = sliceBetween(verify, '// 自然月回退:直接 setMonth', '// 范围过滤(null sendTime 防御性保留');
|
||||
const inl = sliceBetween(popup, '// 自然月回退:直接 setMonth', 'function syncChatModeUI');
|
||||
expect(base).toBeTruthy();
|
||||
expect(inl).toBeTruthy();
|
||||
expect(norm(inl)).toBe(norm(base));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { addItem } from '../push/queue.js';
|
||||
import { processQueue } from '../push/worker.js';
|
||||
|
||||
const DB_NAME = 'inquiry_push';
|
||||
|
||||
async function dumpAll() {
|
||||
// Bypass getPending (which filters by status/nextRetry) to inspect raw rows.
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME);
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
const tx = db.transaction('queue', 'readonly').objectStore('queue').getAll();
|
||||
tx.onsuccess = () => { db.close(); resolve(tx.result); };
|
||||
tx.onerror = () => { db.close(); reject(tx.error); };
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
// worker 无状态:每次 processQueue 自己从 chrome.storage 读配置(模拟已保存的启用配置)。
|
||||
// 不再用 configure() 注入 —— 模块级配置会在 SW 休眠重启后丢失(正是 B 的根因)。
|
||||
function mockStorage(config) {
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: vi.fn().mockResolvedValue({ push_config: config }) } },
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const dbs = (await indexedDB.databases?.().catch(() => null)) || [];
|
||||
const names = dbs.length ? dbs.map(d => d.name) : [DB_NAME];
|
||||
await Promise.all(names.map(name => new Promise(r => {
|
||||
if (!name) { r(); return; }
|
||||
const req = indexedDB.deleteDatabase(name);
|
||||
req.onsuccess = req.onerror = req.onblocked = () => r();
|
||||
})));
|
||||
mockStorage({ enabled: true, serverUrl: 'http://test', token: 'tok' });
|
||||
});
|
||||
|
||||
describe('worker', () => {
|
||||
it('pushes when SW resumed without configure() — reads config from storage (regression for B)', async () => {
|
||||
// SW 休眠重启后模块级配置丢失;worker 必须自己从 storage 读,不能依赖 configure()。
|
||||
// beforeEach 已 mock 启用配置,此处故意不调 configure —— 复现并锁定 B 的修复。
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
await addItem({
|
||||
payload: {
|
||||
record: {
|
||||
inquiryId: 'X',
|
||||
createTimeFromApi: '2026-01-01T00:00:00Z',
|
||||
updateTimeFromApi: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
messages: [],
|
||||
},
|
||||
inquiryId: 'X',
|
||||
});
|
||||
const r = await processQueue();
|
||||
expect(r.processed).toBe(1);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('POSTs pending, marks sent on 200', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
globalThis.fetch = fetchMock;
|
||||
const id = await addItem({
|
||||
payload: {
|
||||
record: {
|
||||
inquiryId: 'X',
|
||||
createTimeFromApi: '2026-01-01T00:00:00Z',
|
||||
updateTimeFromApi: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
messageId: 'm1',
|
||||
sendTime: '2026-01-01T00:00:00Z',
|
||||
direction: 'outgoing',
|
||||
content: 'c',
|
||||
},
|
||||
],
|
||||
},
|
||||
inquiryId: 'X',
|
||||
});
|
||||
const r = await processQueue();
|
||||
expect(r.processed).toBe(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://test/api/ingest');
|
||||
const all = await dumpAll();
|
||||
expect(all.find(x => x.id === id).status).toBe('sent');
|
||||
});
|
||||
|
||||
it('successful push counts as sent in getStats (not silently removed)', async () => {
|
||||
// 显示 bug 回归:成功推送的记录必须能在 getStats 里看到(sent),
|
||||
// 否则 popup 永远显示"已推 0"(旧实现成功即 remove → 统计不到)。
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
await addItem({
|
||||
payload: {
|
||||
record: {
|
||||
inquiryId: 'X',
|
||||
createTimeFromApi: '2026-01-01T00:00:00Z',
|
||||
updateTimeFromApi: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
messages: [],
|
||||
},
|
||||
inquiryId: 'X',
|
||||
});
|
||||
await processQueue();
|
||||
const { getStats } = await import('../push/queue.js');
|
||||
const stats = await getStats();
|
||||
expect(stats.sent).toBe(1);
|
||||
});
|
||||
|
||||
it('on failure, retries++ and nextRetry in future', async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network'));
|
||||
const id = await addItem({
|
||||
payload: {
|
||||
record: {
|
||||
inquiryId: 'X',
|
||||
createTimeFromApi: '2026-01-01T00:00:00Z',
|
||||
updateTimeFromApi: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
messageId: 'm',
|
||||
sendTime: '2026-01-01T00:00:00Z',
|
||||
direction: 'outgoing',
|
||||
content: 'c',
|
||||
},
|
||||
],
|
||||
},
|
||||
inquiryId: 'X',
|
||||
});
|
||||
await processQueue();
|
||||
const all = await dumpAll();
|
||||
const item = all.find(x => x.id === id);
|
||||
expect(item.status).toBe('pending');
|
||||
expect(item.retries).toBe(1);
|
||||
expect(item.nextRetry).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('after MAX_RETRIES, mark failed', async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network'));
|
||||
const id = await addItem({
|
||||
payload: {
|
||||
record: {
|
||||
inquiryId: 'X',
|
||||
createTimeFromApi: '2026-01-01T00:00:00Z',
|
||||
updateTimeFromApi: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
messageId: 'm',
|
||||
sendTime: '2026-01-01T00:00:00Z',
|
||||
direction: 'outgoing',
|
||||
content: 'c',
|
||||
},
|
||||
],
|
||||
},
|
||||
inquiryId: 'X',
|
||||
});
|
||||
// Pre-seed retries=7 so one more failure pushes it to 8 -> failed.
|
||||
const { markStatus } = await import('../push/queue.js');
|
||||
await markStatus(id, 'pending', { retries: 7, nextRetry: 0 });
|
||||
await processQueue();
|
||||
const all = await dumpAll();
|
||||
const item = all.find(x => x.id === id);
|
||||
expect(item.status).toBe('failed');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user