-
+
+
+
+
@@ -709,6 +732,7 @@ export function mountPanel(cb: PanelCallbacks): void {
const pickerList = pickerEl.querySelector('.list')!
const sentinel = pickerList.querySelector('.sentinel')!
const pickerSearch = pickerEl.querySelector('input[type="search"]')!
+ const pickerSrc = pickerEl.querySelector('select.src')!
const pickerEmpty = pickerEl.querySelector('.empty')!
const pickerCount = pickerEl.querySelector('.count')!
const segButtons = [...panel.querySelectorAll('.seg button')]
@@ -880,7 +904,7 @@ export function mountPanel(cb: PanelCallbacks): void {
for (const item of items) {
const row = document.createElement('label')
row.className = 'row'
- row.title = item.title
+ row.title = item.project ? `${item.title}(${item.project})` : item.title
const box = document.createElement('input')
box.type = 'checkbox'
box.dataset['id'] = item.id
@@ -890,7 +914,14 @@ export function mountPanel(cb: PanelCallbacks): void {
const d = document.createElement('span')
d.className = 'd'
d.textContent = item.updated
- row.append(box, t, d)
+ if (item.project) {
+ const p = document.createElement('span')
+ p.className = 'p'
+ p.textContent = item.project
+ row.append(box, t, p, d)
+ } else {
+ row.append(box, t, d)
+ }
// 始终插在哨兵之前,哨兵保持在列表末尾
sentinel.before(row)
}
@@ -904,6 +935,17 @@ export function mountPanel(cb: PanelCallbacks): void {
// 需要主动续拉,否则懒加载会停在第一页。
if (!done) queueMicrotask(maybeAutoFill)
},
+ setPickerProjects: (projects) => {
+ const keep = pickerSrc.value
+ while (pickerSrc.options.length > 2) pickerSrc.remove(2)
+ for (const project of projects) {
+ const option = document.createElement('option')
+ option.value = project.id
+ option.textContent = project.name
+ pickerSrc.add(option)
+ }
+ pickerSrc.value = [...pickerSrc.options].some((option) => option.value === keep) ? keep : 'all'
+ },
clearPicker: () => {
for (const r of rows()) r.remove()
listLoaded = false
@@ -948,15 +990,17 @@ export function mountPanel(cb: PanelCallbacks): void {
sentinel.addEventListener('click', requestMore)
- /** 重置并拉第一页(首次进入「选择」/ 点重新拉取按钮) */
+ /** 重置并拉第一页(首次进入「选择」/ 点重新拉取 / 切换来源) */
function loadList(): void {
handle.clearPicker()
pickerSearch.value = ''
listLoading = true
sentinel.textContent = '加载中…'
- cb.onPickList(handle)
+ cb.onPickList(handle, pickerSrc.value)
}
+ pickerSrc.addEventListener('change', loadList)
+
for (const btn of segButtons) {
btn.addEventListener('click', () => {
const group = btn.parentElement!.dataset['seg']!
diff --git a/test/batch-safety.test.ts b/test/batch-safety.test.ts
new file mode 100644
index 0000000..7c9ef5a
--- /dev/null
+++ b/test/batch-safety.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, test } from 'bun:test'
+import {
+ BatchSafetyError,
+ createBatchSafetyGuard,
+ failureLimitReached,
+ type BatchPolicy,
+} from '../src/core/batch-safety'
+import type { FetchStats } from '../src/core/fetcher'
+
+const policy: BatchPolicy = {
+ concurrency: 1,
+ retryFailed: false,
+ retryDelayMs: 0,
+ failureAbortMin: 5,
+ failureAbortRatio: 0.25,
+ max429Hits: 3,
+ maxRetryAfterHits: 1,
+ maxRequests: 1000,
+}
+
+const stats = (patch: Partial = {}): FetchStats => ({
+ spacingMs: 1500,
+ requests: 0,
+ hits429: 0,
+ retryAfterHits: 0,
+ cooldownMs: 0,
+ maxRetryAfterSec: 0,
+ ...patch,
+})
+
+describe('批量导出限流熔断', () => {
+ test('只计算本批次新增的 429,不受此前统计污染', () => {
+ let current = stats({ hits429: 7, retryAfterHits: 2 })
+ const check = createBatchSafetyGuard(policy, () => current)
+ expect(() => check()).not.toThrow()
+
+ current = stats({ hits429: 9, retryAfterHits: 2 })
+ expect(() => check()).not.toThrow()
+ })
+
+ test('无 Retry-After 的 429 累计达到 3 次就停止', () => {
+ let current = stats()
+ const check = createBatchSafetyGuard(policy, () => current)
+ current = stats({ hits429: 3 })
+ expect(() => check()).toThrow(BatchSafetyError)
+ expect(() => check()).toThrow('本批次已遇到 3 次 HTTP 429')
+ })
+
+ test('一次带 Retry-After 的全局限流信号就停止', () => {
+ let current = stats()
+ const check = createBatchSafetyGuard(policy, () => current)
+ current = stats({ hits429: 1, retryAfterHits: 1, maxRetryAfterSec: 60 })
+ expect(() => check()).toThrow('Retry-After')
+ })
+
+ test('总请求数包含内部重试,达到批次上限就停止', () => {
+ let current = stats({ requests: 40 })
+ const check = createBatchSafetyGuard({ ...policy, maxRequests: 3 }, () => current)
+ current = stats({ requests: 43 })
+ expect(() => check()).toThrow('达到安全上限')
+ })
+})
+
+describe('批量导出失败率护栏', () => {
+ test('未达到最小失败数时不误杀小样本', () => {
+ expect(failureLimitReached(policy, 4, 4)).toBe(false)
+ })
+
+ test('同时达到最小失败数并超过失败率才停止', () => {
+ expect(failureLimitReached(policy, 5, 20)).toBe(false)
+ expect(failureLimitReached(policy, 6, 20)).toBe(true)
+ })
+})
diff --git a/test/claude-pager.test.ts b/test/claude-pager.test.ts
index f5c41b9..553cb82 100644
--- a/test/claude-pager.test.ts
+++ b/test/claude-pager.test.ts
@@ -1,10 +1,16 @@
import { describe, expect, test } from 'bun:test'
import { ApiError } from '../src/core/fetcher'
-import { createConversationPager, type FetchPage } from '../src/sites/claude/api'
+import {
+ createConversationPager,
+ listAllConversations,
+ resolveOrgId,
+ type FetchPage,
+} from '../src/sites/claude/api'
import type { ClaudeConversationListItem } from '../src/sites/claude/types'
// api.ts 拼 URL 要用 location.origin,bun 环境里补一个
;(globalThis as unknown as { location: { origin: string } }).location = { origin: 'https://claude.ai' }
+;(globalThis as unknown as { document: { cookie: string } }).document = { cookie: 'lastActiveOrg=org-from-cookie' }
const items = (n: number, from = 0): ClaudeConversationListItem[] =>
Array.from({ length: n }, (_, i) => ({ uuid: `c${from + i}` }))
@@ -97,4 +103,60 @@ describe('claude 分页器', () => {
expect(again).toEqual({ items: [], done: true })
expect(calls.length).toBe(1)
})
+
+ test('listAll 复用同一分页器并逐页报告进度', async () => {
+ const { fetchPage } = pages(({ offset }) => (offset < 60 ? items(30, offset) : []))
+ const progress: number[] = []
+ const all = await listAllConversations('org', (n) => progress.push(n), undefined, {
+ fetchPage,
+ emptyRetryBaseMs: 0,
+ })
+ expect(all).toHaveLength(60)
+ expect(progress).toEqual([30, 60])
+ })
+
+ test('进度回调触发风控异常时立即停止继续翻页', async () => {
+ const { calls, fetchPage } = pages(({ offset }) => items(5, offset))
+ await expect(
+ listAllConversations(
+ 'org',
+ () => {
+ throw new Error('stop-by-risk-guard')
+ },
+ undefined,
+ { fetchPage, emptyRetryBaseMs: 0 },
+ ),
+ ).rejects.toThrow('stop-by-risk-guard')
+ expect(calls).toHaveLength(1)
+ })
+
+ test('分页请求超过安全上限时停止,不继续空转', async () => {
+ const { calls, fetchPage } = pages(({ offset }) => items(5, offset))
+ const pager = createConversationPager('org', undefined, {
+ fetchPage,
+ emptyRetryBaseMs: 0,
+ maxRequests: 2,
+ })
+ await pager.next()
+ await pager.next()
+ await expect(pager.next()).rejects.toThrow('分页请求已达安全上限')
+ expect(calls).toHaveLength(2)
+ })
+
+ test('累计条目超过安全上限时停止', async () => {
+ const { fetchPage } = pages(({ offset }) => items(5, offset))
+ const pager = createConversationPager('org', undefined, {
+ fetchPage,
+ emptyRetryBaseMs: 0,
+ maxItems: 8,
+ })
+ await pager.next()
+ await expect(pager.next()).rejects.toThrow('对话数已超过安全上限')
+ })
+})
+
+describe('Claude 会话准备', () => {
+ test('已取消时不能吞掉取消异常并回退 cookie 继续执行', async () => {
+ await expect(resolveOrgId({ cancelled: true })).rejects.toThrow('已取消')
+ })
})
diff --git a/test/claude-ui.test.ts b/test/claude-ui.test.ts
new file mode 100644
index 0000000..45c736f
--- /dev/null
+++ b/test/claude-ui.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, test } from 'bun:test'
+import { claudeAdapter } from '../src/sites/claude'
+
+describe('Claude 界面主题', () => {
+ test('重点色固定为接近 Claude 图标的珊瑚橙', () => {
+ expect(claudeAdapter.ui.accent(() => null)).toEqual({
+ bg: [217, 119, 87],
+ fg: null,
+ ring: [217, 119, 87],
+ })
+ })
+})
diff --git a/test/fetcher.test.ts b/test/fetcher.test.ts
new file mode 100644
index 0000000..401668f
--- /dev/null
+++ b/test/fetcher.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, test } from 'bun:test'
+import { createFetcher, parseRetryAfterSeconds } from '../src/core/fetcher'
+
+describe('Fetcher 限流统计', () => {
+ test('Retry-After 同时支持秒数和 HTTP-date', () => {
+ const now = Date.parse('2026-08-29T00:00:00Z')
+ expect(parseRetryAfterSeconds('60', now)).toBe(60)
+ expect(parseRetryAfterSeconds('Sat, 29 Aug 2026 00:01:00 GMT', now)).toBe(60)
+ })
+
+ test('重试耗尽的最后一次 429 也必须计入熔断统计', async () => {
+ const originalFetch = globalThis.fetch
+ globalThis.fetch = (() =>
+ Promise.resolve(
+ new Response('', { status: 429, headers: { 'Retry-After': '60' } }),
+ )) as unknown as typeof fetch
+ try {
+ const fetcher = createFetcher({
+ spacingBaseMs: 0,
+ spacingMaxMs: 0,
+ restEveryN: 0,
+ restDurationMs: 0,
+ maxAttempts: 0,
+ })
+ await expect(fetcher.request('https://example.test/rate-limited')).rejects.toThrow('HTTP 429')
+ expect(fetcher.stats()).toMatchObject({
+ requests: 1,
+ hits429: 1,
+ retryAfterHits: 1,
+ maxRetryAfterSec: 60,
+ })
+ } finally {
+ globalThis.fetch = originalFetch
+ }
+ })
+})
diff --git a/test/markdown.test.ts b/test/markdown.test.ts
index 4b2af57..4d625a1 100644
--- a/test/markdown.test.ts
+++ b/test/markdown.test.ts
@@ -18,6 +18,15 @@ describe('conversationToMarkdown', () => {
expect(markdown).toContain('tags:\n - chatgpt')
})
+ test('project 会话使用 gizmo 地址并写入 project 名', () => {
+ const conv = { ...fixture, gizmo_id: 'g-p-project' }
+ const { markdown: md } = conversationToMarkdown(conv, '', { projectName: '研究计划' })
+ expect(md).toContain(
+ 'url: https://chatgpt.com/g/g-p-project/c/abc12345-6789-4def-8012-3456789abcde',
+ )
+ expect(md).toContain('project: "研究计划"')
+ })
+
test('User / ChatGPT 作为最高级标题', () => {
expect(markdown).toContain('\n# User\n')
expect(markdown).toContain('\n# ChatGPT\n')
diff --git a/test/ui-position.test.ts b/test/ui-position.test.ts
index 5d4e97a..30a3cd4 100644
--- a/test/ui-position.test.ts
+++ b/test/ui-position.test.ts
@@ -37,4 +37,17 @@ describe('computeFabPlacement', () => {
),
).toEqual({ right: 110, bottom: 945, panelTop: 48 })
})
+
+ test('Claude 首页 header 贴在隐身模式动作槽左侧', () => {
+ // /new 实测:#dframe-header-actions-slot = x 578–610、y 8–40,viewport 630×898。
+ expect(
+ computeFabPlacement(
+ 'header',
+ { top: 8, right: 610, bottom: 40, left: 578, height: 32 },
+ { width: 630, height: 898 },
+ 28,
+ 8,
+ ),
+ ).toEqual({ right: 60, bottom: 860, panelTop: 50 })
+ })
})