Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
274 changes: 274 additions & 0 deletions .tests/google-gmail-accounts.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createContext, runInContext } from 'node:vm';
import test from 'node:test';
import { setImmediate as flush } from 'node:timers/promises';

const read = (file) => readFileSync(new URL('../' + file, import.meta.url), 'utf8');
const source = read('google-gmail/account-metadata.js');
const copy = (value) => JSON.parse(JSON.stringify(value));
const plugins = { 'google-gmail': 'gmail' };

async function connectSettings(reply, locale = 'zh-CN') {
const elements = Object.fromEntries(['connect', 'status', 'reauth'].map(id => [id, { disabled: false, textContent: '' }]));
const calls = [];
const ctx = createContext({
AbortController, setTimeout, clearTimeout,
document: { documentElement: {}, getElementById: id => elements[id] },
window: { renderGoogleAccounts() {} },
googleAccountMetadata: { list: async (_key, accounts) => accounts },
fetch: async (url, options = {}) => {
calls.push({ url, ...options });
if (url === '/app-context') return { ok: true, json: async () => ({ context: { locale } }) };
if (url === '/oauth') return { ok: true, json: async () => [{ key: 'gmail_account', accounts: [] }] };
assert.equal(url, '/oauth/gmail_account/connect');
assert.equal(options.method, 'POST');
return reply();
},
});
runInContext(read('google-gmail/settings.js'), ctx);
await flush();
elements.connect.onclick();
await flush();
assert.equal(elements.connect.disabled, false, 'every outcome releases the connect button');
assert.equal(calls.filter(call => call.method === 'POST').length, 1, 'never retry automatically');
return elements.status.textContent;
}

test('连接结果未知时先核对账号状态,覆盖响应丢失、HTTP 错误和解析失败', async () => {
const failures = [
async () => { throw new Error('response lost'); },
async () => ({ ok: false, status: 503 }),
async () => ({ ok: true, json: async () => { throw new SyntaxError('invalid JSON'); } }),
async () => ({ ok: true, json: async () => null }),
async () => ({ ok: true, json: async () => ({ ok: false, error: 'UNKNOWN' }) }),
];
for (const reply of failures) {
assert.equal(await connectSettings(reply), '无法确认连接结果,请重新打开插件详情核对账号状态,再决定是否重试。');
}
for (const [locale, expected] of [['en', /Connection outcome is unknown.*check the account status before deciding/], ['ja', /アカウントの状態を確認/], ['ko', /계정 상태를 확인/], ['fr', /Connection outcome is unknown/]]) {
assert.match(await connectSettings(failures[0], locale), expected);
}
});

test('连接成功及明确取消仍显示原有结果', async () => {
assert.equal(await connectSettings(async () => ({ ok: true, json: async () => ({ ok: true, account: { id: 'account-test', label: 'work@example.test' } }) })), '已连接 work@example.test');
assert.equal(await connectSettings(async () => ({ ok: true, json: async () => ({ ok: false, error: 'CANCELLED' }) })), '授权已取消');
});

function fixture(key = 'gmail_account') {
let data = { unrelated: { retained: true } };
let tail = Promise.resolve();
const accounts = [
{ id: 'acc-work', label: 'work@example.test', status: 'connected', nickname: 'Host value is not authoritative' },
{ id: 'acc-personal', label: 'personal@example.test', status: 'expired' },
];
const calls = [];
const locks = { request(_name, action) {
const pending = tail.then(action);
tail = pending.catch(() => {});
return pending;
} };
const env = {
navigator: { locks },
fetch: async (url, options = {}) => {
calls.push({ url, ...options });
if (url === '/oauth') return { ok: true, json: async () => [{ key, clientConfigured: true, accounts: copy(accounts) }] };
if (url === '/app-context') return { ok: true, json: async () => ({ context: { locale: 'en' } }) };
assert.equal(url, '/kv', 'labels must use only existing same-origin storage');
if (options.method === 'PUT') data = JSON.parse(options.body);
return { ok: true, json: async () => copy(data) };
},
};
function context(extra = {}) {
const ctx = createContext({ ...env, ...extra });
runInContext(source, ctx);
return ctx;
}
return { context, env, accounts, calls, data: () => copy(data) };
}

