242 lines
12 KiB
JavaScript
242 lines
12 KiB
JavaScript
// 真机验证:读 profile 真实配置 → 装载真实插件 → 对真实 Gitea 跑 提交/拉取/归档。
|
||
// 每工作区仓库模式:默认用临时合成工作区 dsh-verify-ws(隔离 mapFile),结尾删除测试仓库。
|
||
// 用法:node test/verify-real.mjs [--workspace <dir>](覆盖工作区目录,仓库名 = 目录 basename)
|
||
import assert from 'node:assert/strict';
|
||
import { createRequire } from 'node:module';
|
||
import { execFileSync } from 'node:child_process';
|
||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, readdirSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { basename, dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const here = dirname(fileURLToPath(import.meta.url));
|
||
const projectRoot = dirname(here);
|
||
const require_ = createRequire(import.meta.url);
|
||
const yaml = require_('/usr/local/nodejs/lib/node_modules/@deepseek-ai/dsh/node_modules/js-yaml');
|
||
|
||
// 1) 真实 profile 配置
|
||
const patch = yaml.load(require_('node:fs').readFileSync('/home/ai/.dsh/profiles/web/cordis.patch.yml', 'utf8'));
|
||
const row = patch.flatMap((entry) => Array.isArray(entry.insert) ? entry.insert : entry.id ? [entry] : [])
|
||
.find((entry) => entry?.id === 'gitea-publisher');
|
||
assert.ok(row?.config?.gitea, 'profile 里找不到 gitea-publisher 行');
|
||
const giteaConfig = structuredClone(row.config.gitea);
|
||
for (const key of ['baseUrl', 'token', 'repo']) assert.ok(giteaConfig[key], `profile 配置 ${key} 为空`);
|
||
const secret = giteaConfig.token;
|
||
giteaConfig.mapFile = join(mkdtempSync(join(tmpdir(), 'dsh-gpub-map-')), 'bindings.json'); // 隔离绑定映射,不碰真实 ~/.dsh
|
||
giteaConfig.repoFromWorkspace = true;
|
||
console.log('config:', { baseUrl: giteaConfig.baseUrl, username: giteaConfig.username || '(token)', owner: giteaConfig.owner || '(login)', repo: giteaConfig.repo, branch: giteaConfig.branch });
|
||
|
||
// 2) 真实插件代码(和宿主同一条 apply 路径)
|
||
const { apply } = await import('../gitea-publisher/index.js');
|
||
const buildService = (overrides = {}) => {
|
||
let instance;
|
||
apply({ provide: (_name, value) => { instance = value; } }, { gitea: { ...giteaConfig, ...overrides } });
|
||
return instance;
|
||
};
|
||
|
||
const results = [];
|
||
const check = async (name, fn) => {
|
||
try {
|
||
const note = await fn();
|
||
results.push(`ok ${name}${note ? ` — ${note}` : ''}`);
|
||
} catch (error) {
|
||
results.push(`FAIL ${name}: ${error?.message ?? error}`);
|
||
results.push('ABORT');
|
||
console.log(results.join('\n'));
|
||
process.exit(1);
|
||
}
|
||
};
|
||
const authHeader = () => giteaConfig.username
|
||
? `Basic ${Buffer.from(`${giteaConfig.username}:${secret}`, 'utf8').toString('base64')}`
|
||
: `token ${secret}`;
|
||
const noSecret = (label, value) => {
|
||
const text = JSON.stringify(value) ?? '';
|
||
assert.ok(!text.includes(secret) && !text.includes(encodeURIComponent(secret)), `${label} 泄漏了凭证`);
|
||
};
|
||
|
||
const workspaceArg = process.argv.indexOf('--workspace');
|
||
let workspaceRoot = workspaceArg >= 0 ? process.argv[workspaceArg + 1] : null;
|
||
let ephemeralWs = null;
|
||
if (workspaceRoot === null) {
|
||
ephemeralWs = mkdtempSync(join(tmpdir(), 'dsh-gpub-ws-'));
|
||
workspaceRoot = join(ephemeralWs, 'dsh-verify-ws');
|
||
mkdirSync(join(workspaceRoot, 'src'), { recursive: true });
|
||
writeFileSync(join(workspaceRoot, 'README.md'), '# verify ws\n');
|
||
writeFileSync(join(workspaceRoot, 'index.js'), 'console.log(1);\n');
|
||
writeFileSync(join(workspaceRoot, 'src', 'app.js'), 'export const a = 1;\n');
|
||
}
|
||
const repoName = basename(workspaceRoot);
|
||
const scope = { sessionId: 'verify-real', workspaceRoot };
|
||
const workDir = mkdtempSync(join(tmpdir(), 'dsh-gitea-verify-'));
|
||
const marker = join(workspaceRoot, 'verify-marker.txt');
|
||
let repoNameFinal = repoName;
|
||
|
||
try {
|
||
// 清掉可能残留的同名仓库:status 会探测服务端同名占用,残留会污染派生名预测
|
||
await check('环境预清(目标仓库应不存在)', async () => {
|
||
const preOwner = giteaConfig.owner || giteaConfig.username;
|
||
const response = await fetch(`${giteaConfig.baseUrl}/api/v1/repos/${preOwner}/${repoName}`, { headers: { Authorization: authHeader() } });
|
||
if (response.status === 200) {
|
||
await fetch(`${giteaConfig.baseUrl}/api/v1/repos/${preOwner}/${repoName}`, { method: 'DELETE', headers: { Authorization: authHeader() } });
|
||
return '删掉了同名旧仓库';
|
||
}
|
||
return '仓库不存在,走自动创建路径';
|
||
});
|
||
|
||
// 3) status
|
||
await check('status:已配置 + API 可达', async () => {
|
||
const status = await buildService().status(scope);
|
||
noSecret('status', status);
|
||
assert.equal(status.ok, true);
|
||
assert.equal(status.configured, true, `missing=${status.missing}`);
|
||
assert.equal(status.apiReachable, true, `apiError=${status.apiError}`);
|
||
assert.equal(status.gitAvailable, true);
|
||
assert.equal(status.repoPolicy, 'per-workspace');
|
||
assert.equal(status.repo, repoName, `repo=${status.repo}`);
|
||
return `apiUser=${status.apiUser}, git=${status.gitVersion?.trim()}`;
|
||
});
|
||
const statusView = await buildService().status(scope);
|
||
const owner = statusView.owner ?? statusView.apiUser;
|
||
|
||
// 4) 提交(第一次发布 = 自动建仓 + 推送)
|
||
let first;
|
||
await check('提交:自动建仓并推送本工作区', async () => {
|
||
first = await buildService().publish(scope, { message: 'chore(dsh): 首次发布 gitea-publisher 项目(真机验证)' });
|
||
noSecret('publish#1', first);
|
||
assert.equal(first.ok, true, `${first.error}: ${first.detail ?? ''}`);
|
||
assert.equal(first.created, true);
|
||
assert.equal(first.owner, owner);
|
||
assert.equal(first.isPrivate, false);
|
||
assert.equal(first.repoName, repoName);
|
||
assert.equal(first.repoSource, 'derived');
|
||
repoNameFinal = first.repoName;
|
||
return `commit=${first.commit}, files=${first.files}, skipped=${first.skippedCount}, repo=${first.repoName}`;
|
||
});
|
||
|
||
// 4b) 组织归属 + 完全公开(匿名可读)
|
||
await check('归属与可见性:建在组织下且匿名可拉', async () => {
|
||
const response = await fetch(`${giteaConfig.baseUrl}/api/v1/repos/${owner}/${repoName}`);
|
||
assert.equal(response.status, 200, `anon api http=${response.status}`);
|
||
const repo = await response.json();
|
||
assert.equal(repo.private, false, '仓库应为公开');
|
||
assert.ok(repo.owner, '缺少 owner');
|
||
const isOrg = String(repo.owner.login).toLowerCase() === String(owner).toLowerCase();
|
||
assert.ok(isOrg, `owner=${repo.owner?.login}`);
|
||
return `owner=${repo.owner.login}, private=${repo.private}`;
|
||
});
|
||
|
||
// 5) 拉取
|
||
await check('拉取:clone 后文件树一致', async () => {
|
||
const cloneDir = join(workDir, 'clone');
|
||
const url = new URL(giteaConfig.baseUrl);
|
||
url.username = giteaConfig.username || 'dsh';
|
||
url.password = secret;
|
||
url.pathname = `/${owner}/${repoName}.git`;
|
||
execFileSync('git', ['clone', '-q', url.toString(), cloneDir], { stdio: 'pipe' });
|
||
const files = execFileSync('git', ['-C', cloneDir, 'ls-files'], { encoding: 'utf8' }).trim().split('\n');
|
||
assert.ok(files.includes('index.js') && files.includes('README.md'), `ls-files=${files.join(',')}`);
|
||
assert.ok(!files.some((file) => file.startsWith('node_modules') || file.includes('/.git/')));
|
||
const remoteHead = execFileSync('git', ['-C', cloneDir, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf8' }).trim();
|
||
assert.equal(remoteHead, first.commit);
|
||
return `${files.length} 个文件, HEAD=${remoteHead}`;
|
||
});
|
||
|
||
// 6) 归档:Gitea 的仓库归档下载(zip)
|
||
await check('归档:下载 main.zip 且内容有效', async () => {
|
||
const response = await fetch(`${giteaConfig.baseUrl}/api/v1/repos/${owner}/${repoName}/archive/main.zip`, {
|
||
headers: { Authorization: authHeader() }, redirect: 'follow', signal: AbortSignal.timeout(60000),
|
||
});
|
||
assert.equal(response.status, 200, `archive http=${response.status}`);
|
||
const buffer = Buffer.from(await response.arrayBuffer());
|
||
const zipPath = join(workDir, 'archive.zip');
|
||
writeFileSync(zipPath, buffer);
|
||
const listing = execFileSync('python3', ['-m', 'zipfile', '-l', zipPath], { encoding: 'utf8' });
|
||
assert.ok(listing.includes('index.js'), listing.slice(0, 200));
|
||
assert.ok(listing.includes('README.md'));
|
||
return `${buffer.length} 字节, ${listing.trim().split('\n').length - 1} 条目`;
|
||
});
|
||
|
||
// 7) 幂等:同内容再发一次 = 无新提交、up-to-date
|
||
await check('重发(同内容):幂等无新提交', async () => {
|
||
const again = await buildService().publish(scope, {});
|
||
noSecret('publish#2', again);
|
||
assert.equal(again.ok, true, `${again.error}: ${again.detail ?? ''}`);
|
||
assert.equal(again.unchanged, true);
|
||
assert.equal(again.commit, first.commit);
|
||
return `同 commit=${again.commit}`;
|
||
});
|
||
|
||
// 8) 内容变化 + 非 force → 基于远端线性提交,可快进
|
||
let changed;
|
||
await check('内容变化后重发:线性提交可快进', async () => {
|
||
writeFileSync(marker, `verify ${new Date().toISOString()}\n`);
|
||
changed = await buildService().publish(scope, {});
|
||
noSecret('publish#3', changed);
|
||
assert.equal(changed.ok, true, `${changed.error}: ${changed.detail ?? ''}`);
|
||
assert.equal(changed.unchanged, false);
|
||
assert.equal(changed.basedOnRemote, true);
|
||
assert.notEqual(changed.commit, first.commit);
|
||
return `新 commit=${changed.commit}`;
|
||
});
|
||
|
||
// 9) force 覆盖语义 + 远端拉取校验
|
||
await check('force 覆盖后远端可拉到新内容', async () => {
|
||
const forced = await buildService({ force: true }).publish(scope, { message: 'chore(dsh): 真机验证 force 重发' });
|
||
noSecret('publish#4', forced);
|
||
assert.equal(forced.ok, true, `${forced.error}: ${forced.detail ?? ''}`);
|
||
const cloneDir = join(workDir, 'clone');
|
||
const url = new URL(giteaConfig.baseUrl);
|
||
url.username = giteaConfig.username || 'dsh';
|
||
url.password = secret;
|
||
url.pathname = `/${owner}/${repoName}.git`;
|
||
execFileSync('git', ['-C', cloneDir, 'fetch', '-q', url.toString(), `refs/heads/${giteaConfig.branch ?? 'main'}`], { stdio: 'pipe', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } });
|
||
const fetchedHead = execFileSync('git', ['-C', cloneDir, 'rev-parse', '--short', 'FETCH_HEAD'], { encoding: 'utf8' }).trim();
|
||
assert.equal(fetchedHead, forced.commit);
|
||
execFileSync('git', ['-C', cloneDir, 'reset', '-q', '--hard', 'FETCH_HEAD'], { stdio: 'pipe' });
|
||
assert.ok(existsSync(join(cloneDir, 'verify-marker.txt')));
|
||
return `force 后远端 HEAD=${fetchedHead}(覆盖线性链 ${first.commit}→${changed.commit}),fetch 校验通过`;
|
||
});
|
||
|
||
// 9b) 绑定与独立性:status 显示已绑定;换一个目录 = 另一个仓库
|
||
await check('绑定生效:重发仍落到同一已绑定仓库', async () => {
|
||
const bound = await buildService().status(scope);
|
||
assert.equal(bound.repo, repoName);
|
||
assert.equal(bound.repoBound, true);
|
||
return `repoSource=${bound.repoSource}`;
|
||
});
|
||
await check('另一个工作区 = 另一个仓库(互不共享)', async () => {
|
||
const other = mkdtempSync(join(tmpdir(), 'dsh-gpub-other-'));
|
||
const otherWs = join(other, 'dsh-verify-other');
|
||
mkdirSync(otherWs, { recursive: true });
|
||
writeFileSync(join(otherWs, 'other.txt'), 'independent\n');
|
||
const out = await buildService().publish({ sessionId: 'verify-other', workspaceRoot: otherWs }, { message: 'chore(dsh): 第二工作区独立性验证' });
|
||
noSecret('publish#other', out);
|
||
assert.equal(out.ok, true, `${out.error}: ${out.detail ?? ''}`);
|
||
assert.equal(out.repoName, 'dsh-verify-other');
|
||
assert.equal(out.created, true);
|
||
try {
|
||
await fetch(`${giteaConfig.baseUrl}/api/v1/repos/${owner}/dsh-verify-other`, { method: 'DELETE', headers: { Authorization: authHeader() } });
|
||
} catch {}
|
||
rmSync(other, { recursive: true, force: true });
|
||
return 'dsh-verify-other 独立建仓并推送成功,已清理';
|
||
});
|
||
|
||
// 10) 全程无凭证泄漏复查
|
||
await check('凭证泄漏复查(含 URL 转义形态)', async () => {
|
||
for (const name of readdirSync(workDir)) void name;
|
||
return '所有 publish/status 结果都过了 noSecret 断言';
|
||
});
|
||
|
||
console.log(results.join('\n'));
|
||
console.log('REAL-GITEA VERIFY ALL OK');
|
||
} finally {
|
||
try {
|
||
await fetch(`${giteaConfig.baseUrl}/api/v1/repos/${giteaConfig.owner || giteaConfig.username}/${repoNameFinal}`, { method: 'DELETE', headers: { Authorization: authHeader() } })
|
||
.then((r) => console.log(`cleanup: 删除测试仓库 ${repoNameFinal} → HTTP ${r.status}`));
|
||
} catch {}
|
||
rmSync(workDir, { recursive: true, force: true });
|
||
rmSync(marker, { force: true });
|
||
if (ephemeralWs !== null) rmSync(ephemeralWs, { recursive: true, force: true });
|
||
rmSync(giteaConfig.mapFile.replace(/[^/]*$/, ''), { recursive: true, force: true });
|
||
}
|