368 lines
17 KiB
JavaScript
368 lines
17 KiB
JavaScript
// 宿主半边端到端测试:
|
||
// - 用真实的 dsh-typert-protocol remoteMethods() 校验原型标记;
|
||
// - 复刻 gateway 的 methodParameterNames 验证签名可解析;
|
||
// - 起一个假 Gitea(HTTP):/api/v1/user、/api/v1/repos、POST /user/repos + git-http-backend 真推送;
|
||
// - 完整跑 publish 的创建/成功/拒绝/强推四条路径。
|
||
import assert from 'node:assert/strict';
|
||
import { createServer } from 'node:http';
|
||
import { spawn, spawnSync } from 'node:child_process';
|
||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { remoteMethods } from '/usr/local/nodejs/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-typert-protocol/lib/index.js';
|
||
|
||
const here = dirname(fileURLToPath(import.meta.url));
|
||
const TOKEN = 'tk-e2e-secret';
|
||
const results = [];
|
||
const check = (name, fn) => {
|
||
try {
|
||
fn();
|
||
results.push(`ok ${name}`);
|
||
} catch (error) {
|
||
results.push(`FAIL ${name}: ${error?.message ?? error}`);
|
||
process.exitCode = 1;
|
||
}
|
||
};
|
||
|
||
// —— 复刻 gateway 的 methodParameterNames(源出 dsh-api-gateway/lib/index.js:1458)——
|
||
function methodParameterNames(service, method) {
|
||
let prototype = Object.getPrototypeOf(service);
|
||
let implementation;
|
||
while (prototype !== null) {
|
||
const descriptor = Object.getOwnPropertyDescriptor(prototype, method);
|
||
if (descriptor !== void 0) {
|
||
if ('value' in descriptor && typeof descriptor.value === 'function') implementation = descriptor.value;
|
||
break;
|
||
}
|
||
prototype = Object.getPrototypeOf(prototype);
|
||
}
|
||
if (implementation === void 0) throw new Error(`no prototype method ${method}`);
|
||
const source = Function.prototype.toString.call(implementation);
|
||
const open = source.indexOf('(');
|
||
const close = source.indexOf(')', open + 1);
|
||
if (open < 0 || close < 0) throw new Error('bad params');
|
||
const body = source.slice(open + 1, close).trim();
|
||
if (body.length === 0) return [];
|
||
const names = new Set();
|
||
for (const part of body.split(',').map((x) => x.trim())) {
|
||
if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) throw new Error(`invalid signature param "${part}"`);
|
||
names.add(part);
|
||
}
|
||
return [...names];
|
||
}
|
||
|
||
// —— 装载宿主模块 ——
|
||
const host = await import('../gitea-publisher/index.js');
|
||
assert.equal(typeof host.apply, 'function');
|
||
|
||
// —— 假 Gitea + git http backend ——
|
||
const ROOT = mkdtempSync(join(tmpdir(), 'gitea-e2e-roots-'));
|
||
mkdirSync(join(ROOT, 'tester'), { recursive: true });
|
||
const apiLog = [];
|
||
const server = createServer((req, res) => {
|
||
const url = new URL(req.url, 'http://stub');
|
||
if (url.pathname.startsWith('/api/v1/')) {
|
||
if (!(req.headers.authorization ?? '').includes(TOKEN)) {
|
||
res.writeHead(401, { 'Content-Type': 'application/json' }).end('{"message":"unauthenticated"}');
|
||
return;
|
||
}
|
||
if (req.method === 'GET' && url.pathname === '/api/v1/user') {
|
||
res.writeHead(200, { 'Content-Type': 'application/json' }).end('{"login":"tester"}');
|
||
return;
|
||
}
|
||
const repoGet = /^\/api\/v1\/repos\/tester\/([^/]+)$/.exec(url.pathname);
|
||
if (req.method === 'GET' && repoGet) {
|
||
const name = decodeURIComponent(repoGet[1]);
|
||
if (existsSync(join(ROOT, 'tester', `${name}.git`))) res.writeHead(200, { 'Content-Type': 'application/json' }).end(`{"name":"${name}"}`);
|
||
else res.writeHead(404, { 'Content-Type': 'application/json' }).end('{"message":"not found"}');
|
||
return;
|
||
}
|
||
if (req.method === 'POST' && url.pathname === '/api/v1/user/repos') {
|
||
let body = '';
|
||
req.on('data', (chunk) => body += chunk);
|
||
req.on('end', () => {
|
||
apiLog.push(['POST /user/repos', body]);
|
||
let name = 'demo';
|
||
try { name = JSON.parse(body).name; } catch {}
|
||
if (!/^[A-Za-z0-9._-]+$/.test(name)) { res.writeHead(422).end('{"message":"bad name"}'); return; }
|
||
const made = spawnSync('git', ['init', '--bare', '-b', 'main', join(ROOT, 'tester', `${name}.git`)], { encoding: 'utf8' });
|
||
if (made.status !== 0) res.writeHead(500).end('{"message":"git init failed"}');
|
||
else res.writeHead(201, { 'Content-Type': 'application/json' }).end(`{"name":"${name}"}`);
|
||
});
|
||
return;
|
||
}
|
||
res.writeHead(404, { 'Content-Type': 'application/json' }).end('{"message":"no route"}');
|
||
return;
|
||
}
|
||
if (/\/[^/]+\/[^/]+\.git(\/|$)/.test(url.pathname)) {
|
||
const body = [];
|
||
req.on('data', (chunk) => body.push(chunk));
|
||
req.on('end', () => {
|
||
const payload = Buffer.concat(body);
|
||
const child = spawn('/usr/lib/git-core/git-http-backend', {
|
||
env: {
|
||
...process.env,
|
||
GIT_PROJECT_ROOT: ROOT,
|
||
GIT_HTTP_EXPORT_ALL: '1',
|
||
REMOTE_USER: 'tester',
|
||
PATH_INFO: decodeURIComponent(url.pathname),
|
||
REQUEST_METHOD: req.method,
|
||
QUERY_STRING: url.search.startsWith('?') ? url.search.slice(1) : '',
|
||
CONTENT_TYPE: req.headers['content-type'] ?? '',
|
||
CONTENT_LENGTH: String(payload.length),
|
||
REMOTE_ADDR: '127.0.0.1',
|
||
SERVER_PROTOCOL: 'HTTP/1.1',
|
||
},
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
});
|
||
const chunks = [];
|
||
const errs = [];
|
||
child.stdout.on('data', (chunk) => chunks.push(chunk));
|
||
child.stderr.on('data', (chunk) => errs.push(chunk));
|
||
child.on('close', () => {
|
||
const raw = Buffer.concat(chunks);
|
||
const split = raw.indexOf('\r\n\r\n');
|
||
if (split < 0) {
|
||
res.writeHead(502).end(Buffer.concat(errs));
|
||
return;
|
||
}
|
||
const headers = {};
|
||
let status = 200;
|
||
for (const line of raw.subarray(0, split).toString('latin1').split('\r\n')) {
|
||
const idx = line.indexOf(':');
|
||
if (idx < 0) continue;
|
||
const key = line.slice(0, idx).trim().toLowerCase();
|
||
const value = line.slice(idx + 1).trim();
|
||
if (key === 'status') status = Number(value.split(' ')[0]);
|
||
else headers[key] = value;
|
||
}
|
||
res.writeHead(status, headers);
|
||
res.end(raw.subarray(split + 4));
|
||
});
|
||
child.stdin.end(payload);
|
||
});
|
||
return;
|
||
}
|
||
res.writeHead(404).end();
|
||
});
|
||
await new Promise((done) => server.listen(0, '127.0.0.1', done));
|
||
const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||
|
||
// —— 测试工作区 ——
|
||
const WS = mkdtempSync(join(tmpdir(), 'gitea-e2e-ws-'));
|
||
const put = (rel, text) => {
|
||
const path = join(WS, rel);
|
||
mkdirSync(dirname(path), { recursive: true });
|
||
writeFileSync(path, text);
|
||
};
|
||
put('README.md', '# demo project\n');
|
||
put('index.js', 'console.log(1);\n');
|
||
put('package.json', '{\n "name": "demo"\n}\n');
|
||
put('src/app.py', 'print("hi")\n');
|
||
put('src/deep/nested.txt', 'nested\n');
|
||
put('.gitignore', 'ignored.txt\n*.log\n');
|
||
put('ignored.txt', 'should not be tracked\n');
|
||
put('debug.log', 'noise\n');
|
||
put('node_modules/junk/index.js', 'junk\n');
|
||
put('dist/bundle.js', 'junk\n');
|
||
put('.git/HEAD', 'ref: refs/heads/fake\n');
|
||
put('__pycache__/app.pyc', 'junk\n');
|
||
writeFileSync(join(WS, 'big.bin'), Buffer.alloc(40 * 1024 * 1024, 7)); // 40 MiB > 30 MiB 上限
|
||
symlinkSync('README.md', join(WS, 'link.txt'));
|
||
|
||
const MAP = join(mkdtempSync(join(tmpdir(), 'gitea-e2e-map-')), 'bindings.json');
|
||
const config = { gitea: { baseUrl, token: TOKEN, owner: '', repo: 'demo', branch: 'main', repoFromWorkspace: false, mapFile: MAP } };
|
||
let provided;
|
||
const ctx = { provide: (key, value) => { provided = { key, value }; }, logger: { info() {}, warn() {}, error() {} } };
|
||
host.apply(ctx, config);
|
||
const service = provided.value;
|
||
|
||
// —— 结构与发现 ——
|
||
check('provides giteaPublish', () => assert.equal(provided.key, 'giteaPublish'));check('typertRemote binding duck-type', () => {
|
||
assert.equal(service.typertRemote.service, service);
|
||
assert.equal(service.typertRemote.serviceKey, 'giteaPublish');
|
||
assert.equal(service.typertRemote.namespace, 'giteaPublish');
|
||
});
|
||
check('protocol remoteMethods() reads marker', () => {
|
||
const marker = remoteMethods(service);
|
||
assert.deepEqual(marker.map((entry) => entry.method), ['status', 'publish']);
|
||
for (const entry of marker) assert.equal(entry.invocation.kind, 'direct');
|
||
});
|
||
check('gateway can parse signatures', () => {
|
||
assert.deepEqual(methodParameterNames(service, 'status'), ['workspaceFileScope', 'signal']);
|
||
assert.deepEqual(methodParameterNames(service, 'publish'), ['workspaceFileScope', 'request', 'signal']);
|
||
});
|
||
|
||
// —— 运行时 ——
|
||
const unconfigured = new (service.constructor)(ctx, { gitea: {} });
|
||
const st = await unconfigured.status();
|
||
check('status(not configured) fields', () => {
|
||
assert.equal(st.ok, true);
|
||
assert.equal(st.configured, false);
|
||
assert.deepEqual(st.missing, ['baseUrl', 'token']);
|
||
assert.equal(st.gitAvailable, true);
|
||
assert.equal(JSON.stringify(st) === undefined, false);
|
||
});
|
||
const refused = await unconfigured.publish({ sessionId: 's', workspaceRoot: WS }, {});
|
||
check('publish refuses when unconfigured', () => assert.equal(refused.error, 'not-configured'));
|
||
|
||
const scope = { sessionId: 'sess-e2e', workspaceRoot: WS };
|
||
|
||
const first = await service.publish(scope, undefined);
|
||
check('first publish creates repo and pushes', () => {
|
||
if (!first.ok) throw new Error(`${first.error} :: ${first.detail ?? ''}`);
|
||
assert.equal(first.created, true);
|
||
assert.equal(first.files, 6); // README.md index.js package.json src/app.py src/deep/nested.txt .gitignore
|
||
assert.equal(first.skippedCount, 1); // big.bin 40MiB
|
||
assert.equal(first.branch, 'main');
|
||
assert.match(first.repo, /^http:\/\/127\.0\.0\.1:\d+\/tester\/demo$/);
|
||
});
|
||
check('POST /user/repos was called', () => assert.equal(apiLog.length, 1));
|
||
check('bare repo actually has the branch', () => {
|
||
const files = spawnSync('git', ['--git-dir', join(ROOT, 'tester/demo.git'), 'ls-tree', '--name-only', 'main'], { encoding: 'utf8' });
|
||
assert.equal(files.status, 0, files.stderr);
|
||
assert.deepEqual(files.stdout.split('\n').filter(Boolean), ['.gitignore', 'README.md', 'index.js', 'package.json', 'src']);
|
||
const log = spawnSync('git', ['--git-dir', join(ROOT, 'tester/demo.git'), 'log', '-1', '--format=%an <%ae> %s', 'main'], { encoding: 'utf8' });
|
||
assert.match(log.stdout, /DSH Publisher <dsh-publisher@localhost>/);
|
||
});
|
||
|
||
put('late-change.txt', 'content changed after first push\n');
|
||
const second = await service.publish(scope, {});
|
||
check('second publish commits on top of remote (linear history)', () => {
|
||
if (!second.ok) throw new Error(`${second.error} :: ${second.detail ?? ''}`);
|
||
assert.equal(second.basedOnRemote, true);
|
||
assert.equal(second.unchanged, false);
|
||
});
|
||
check('remote history is two linear commits incl. new file', () => {
|
||
const gitDir = join(ROOT, 'tester/demo.git');
|
||
const count = spawnSync('git', ['--git-dir', gitDir, 'rev-list', '--count', 'main'], { encoding: 'utf8' });
|
||
assert.equal(count.stdout.trim(), '2');
|
||
const tree = spawnSync('git', ['--git-dir', gitDir, 'ls-tree', '--name-only', 'main'], { encoding: 'utf8' });
|
||
assert.ok(tree.stdout.split('\n').includes('late-change.txt'), tree.stdout);
|
||
});
|
||
const idem = await service.publish(scope, {});
|
||
check('re-publish of identical content is idempotent', () => {
|
||
if (!idem.ok) throw new Error(`${idem.error} :: ${idem.detail ?? ''}`);
|
||
assert.equal(idem.unchanged, true);
|
||
assert.equal(idem.commit, second.commit);
|
||
});
|
||
|
||
const third = await service.publish(scope, { force: true, message: 'forced snapshot' });
|
||
check('force publish rewrites to single-commit history', () => {
|
||
if (!third.ok) throw new Error(`${third.error} :: ${third.detail ?? ''}`);
|
||
assert.equal(third.created, false);
|
||
assert.equal(third.message, 'forced snapshot');
|
||
const count = spawnSync('git', ['--git-dir', join(ROOT, 'tester/demo.git'), 'rev-list', '--count', 'main'], { encoding: 'utf8' });
|
||
assert.equal(count.stdout.trim(), '1');
|
||
});
|
||
|
||
const statusAfter = await service.status(scope);
|
||
check('status(reachable, fixed mode)', () => {
|
||
assert.equal(statusAfter.configured, true);
|
||
assert.equal(statusAfter.apiReachable, true);
|
||
assert.equal(statusAfter.apiUser, 'tester');
|
||
assert.equal(statusAfter.owner, 'tester');
|
||
assert.equal(statusAfter.repoPolicy, 'fixed');
|
||
assert.equal(statusAfter.repo, 'demo');
|
||
});
|
||
|
||
// —— 每工作区仓库模式 ——
|
||
let provided2;
|
||
host.apply({ ...ctx, provide: (key, value) => { provided2 = { key, value }; } }, { gitea: { baseUrl, token: TOKEN, owner: '', branch: 'main', mapFile: MAP } });
|
||
const service2 = provided2.value;
|
||
const mkws = (parent, name, file) => { const d = join(parent, name); mkdirSync(d, { recursive: true }); writeFileSync(join(d, file), `${name}\n`); return d; };
|
||
const PWA = mkws(mkdtempSync(join(tmpdir(), 'pw-a-')), 'proj-alpha', 'a.txt');
|
||
const PWB = mkws(mkdtempSync(join(tmpdir(), 'pw-b-')), 'proj-beta', 'b.txt');
|
||
const PWC1 = mkws(mkdtempSync(join(tmpdir(), 'pw-c1-')), 'proj-gamma', 'c1.txt');
|
||
const PWC2 = mkws(mkdtempSync(join(tmpdir(), 'pw-c2-')), 'proj-gamma', 'c2.txt');
|
||
const scopeA = { sessionId: 'sa', workspaceRoot: PWA };
|
||
const scopeB = { sessionId: 'sb', workspaceRoot: PWB };
|
||
|
||
writeFileSync(MAP, JSON.stringify({ version: 1, bindings: { '/legacy/ws': 'legacy-repo', '/junk/ws': { repo: 'bad name!' } } }));
|
||
const pwCompat = service2.loadBindings(MAP);
|
||
check('map loader accepts plain-string and {repo} entries, drops invalid', () => {
|
||
assert.equal(pwCompat['/legacy/ws'], 'legacy-repo');
|
||
assert.equal('/junk/ws' in pwCompat, false);
|
||
});
|
||
const pwStatus0 = await service2.status(scopeA);
|
||
check('status predicts derived repo for unbound workspace', () => {
|
||
assert.equal(pwStatus0.repoPolicy, 'per-workspace');
|
||
assert.equal(pwStatus0.repo, 'proj-alpha');
|
||
assert.equal(pwStatus0.repoSource, 'derived');
|
||
assert.equal(pwStatus0.repoBound, false);
|
||
});
|
||
const pwNoScope = await service2.status();
|
||
check('status without session scope reports pending resolution', () => {
|
||
assert.equal(pwNoScope.repo, null);
|
||
assert.equal(pwNoScope.repoSource, 'unknown-session');
|
||
});
|
||
const pwA = await service2.publish(scopeA, {});
|
||
check('workspace A publishes to its own repo (derived name)', () => {
|
||
if (!pwA.ok) throw new Error(`${pwA.error} :: ${pwA.detail ?? ''}`);
|
||
assert.equal(pwA.repoName, 'proj-alpha');
|
||
assert.equal(pwA.repoSource, 'derived');
|
||
assert.equal(pwA.created, true);
|
||
});
|
||
const pwB = await service2.publish(scopeB, {});
|
||
check('workspace B gets a separate repo, not shared', () => {
|
||
if (!pwB.ok) throw new Error(`${pwB.error} :: ${pwB.detail ?? ''}`);
|
||
assert.equal(pwB.repoName, 'proj-beta');
|
||
assert.ok(existsSync(join(ROOT, 'tester/proj-alpha.git')) && existsSync(join(ROOT, 'tester/proj-beta.git')));
|
||
});
|
||
const pwA2 = await service2.publish(scopeA, { message: 'second' });
|
||
check('re-publish keeps the bound repo', () => {
|
||
if (!pwA2.ok) throw new Error(`${pwA2.error} :: ${pwA2.detail ?? ''}`);
|
||
assert.equal(pwA2.repoName, 'proj-alpha');
|
||
assert.equal(pwA2.repoSource, 'bound');
|
||
assert.equal(pwA2.unchanged, true);
|
||
});
|
||
const pwStatusBound = await service2.status(scopeA);
|
||
check('status shows bound repo after first publish', () => {
|
||
assert.equal(pwStatusBound.repo, 'proj-alpha');
|
||
assert.equal(pwStatusBound.repoBound, true);
|
||
});
|
||
const pwC1 = await service2.publish({ sessionId: 'sc1', workspaceRoot: PWC1 }, {});
|
||
check('first proj-gamma takes the plain name', () => {
|
||
if (!pwC1.ok) throw new Error(`${pwC1.error} :: ${pwC1.detail ?? ''}`);
|
||
assert.equal(pwC1.repoName, 'proj-gamma');
|
||
});
|
||
const pwC2 = await service2.publish({ sessionId: 'sc2', workspaceRoot: PWC2 }, {});
|
||
check('same directory name collides into a suffixed repo', () => {
|
||
if (!pwC2.ok) throw new Error(`${pwC2.error} :: ${pwC2.detail ?? ''}`);
|
||
assert.match(pwC2.repoName, /^proj-gamma-[0-9a-f]{8}$/);
|
||
assert.equal(pwC2.repoSource, 'derived-collision');
|
||
assert.ok(existsSync(join(ROOT, `tester/${pwC2.repoName}.git`)));
|
||
});
|
||
const pwRenamed = await service2.publish(scopeB, { repo: 'renamed-here' });
|
||
check('request.repo rebinds the workspace', () => {
|
||
if (!pwRenamed.ok) throw new Error(`${pwRenamed.error} :: ${pwRenamed.detail ?? ''}`);
|
||
assert.equal(pwRenamed.repoName, 'renamed-here');
|
||
});
|
||
const pwStatusRebound = await service2.status(scopeB);
|
||
check('rebinding persists in the map file', () => assert.equal(pwStatusRebound.repo, 'renamed-here'));
|
||
const pwBadName = await service2.publish(scopeB, { repo: 'bad name!' });
|
||
check('bad repo name refused', () => assert.equal(pwBadName.error, 'bad-repo-name'));
|
||
|
||
// 泄露检查:任何结果文本里不出现 token
|
||
check('no token leak in results', () => {
|
||
const all = JSON.stringify([first, second, idem, third, statusAfter, pwStatus0, pwA, pwB, pwA2, pwC1, pwC2, pwRenamed, pwStatusRebound, pwBadName]);
|
||
assert.equal(all.includes(TOKEN), false);
|
||
});
|
||
|
||
// busy 与取消信号:直接调用两次并发
|
||
const [dupA, dupB] = await Promise.all([service.publish(scope, { force: true }), service.publish(scope, { force: true })]);
|
||
check('concurrent publish refused', () => {
|
||
const errors = [dupA, dupB].map((r) => r.error);
|
||
assert.equal(errors.includes('busy'), true);
|
||
});
|
||
|
||
server.close();
|
||
rmSync(ROOT, { recursive: true, force: true });
|
||
rmSync(WS, { recursive: true, force: true });
|
||
rmSync(MAP.replace(/[^/]*$/, ''), { recursive: true, force: true });
|
||
for (const dir of [pwStatus0, pwA, pwB, pwA2, pwC1, pwC2, pwRenamed, pwStatusRebound, pwBadName]) void dir;
|
||
for (const w of [PWA, PWB, PWC1, PWC2]) rmSync(dirname(w), { recursive: true, force: true });
|
||
console.log(results.join('\n'));
|
||
console.log(process.exitCode ? 'E2E FAILED' : 'E2E ALL OK');
|