test('昵称保存、清空和断开清理只修改插件 KV,重新加载仍可读取', async () => {
const f = fixture();
const before = copy(f.accounts);
await f.context().googleAccountMetadata.save('gmail_account', 'acc-work', ' 公司邮箱 ');
const api = f.context().googleAccountMetadata;
assert.equal((await api.list('gmail_account', f.accounts))[0].nickname, '公司邮箱');
assert.deepEqual(f.accounts, before);
assert.deepEqual(f.data().unrelated, { retained: true });
await api.save('gmail_account', 'acc-work', '');
assert.equal((await api.list('gmail_account', f.accounts))[0].nickname, '');
await api.save('gmail_account', 'acc-personal', '私人邮箱');
await api.remove('gmail_account', 'acc-personal');
assert.deepEqual(f.data().accountNicknames.gmail_account, {});
assert.ok(f.calls.filter(call => call.method === 'PUT').every(call => call.url === '/kv'));
});

test('拒绝不存在的账号和非法昵称,不把昵称写成授权身份', async () => {
const f = fixture();
const api = f.context().googleAccountMetadata;
for (const value of ['a'.repeat(81), '公司\n邮箱', 1]) {
await assert.rejects(api.save('gmail_account', 'acc-work', value), /Invalid/);
}
await assert.rejects(api.save('gmail_account', 'missing', '公司'), /no longer connected/);
await assert.rejects(api.save('other-key', 'acc-work', '公司'), /no longer connected/);
assert.equal(f.calls.filter(call => call.method === 'PUT').length, 0);
});

test('跨页面并发修改不同账号不覆盖,插件间数据不共享', async () => {
const f = fixture();
await Promise.all([
f.context().googleAccountMetadata.save('gmail_account', 'acc-work', '公司'),
f.context().googleAccountMetadata.save('gmail_account', 'acc-personal', '私人'),
]);
assert.deepEqual(f.data().accountNicknames.gmail_account, { 'acc-work': '公司', 'acc-personal': '私人' });
const otherPlugin = fixture();
assert.equal((await otherPlugin.context().googleAccountMetadata.list('gmail_account', otherPlugin.accounts))[0].nickname, '');
});

test('存储读写失败不报告成功,也不使用 Host 昵称兜底', async () => {
for (const failedMethod of ['GET', 'PUT']) {
const f = fixture();
const api = f.context({ fetch: (url, options = {}) => url === '/kv' && (options.method || 'GET') === failedMethod
? Promise.resolve({ ok: false }) : f.env.fetch(url, options) }).googleAccountMetadata;
await assert.rejects(api.save('gmail_account', 'acc-work', '公司'), /preferences/);
assert.deepEqual(f.data(), { unrelated: { retained: true } });
if (failedMethod === 'GET') {
const accounts = await api.list('gmail_account', f.accounts);
assert.equal(accounts.length, 2);
assert.equal(accounts[0].id, 'acc-work');
assert.equal(accounts[0].nickname, '');
}
}
});

test('昵称 KV JSON 损坏不影响账号列表,但保存仍拒绝覆盖', async () => {
const f = fixture();
const api = f.context({ fetch: (url, options = {}) => url === '/kv'
? Promise.resolve({ ok: true, json: async () => { throw new SyntaxError('invalid JSON'); } })
: f.env.fetch(url, options) }).googleAccountMetadata;
const accounts = await api.list('gmail_account', f.accounts);
assert.equal(accounts.length, 2);
assert.equal(accounts[0].nickname, '');
await assert.rejects(api.save('gmail_account', 'acc-work', '公司'), /invalid JSON/);
assert.equal(f.calls.filter(call => call.method === 'PUT').length, 0);
});

