Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,97 @@ describe('MyIssuesService.list', () => {
expect(result.githubEnhancementFailed).toBe(true);
});

describe('首屏快照写入', () => {
it('落地成功后写快照,只带 items 与身份', async () => {
const writeSnapshot = vi.fn();
const service = new MyIssuesService(
makeDeps({
now: () => Date.parse('2026-07-31T12:00:00.000Z'),
readLedger: () => [ledgerRecord()],
resolveGithubEnhancement: async () => GHOST_VIEWER,
searchAuthoredIssues: async () => ({ issues: [remoteIssue({ number: 7 })], totalCount: 1 }),
writeSnapshot,
}),
);

await service.list();
expect(writeSnapshot).toHaveBeenCalledTimes(1);
const snapshot = writeSnapshot.mock.calls[0]![0];
expect(snapshot.items.map((i: { number: number }) => i.number)).toEqual([7, 1001]);
expect(snapshot.githubEnhancement).toEqual({ login: 'octocat', source: 'ghost' });
expect(snapshot.cachedAt).toBe('2026-07-31T12:00:00.000Z');
// 「这一次查得怎么样」不进快照 —— 否则用户进页面就看到一条过期的错误提示。
expect(snapshot).not.toHaveProperty('degraded');
expect(snapshot).not.toHaveProperty('githubEnhancementFailed');
expect(snapshot).not.toHaveProperty('truncated');
});

it('落地时账号已切换 → 不写快照(结果本身也被拒绝交付)', async () => {
let scope = 'owner-a:1';
const writeSnapshot = vi.fn();
const service = new MyIssuesService(
makeDeps({
readScope: () => scope,
fetchPlatformIssues: async () => {
scope = 'owner-b:2';
return { ok: true as const, page: { issues: [remoteIssue()], totalCount: 1 } };
},
writeSnapshot,
}),
);

await expect(service.list()).rejects.toSatisfy(isStaleAccountScopeError);
// 快照按 owner 路径落盘,写进去就等于把 A 的 issue 塞进 B 的首屏。
expect(writeSnapshot).not.toHaveBeenCalled();
});

it('期间有提交成功(epoch 变了)→ 不写快照,与内存缓存同一判据', async () => {
const writeSnapshot = vi.fn();
let release: (() => void) | null = null;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const service = new MyIssuesService(
makeDeps({
fetchPlatformIssues: async () => {
await gate;
return { ok: true as const, page: { issues: [remoteIssue()], totalCount: 1 } };
},
writeSnapshot,
}),
);

const pending = service.list();
service.invalidate(); // 提交成功 → 账本变了
release!();
await pending;

// 落一份已知过时的首屏镜像没有收益(下次进页面反正要查)。
expect(writeSnapshot).not.toHaveBeenCalled();
});

it('写快照抛错不影响这一次查询的结果', async () => {
const service = new MyIssuesService(
makeDeps({
readLedger: () => [ledgerRecord()],
writeSnapshot: () => {
throw new Error('ENOSPC: no space left on device');
},
}),
);

await expect(service.list()).resolves.toMatchObject({
items: [expect.objectContaining({ number: 1001 })],
});
});

it('没注入 writeSnapshot 时照常工作(快照是可选加速)', async () => {
const service = new MyIssuesService(makeDeps({ readLedger: () => [ledgerRecord()] }));
const result = await service.list();
expect(result.items.map((i) => i.number)).toEqual([1001]);
});
});

