173 lines
8.7 KiB
JavaScript
173 lines
8.7 KiB
JavaScript
// 浏览器半边测试:在 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');
|