Files

368 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 宿主半边端到端测试:
// - 用真实的 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');