describe('主通道搜不到时的兜底', () => {
/**
* 现实成因(实测):插件 PAT 是 fine-grained token,`get_current_user` 正常、搜本仓
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* 首屏快照的账号隔离回归。
*
* 快照里有 issue 标题与 GitHub 用户名 —— 是账号私有数据,不是可共享的缓存。存储走
* ownerScopedUserDataPath(),换号后必须读不到上一个账号的快照(否则切号瞬间的首屏会
* 闪出别人的 issue 列表)。
*
* 这里钉住「store 实例跟着 owner 路径重建」这一条 —— electron-store 被 mock,不碰真实
* 文件系统(mock 形状照 submittedIssueLedgerScope.test.ts)。
*/

import { beforeEach, describe, expect, it, vi } from 'vitest';

import type { MyIssuesSnapshot } from '../../../shared/myIssues';

const ownerPathRef = { value: '/tmp/cindy-test-owner-a' };
/** 按 owner 路径分桶,模拟真实的「每个账号一个目录」。 */
const buckets: Record<string, Record<string, unknown>> = {};

vi.mock('../../appSessionState.js', () => ({
ownerScopedUserDataPath: () => ownerPathRef.value,
}));

vi.mock('electron-store', () => ({
default: class FakeStore {
private readonly bucket: Record<string, unknown>;
constructor(options: { cwd: string }) {
buckets[options.cwd] ??= {};
this.bucket = buckets[options.cwd]!;
}
get(key: string, fallback: unknown) {
return this.bucket[key] ?? fallback;
}
set(key: string, value: unknown) {
this.bucket[key] = value;
}
},
}));

const { readMyIssuesSnapshot, writeMyIssuesSnapshot } = await import('../myIssuesSnapshotStore');

function snapshot(over: Partial<MyIssuesSnapshot> = {}): MyIssuesSnapshot {
return {
items: [
{
number: 1061,
url: 'https://github.com/makecindy/cindy/issues/1061',
title: '账号 A 的 issue 标题',
type: 'bug',
state: 'open',
createdAt: '2026-07-30T09:12:49.000Z',
updatedAt: null,
commentCount: null,
sources: ['cindy-tool'],
},
],
githubEnhancement: { login: 'owner-a-login', source: 'ghost' },
cachedAt: '2026-07-31T12:00:00.000Z',
...over,
};
}

/**
* 每个用例用一组**全新路径**。store 实例按 owner 路径缓存在模块级变量里,清空 buckets
* 并不会让它重建 —— 复用旧实例会读到一个已被移除的桶对象,用例之间互相污染。
*/
let caseId = 0;
const ownerPath = (owner: 'a' | 'b') => `/tmp/cindy-test-${caseId}-owner-${owner}`;

beforeEach(() => {
for (const key of Object.keys(buckets)) delete buckets[key];
caseId += 1;
ownerPathRef.value = ownerPath('a');
});

describe('首屏快照的账号隔离', () => {
it('同一账号内写了能读回来', () => {
writeMyIssuesSnapshot(snapshot());
expect(readMyIssuesSnapshot()?.items.map((i) => i.number)).toEqual([1061]);
expect(readMyIssuesSnapshot()?.githubEnhancement?.login).toBe('owner-a-login');
});

it('切到另一个账号后读不到上一个账号的快照', () => {
writeMyIssuesSnapshot(snapshot());
expect(readMyIssuesSnapshot()).not.toBeNull();

ownerPathRef.value = ownerPath('b');
// 账号 B 的首屏必须是干净的 —— 既不能看到 A 的标题,也不能看到 A 的 GitHub 用户名。
expect(readMyIssuesSnapshot()).toBeNull();
});

it('切回原账号仍能读到自己那份', () => {
writeMyIssuesSnapshot(snapshot());
ownerPathRef.value = ownerPath('b');
writeMyIssuesSnapshot(snapshot({ githubEnhancement: { login: 'owner-b-login', source: 'gh-cli' } }));

ownerPathRef.value = ownerPath('a');
expect(readMyIssuesSnapshot()?.githubEnhancement?.login).toBe('owner-a-login');
});

it('账号 B 写入不会污染账号 A 的桶', () => {
writeMyIssuesSnapshot(snapshot());
ownerPathRef.value = ownerPath('b');
writeMyIssuesSnapshot(snapshot({ items: [] }));

ownerPathRef.value = ownerPath('a');
expect(readMyIssuesSnapshot()?.items).toHaveLength(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* 首屏快照的清洗 —— 落盘文件是**不可信输入**(可被篡改、可能是旧版本写的)。
* 判据与 payload 解析、账本清洗刻意保持一致:这一族在 #1103 / #1224 里反复漏过。
* 只测纯函数,不碰 electron-store。
*/

import { describe, expect, it } from 'vitest';

import type { MyIssueItem } from '../../../shared/myIssues';
import { normalizeSnapshot, normalizeSnapshotItems, __testing } from '../myIssuesSnapshotStore';

function item(over: Partial<MyIssueItem> = {}): MyIssueItem {
const number = over.number ?? 1061;
return {
number,
url: `https://github.com/makecindy/cindy/issues/${number}`,
title: '标题',
type: 'bug',
state: 'open',
createdAt: '2026-07-30T09:12:49.000Z',
updatedAt: null,
commentCount: null,
sources: ['cindy-tool'],
...over,
};
}

describe('normalizeSnapshotItems', () => {
it('正常条目原样保留', () => {
expect(normalizeSnapshotItems([item()])).toEqual([item()]);
});

it('链接一律按 number 派生,不采纳落盘的值', () => {
// 快照文件可被篡改,而这一页每行都声称「这是你在本仓提的 issue」、整行点击直接
// 交给 openExternal。派生而非校验 —— 与 #1224 确立的「url 只有一个产出方式」一致。
const [normalized] = normalizeSnapshotItems([
item({ number: 42, url: 'https://evil.example.com/phish' }),
]);
expect(normalized.url).toBe('https://github.com/makecindy/cindy/issues/42');
});

it('丢掉形状不对的条目', () => {
const dropped = [
null,
'nope',
{ ...item(), number: 0 },
{ ...item(), number: 1.5 },
{ ...item(), title: '' },
// createdAt 不可解析 → 排序比较器会得到 NaN,让**整份**列表顺序未定义
{ ...item(), createdAt: 'not-a-date' },
{ ...item(), state: 'reopened' },
// sources 全非法 ⇒ 无法标注来源,不如不显示
{ ...item(), sources: ['made-up'] },
{ ...item(), sources: [] },
];
expect(normalizeSnapshotItems(dropped)).toEqual([]);
});

it('可选字段坏掉时降级为 null,不整条丢弃', () => {
const [normalized] = normalizeSnapshotItems([
item({ type: 'question' as never, updatedAt: 'nope', commentCount: 'lots' as never }),
]);
expect(normalized).toMatchObject({ type: null, updatedAt: null, commentCount: null });
});

it('只保留合法的来源,顺序按既有约定', () => {
const [normalized] = normalizeSnapshotItems([
item({ sources: ['github-account', 'nonsense', 'cindy-tool'] as never }),
]);
expect(normalized.sources).toEqual(['cindy-tool', 'github-account']);
});

it('总量压在上限内 —— 首屏只需要看得见的那一段', () => {
const many = Array.from({ length: __testing.MAX_SNAPSHOT_ITEMS + 50 }, (_, i) =>
item({ number: i + 1 }),
);
expect(normalizeSnapshotItems(many)).toHaveLength(__testing.MAX_SNAPSHOT_ITEMS);
});

it('非数组输入返回空列表', () => {
expect(normalizeSnapshotItems(undefined)).toEqual([]);
expect(normalizeSnapshotItems({ items: [] })).toEqual([]);
});
});

describe('normalizeSnapshot', () => {
it('完整快照原样通过', () => {
const snapshot = {
items: [item()],
githubEnhancement: { login: 'octocat', source: 'ghost' as const },
cachedAt: '2026-07-31T12:00:00.000Z',
};
expect(normalizeSnapshot(snapshot)).toEqual(snapshot);
});

it('cachedAt 缺失或不可解析时当作没有快照', () => {
for (const bad of [undefined, '', 'yesterday', 123]) {
expect(normalizeSnapshot({ items: [item()], cachedAt: bad })).toBeNull();
}
});

it('身份形状不对时降级为 null,但条目照常保留', () => {
const result = normalizeSnapshot({
items: [item()],
githubEnhancement: { login: '', source: 'ghost' },
cachedAt: '2026-07-31T12:00:00.000Z',
});
expect(result?.githubEnhancement).toBeNull();
expect(result?.items).toHaveLength(1);

const badSource = normalizeSnapshot({
items: [],
githubEnhancement: { login: 'octocat', source: 'carrier-pigeon' },
cachedAt: '2026-07-31T12:00:00.000Z',
});
expect(badSource?.githubEnhancement).toBeNull();
});

it('null / 非对象一律当没有快照', () => {
expect(normalizeSnapshot(null)).toBeNull();
expect(normalizeSnapshot('nope')).toBeNull();
expect(normalizeSnapshot(undefined)).toBeNull();
});

it('空列表的快照是合法的 —— 但它不代表「查证过没有」', () => {
// 语义在 useMyIssues 的 hasFreshData 那一层收口:快照顶上来时不下任何结论。
const result = normalizeSnapshot({
items: [],
githubEnhancement: null,
cachedAt: '2026-07-31T12:00:00.000Z',
});
expect(result).toEqual({ items: [], githubEnhancement: null, cachedAt: '2026-07-31T12:00:00.000Z' });
});
});
13 changes: 12 additions & 1 deletion apps/desktop/src/main/github-issue/myIssuesRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@

import { GithubClient } from '@cindy/github-client';

import { MY_ISSUES_REPOSITORY, type MyIssuesDegradedReason } from '../../shared/myIssues.js';
import {
MY_ISSUES_REPOSITORY,
type MyIssuesDegradedReason,
type MyIssuesSnapshot,
} from '../../shared/myIssues.js';
import { getAppCapabilities } from '../appCapabilities.js';
import { activeOwnerScopeKey } from '../appSessionState.js';
import { getClientEndpoint } from '../clientEndpointsService';
Expand All @@ -39,6 +43,7 @@ import {
type RemoteIssuePage,
} from './myIssuesService.js';
import { listSubmittedIssues } from './submittedIssueLedger.js';
import { readMyIssuesSnapshot, writeMyIssuesSnapshot } from './myIssuesSnapshotStore.js';

const log = createLogger('github-issue/my-issues-runtime');

Expand Down Expand Up @@ -74,12 +79,18 @@ export function getMyIssuesService(): MyIssuesService {
resolveGithubEnhancement: resolveGithubEnhancement,
searchAuthoredIssues: searchAuthoredIssues,
searchAuthoredIssuesFallback: searchAuthoredIssuesFallback,
writeSnapshot: writeMyIssuesSnapshot,
readScope: activeOwnerScopeKey,
});
}
return serviceInstance;
}

/** 首屏快照:进页面先渲染上次结果,不用空等远端。没有 / 坏掉返回 null。 */
export function getMyIssuesSnapshot(): MyIssuesSnapshot | null {
return readMyIssuesSnapshot();
}

/** 提交成功后让列表缓存立即失效,不然新提交的那条最多要等 60s 才出现。 */
export function invalidateMyIssuesCache(): void {
serviceInstance?.invalidate();
Expand Down
Loading
Loading