Files
gitea/test/e2e-client.mjs
T

173 lines
8.7 KiB
JavaScript
Raw 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.
// 浏览器半边测试:在 node 里用假 window/require/ctx 跑工厂,
// 复刻 dsh-api-gateway client 的 validateInvocation + requireStrictInputs 规则校验描述符,
// 并检查 zh/en 字典覆盖组件用到的所有 key 与宿主所有 error 码。
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const clientSource = readFileSync(join(here, '..', 'gitea-publisher', 'client.js'), 'utf8');
const hostSource = readFileSync(join(here, '..', 'gitea-publisher', 'index.js'), 'utf8');
const results = [];
const check = (name, fn) => {
try {
fn();
results.push(`ok ${name}`);
} catch (error) {
results.push(`FAIL ${name}: ${error?.message ?? error}`);
process.exitCode = 1;
}
};
let loaded = null;
globalThis.window = { __ModuleLoader__: { load: (module) => { loaded = module; } } };
await import('../gitea-publisher/client.js');
check('module registers with loader id', () => assert.equal(loaded.id, '@local/gitea-publisher'));
const ReactStub = { createElement: (type, props, ...kids) => ({ type, props, kids }), useState: (init) => [init, () => {}], useRef: () => ({ current: null }), useEffect: () => {}, Fragment: 'Fragment' };
const exports = loaded.factory((name) => (name === 'react' ? ReactStub : {}));
check('exports injects remote/slots/locale', () => assert.deepEqual(exports.inject, ['remote', 'slots', 'locale']));
const effects = [];
const dictionaries = [];
const mounts = [];
const injected = [];
const published = [];
const statuses = [];
let disposed = 0;
const ctx = {
effect: (fn, label) => { effects.push({ fn, label }); },
locale: { register: (id, dict) => { dictionaries.push([id, dict]); return () => {}; } },
remote: {
$mount: async (contribution) => { mounts.push(contribution); return async () => { disposed += 1; }; },
giteaPublish: {
status: async (sessionId) => { statuses.push(sessionId); return { ok: true, value: { ok: true, configured: true } }; },
publish: async (sessionId, request) => { published.push([sessionId, request]); return { ok: true, value: { ok: true, commit: 'abc' } }; },
},
},
slots: { inject: (slotName, factory) => { injected.push([slotName, factory]); } },
get: (name) => (name === 'remote.giteaPublish' ? ctx.remote.giteaPublish : undefined),
};
exports.apply(ctx);
check('two effects + one slot injection', () => {
assert.equal(effects.length, 2);
assert.equal(injected.length, 1);
assert.equal(injected[0][0], 'conversation.session.header.actions');
});
for (const effect of effects) await effect.fn();
check('locale + remote contributions mounted', () => {
assert.equal(dictionaries[0][0], 'gitea');
assert.equal(mounts.length, 1);
});
// —— 复刻 validateInvocation / validateContribution 规则(dsh-typert-registry client.js:1314,api-gateway client validateContribution)——
const WIRE = /^[A-Za-z0-9_$.-]+$/;
function validateDescriptor(descriptor, seenIds, seenMethods) {
assert.ok(descriptor.id.length > 0, 'empty id');
assert.ok(!descriptor.service.includes('#'), 'service has #');
assert.ok(WIRE.test(descriptor.namespace) && descriptor.namespace !== '.', 'namespace');
assert.ok(WIRE.test(descriptor.method), 'method');
const key = `${descriptor.namespace}/${descriptor.method}`;
assert.equal(seenMethods.has(key), false, `duplicate ${key}`);
seenMethods.add(key);
assert.equal(seenIds.has(descriptor.id), false, 'duplicate id');
seenIds.add(descriptor.id);
if (descriptor.result.mode !== 'src-json') throw new Error('result must be src-json');
const wires = new Set();
for (const parameter of descriptor.parameters) {
assert.ok(WIRE.test(parameter.name) && WIRE.test(parameter.wire), `wire names ${parameter.name}`);
assert.equal(wires.has(parameter.wire), false, 'duplicate wire');
wires.add(parameter.wire);
if (parameter.source === 'lookup') {
assert.ok(parameter.lookup !== undefined && !parameter.lookup.includes('#'), 'lookup key');
assert.equal(parameter.acceptsUndefined, undefined, 'lookup cannot acceptUndefined');
} else {
assert.equal(parameter.lookup, undefined, 'json parameter has lookup key');
}
assert.equal(parameter.codec.mode, 'strict', 'client input codecs must be strict');
assert.ok(parameter.codec.typeSymbol.length > 0, 'typeSymbol');
assert.equal(typeof parameter.codec.create, 'function', 'create factory');
}
if (descriptor.cancellation !== undefined) assert.equal(descriptor.cancellation.parameter, 'signal');
assert.equal(descriptor.invocation.kind, 'direct');
}
check('descriptors pass replicated client validation', () => {
const seenIds = new Set();
const seenMethods = new Set();
for (const descriptor of mounts[0].descriptors) validateDescriptor(descriptor, seenIds, seenMethods);
});
check('status descriptor carries the workspace lookup parameter', () => {
const descriptor = mounts[0].descriptors.find((entry) => entry.method === 'status');
assert.equal(descriptor.parameters.length, 1);
assert.equal(descriptor.parameters[0].name, 'workspaceFileScope');
assert.equal(descriptor.parameters[0].wire, 'workspaceFileScopeId');
assert.equal(descriptor.parameters[0].lookup, 'workspaceFileScope');
});
check('publish descriptor matches host lookup contract', () => {
const descriptor = mounts[0].descriptors.find((entry) => entry.method === 'publish');
assert.equal(descriptor.parameters[0].name, 'workspaceFileScope');
assert.equal(descriptor.parameters[0].wire, 'workspaceFileScopeId');
assert.equal(descriptor.parameters[0].lookup, 'workspaceFileScope');
assert.equal(descriptor.parameters[1].name, 'request');
});
// —— 槽位注入闭包:再 apply 一次到带 register 的 ctx,然后调用注入工厂 ——
const slotsCaptured = [];
const ctx3 = { ...ctx, slots: { inject: (slotName, factory) => { slotsCaptured.push([slotName, factory]); }, register: (spec3, component) => { slotsCaptured.registered = [spec3, component]; return () => {}; } } };
exports.apply(ctx3);
slotsCaptured[0][1](); // 触发 ctx3 闭包里的 slots.register
// 第二次 apply 新登记的 effect(index 2、3)必须先跑,$mount 的 ready promise 才会 resolve。
for (const effect of effects.slice(2)) await effect.fn();
const [slotSpec, slotComponent] = slotsCaptured.registered;
check('slot registration spec', () => {
assert.equal(slotSpec.name, 'conversation.session.header.actions');
assert.equal(slotSpec.id, 'gitea-publish');
assert.equal(slotSpec.locale, 'gitea');
});
const closures = slotSpec.inject();
const publishOut = await closures.runPublish('sess-9', {});
check('runPublish forwards session id', () => {
assert.equal(published.length, 1);
assert.equal(published[0][0], 'sess-9');
});
check('runPublish unwraps the RPC envelope', () => assert.deepEqual(publishOut, { ok: true, commit: 'abc' }));
const statusOut = await closures.getStatus('sess-7');
check('getStatus unwraps the RPC envelope', () => assert.deepEqual(statusOut, { ok: true, configured: true }));
check('getStatus forwards session id (per-workspace repo)', () => assert.deepEqual(statuses, ['sess-7']));
// —— 字典完备性 ——
const [localeId, dictionaries0] = dictionaries[0] === undefined ? [] : [dictionaries[0][0], dictionaries[0][1]];
check('zh/en dictionaries both present', () => {
assert.ok(dictionaries0.zh && dictionaries0.en);
assert.deepEqual(Object.keys(dictionaries0.zh).sort(), Object.keys(dictionaries0.en).sort());
});
const usedKeys = [...clientSource.matchAll(/\bt\('([^']+)'/g)].map((match) => match[1]);
check('every t() key exists in dictionary', () => {
for (const key of usedKeys) assert.ok(key in dictionaries0.zh, `missing key ${key}`);
});
const hostErrors = [...hostSource.matchAll(/error:\s*'([a-z-]+)'/g)].map((match) => match[1]);
const uniqueHostErrors = [...new Set(hostErrors)].filter((code) => code !== 'direct');
check('every host error code has zh/en message', () => {
for (const code of uniqueHostErrors) {
assert.ok(`error.${code}` in dictionaries0.zh, `zh missing error.${code} (${uniqueHostErrors.join(',')})`);
assert.ok(`error.${code}` in dictionaries0.en, `en missing error.${code}`);
}
});
// —— 组件渲染冒烟(stub react,跑一次 render 函数体不会崩) ——
check('component renders without throwing (stub react)', () => {
const tree = slotComponent({ sessionId: 's', t: (key) => `[${key}]`, getStatus: () => Promise.resolve(null), runPublish: () => Promise.resolve({}) });
assert.equal(tree.type, 'span');
});
// disposer
for (const effect of effects) {
const disposer = await effect.fn();
if (typeof disposer === 'function') await disposer();
}
check('$mount disposers invoked', () => assert.ok(disposed >= 1));
console.log(results.join('\n'));
console.log(process.exitCode ? 'CLIENT TEST FAILED' : 'CLIENT TEST ALL OK');