for (const [plugin, prefix] of Object.entries(plugins)) {
test(`${plugin}: 打包源和昵称读写契约不依赖客户端专用接口`, () => {
assert.equal(read(plugin + '/account-metadata.js'), source);
assert.ok(read(plugin + '/main.js').includes(source));
assert.match(read(plugin + '/settings.html'), /account-metadata\.js[\s\S]*account-nickname\.js[\s\S]*settings\.js/);
const editor = read(plugin + '/account-nickname.js');
assert.match(editor, /googleAccountMetadata\.save\(key, account\.id,/);
assert.match(editor, /googleAccountMetadata\.remove\(key, account\.id\)/);
assert.doesNotMatch(editor, /\/nickname|unsupported/);
assert.match(editor, /disconnectFailed: '无法确认.*核对账号状态.*再决定是否重试/);
assert.match(editor, /disconnectFailed: 'Disconnect outcome is unknown\..*check the account status before deciding whether to retry/);
});

test(`${plugin}: 模型读取插件昵称,执行仍用账号 ID,不默选其他账号`, async () => {
const key = prefix + '_account';
const f = fixture(key);
await f.context().googleAccountMetadata.save(key, 'acc-work', '公司邮箱');
const handlers = [], replies = [], requests = [];
const ctx = f.context({ cindy: {
onHostMessage: handler => handlers.push(handler),
send: async reply => replies.push(copy(reply)),
node: { request: async request => { requests.push(copy(request)); return { ok: true, result: { ok: true, data: {} } }; } },
} });
runInContext(read(plugin + '/main.js'), ctx);
const call = async (tool, args = {}) => {
replies.length = 0;
for (const handler of handlers) await handler({ type: 'tool-call', tool, args, callId: 'test-call' });
assert.equal(replies.length, 1);
return replies[0];
};
assert.equal((await call(prefix + '_accounts')).result.accounts[0].nickname, '公司邮箱');
await f.context().googleAccountMetadata.save(key, 'acc-work', '工作');
assert.equal((await call(prefix + '_accounts')).result.accounts[0].nickname, '工作');
assert.equal((await call(prefix + '_run', { command: ['list'] })).ok, false);
assert.equal((await call(prefix + '_run', { account: '公司邮箱', command: ['list'] })).ok, false);
assert.equal((await call(prefix + '_run', { account: 'acc-personal', command: ['list'] })).ok, false);
assert.equal(requests.length, 0);
f.accounts[0].scopeStale = true;
assert.equal((await call(prefix + '_run', { account: 'acc-work', command: ['list'] })).ok, false);
assert.equal(requests.length, 0);
f.accounts[0].scopeStale = false;
assert.equal((await call(prefix + '_run', { account: 'acc-work', command: ['list'], session_context: { workdir_is_local: true, workdir_is_read_only: false, workdir: '/test-workdir' } })).ok, true);
assert.equal(requests[0].authAccount, 'acc-work');
assert.equal(JSON.stringify(requests[0]).includes('工作'), false);
});

test(`${plugin}: 旧客户端缺少运行接口时提示升级,账号列表仍可使用`, async () => {
for (const node of [undefined, {}, { request: async () => { throw new Error('must not execute'); } }]) {
const f = fixture(prefix + '_account');
let handler;
const replies = [];
const ctx = f.context({ cindy: {
onHostMessage: value => { handler = value; },
send: async reply => replies.push(copy(reply)), node,
} });
runInContext(read(plugin + '/main.js'), ctx);
await handler({ type: 'tool-call', tool: prefix + '_run', callId: 'old-host', args: {} });
assert.equal(replies.length, 1);
assert.equal(replies[0].ok, false);
assert.match(replies[0].message, /尚未执行.*0\.1\.82/);
await handler({ type: 'tool-call', tool: prefix + '_accounts', callId: 'accounts', args: {} });
assert.equal(replies[1].ok, true);
assert.equal(replies[1].result.accounts.length, 2);
}
});

test(`${plugin}: 账号服务失败保留可操作提示,执行前失败不调用 worker`, async () => {
const cases = [
[async () => { throw new Error('transport details'); }, /无法连接.*重新打开/],
[async () => undefined, /未返回结果.*稍后重试/],
...[401, 403].map(status => [async () => ({ ok: false, status }), /拒绝访问.*检查连接状态/]),
[async () => ({ ok: false, status: 503 }), /暂时不可用.*重新打开/],
[async () => ({ ok: true, json: async () => { throw new Error('parse details'); } }), /无法解析.*重新打开/],
...[{}, [{ key: 'gmail_account', clientConfigured: true }],
[{ key: 'gmail_account', clientConfigured: true, accounts: [null] }]].map(data =>
[async () => ({ ok: true, json: async () => data }), /格式异常.*重新打开/]),
];
for (const [fetch, expected] of cases) {
let handler;
const replies = [];
let requested = false;
const ctx = fixture().context({ fetch, cindy: {
onHostMessage: value => { handler = value; }, send: async reply => replies.push(copy(reply)),
node: { request: async () => { requested = true; } },
} });
runInContext(read(plugin + '/main.js'), ctx);
for (const tool of [prefix + '_accounts', prefix + '_run']) {
await handler({ type: 'tool-call', tool, callId: 'bad-accounts', args: { session_context: {} } });
assert.equal(replies.at(-1).ok, false);
assert.match(replies.at(-1).message, expected);
}
assert.match(replies.at(-1).message, /^尚未执行/);
assert.equal(requested, false);
assert.equal(replies.length, 2);
}
});

test(`${plugin}: 设置页先合并插件昵称再渲染,保存后重新读取`, async () => {
const key = prefix + '_account';
const f = fixture(key);
await f.context().googleAccountMetadata.save(key, 'acc-work', '公司');
const nodes = {};
const renders = [];
let refresh;
const ctx = f.context({
AbortController, setTimeout, clearTimeout,
document: { documentElement: { lang: 'en' }, getElementById: id => nodes[id] ||= {} },
window: { renderGoogleAccounts: (_key, accounts, reload) => { renders.push(copy(accounts)); refresh = reload; } },
});
runInContext(read(plugin + '/settings.js'), ctx);
await new Promise(setImmediate);
assert.equal(renders.length, 1);
assert.equal(renders[0][0].nickname, '公司');
await f.context().googleAccountMetadata.save(key, 'acc-work', '工作');
await refresh();
assert.equal(renders.at(-1)[0].nickname, '工作');
});
}
98 changes: 98 additions & 0 deletions .tests/google-gmail-packaging.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { once } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { brotliDecompressSync } from 'node:zlib';
import test from 'node:test';

const root = path.resolve(import.meta.dirname, '..');
const plugin = path.join(root, 'google-gmail');
const json = filename => JSON.parse(fs.readFileSync(path.join(plugin, filename), 'utf8'));
const declaration = json('binary-dependencies.json');
const binaries = json('vendor/gog/binaries.json');
const release = json('vendor/gog/release.json');
const tracked = execFileSync('git', ['ls-files', '-z', '--', 'google-gmail'], { cwd: root, encoding: 'utf8' }).split('\0').filter(Boolean);
const digest = bytes => createHash('sha256').update(bytes).digest('hex');

test('Gmail declares all pinned gog assets without tracking payloads', () => {
assert.equal(declaration.dependencies.length, 1);
const dependency = declaration.dependencies[0];
assert.equal(dependency.version, release.version);
assert.equal(dependency.assets.length, 6);
assert.equal(tracked.filter(file => file.endsWith('.br')).length, 0);
for (const asset of dependency.assets) {
assert.equal(asset.platforms.length, 1);
const [platform] = asset.platforms;
const key = platform.replace('x64', 'amd64');
assert.equal(asset.sha256, release.assets[key]);
assert.equal(asset.sha256, binaries[key].releaseSha256);
assert.equal(asset.files[0].target, `vendor/gog/${key}.br`);
assert.equal(asset.files[0].encoding, 'brotli');
}
});

test('Gmail source → real downloads → package → native worker schema', {
skip: process.env.CINDY_GMAIL_BINARY_SMOKE !== '1', timeout: 1_800_000,
}, async () => {
// Snapshot the tracked working file set into an isolated test repository. No commits,
// branches, installed plugins or user credentials in the real repo change.
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-gmail-package-'));
const fixture = path.join(directory, 'source');
fs.mkdirSync(fixture);
const git = (...args) => execFileSync('git', args, { cwd: fixture, encoding: 'utf8' }).trim();
git('init', '-q');
const commit = () => git('-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgsign=false',
'-c', 'user.name=Packaging Fixture', '-c', 'user.email=fixture@example.test', 'commit', '--allow-empty', '-qm', 'fixture');
commit();
const base = git('rev-parse', 'HEAD');
for (const file of [...tracked, 'LICENSE', 'NOTICE', 'TRADEMARKS.md', 'TRADEMARKS.zh-CN.md']) {
const target = path.join(fixture, file);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(path.join(root, file), target);
}
git('add', '.'); commit();
execFileSync('node', [path.join(root, '.github/scripts/check-source-size.mjs'), base], { cwd: fixture, stdio: 'inherit' });
const output = path.join(directory, 'google-gmail-1.3.1.cindy');
console.log(`Gmail smoke output: ${output}`);
execFileSync('bash', [path.join(root, '.github/scripts/package-plugin.sh'), 'google-gmail', output], {
cwd: fixture, stdio: 'inherit', timeout: 1_700_000,
});
const unzipped = path.join(directory, 'unpacked');
execFileSync('unzip', ['-q', output, '-d', unzipped]);
for (const [key, expected] of Object.entries(binaries)) {
const compressed = fs.readFileSync(path.join(unzipped, 'vendor/gog', `${key}.br`));
assert.equal(digest(compressed), expected.compressedSha256, key);
assert.equal(digest(brotliDecompressSync(compressed)), expected.sha256, key);
}
const worker = spawn(process.execPath, [path.join(unzipped, 'node/gog.cjs')], { stdio: ['pipe', 'pipe', 'pipe'] });
const closed = once(worker, 'close');
let stdout = '';
let stderr = '';
const timer = setTimeout(() => worker.kill('SIGKILL'), 30_000);
worker.stderr.setEncoding('utf8').on('data', chunk => { stderr += chunk; });
worker.stdout.setEncoding('utf8').on('data', chunk => {
stdout += chunk;
if (stdout.includes('\n')) worker.stdin.end();
});
worker.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'schema', params: {} }) + '\n');
try {
const [code] = await closed;
assert.equal(code, 0, stderr);
const response = JSON.parse(stdout.trim());
assert.equal(response.result.ok, true, stdout);
const commands = response.result.data.subcommands.map(command => command.name);
assert.ok(commands.includes('attachment'), JSON.stringify(commands));
assert.ok(commands.includes('send'), JSON.stringify(commands));
const evidence = { package: output, bytes: fs.statSync(output).size, sha256: digest(fs.readFileSync(output)),
verifiedPlatforms: Object.keys(binaries), runtimePlatform: `${process.platform}-${process.arch}`,
gmailCommands: commands, realAccountAccessed: false };
fs.writeFileSync(path.join(directory, 'verification.json'), JSON.stringify(evidence, null, 2));
console.log(JSON.stringify(evidence));
} finally {
clearTimeout(timer);
if (worker.exitCode === null) worker.kill('SIGKILL');
}
});
Loading
Loading