|
@@ -0,0 +1,156 @@
|
|
|
|
|
+'use strict';
|
|
|
|
|
+const fs = require('node:fs');
|
|
|
|
|
+const path = require('node:path');
|
|
|
|
|
+const cp = require('node:child_process');
|
|
|
|
|
+const assert = require('node:assert/strict');
|
|
|
|
|
+const vm = require('node:vm');
|
|
|
|
|
+const { isDeepStrictEqual } = require('node:util');
|
|
|
|
|
+
|
|
|
|
|
+const APP = 'APP_ZQ3I7XO2RSHDJ4QDEVNB';
|
|
|
|
|
+const FORM = 'FORM-1741B1D56FE04480B0E4F5412FF9AE6ATYGR';
|
|
|
|
|
+
|
|
|
|
|
+/** @returns {object} 使用已安装OpenYida的编译、认证、校验和发布传输。 */
|
|
|
|
|
+function toolchain() {
|
|
|
|
|
+ const executable = cp.execFileSync('which', ['openyida'], { encoding: 'utf8' }).trim();
|
|
|
|
|
+ const root = path.resolve(path.dirname(fs.realpathSync(executable)), '..');
|
|
|
|
|
+ return {
|
|
|
|
|
+ transform: require(root + '/lib/core/babel-transform').default,
|
|
|
|
|
+ guard: require(root + '/lib/core/no-emoji-guard'),
|
|
|
|
|
+ client: require(root + '/lib/core/yida-client'),
|
|
|
|
|
+ utils: require(root + '/lib/core/utils'),
|
|
|
|
|
+ transport: require(root + '/lib/app/publish').sendSaveRequestWithAuth
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** @param {object} value Schema响应或Schema @returns {object} 完整Schema */
|
|
|
|
|
+function schemaContent(value) {
|
|
|
|
|
+ assert.ok(value && typeof value === 'object' && value.success !== false, 'Schema响应无效');
|
|
|
|
|
+ const content = value.content === undefined ? value : value.content;
|
|
|
|
|
+ const schema = typeof content === 'string' ? JSON.parse(content) : content;
|
|
|
|
|
+ assert.ok(schema && Array.isArray(schema.pages) && schema.actions, '缺少页面或动作配置');
|
|
|
|
|
+ return schema;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** @param {object} schema 完整Schema @returns {{node:object,path:string}} 唯一原生Jsx节点 */
|
|
|
|
|
+function jsxNode(schema) {
|
|
|
|
|
+ const found = [];
|
|
|
|
|
+ /** @param {object} value 节点 @param {string} pointer JSON路径 @returns {void} */
|
|
|
|
|
+ function visit(value, pointer) {
|
|
|
|
|
+ if (!value || typeof value !== 'object') return;
|
|
|
|
|
+ if (value.componentName === 'Jsx' && value.props && value.props.render) found.push({ node: value, path: pointer });
|
|
|
|
|
+ for (const [key, child] of Object.entries(value)) visit(child, pointer + '/' + key);
|
|
|
|
|
+ }
|
|
|
|
|
+ visit(schema.pages, '/pages');
|
|
|
|
|
+ assert.equal(found.length, 1, '正式页必须包含唯一的既有Jsx渲染节点');
|
|
|
|
|
+ return found[0];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** @param {string} source 源码 @param {boolean} render 是否为独立渲染 @param {object} tools 工具链 @returns {string} 平台编译码 */
|
|
|
|
|
+function compile(source, render, tools) {
|
|
|
|
|
+ const result = tools.transform(source, {}, render, { RE_VERSION: '7.4.0' });
|
|
|
|
|
+ if (result.error) throw result.error;
|
|
|
|
|
+ assert.ok(result.compiled, '平台编译码不能为空');
|
|
|
|
|
+ new vm.Script(render ? '(' + result.compiled + ')' : result.compiled);
|
|
|
|
|
+ return result.compiled;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** @param {object} baseline 最新基线 @param {string} actions 合并后的JS @param {string} render 合并后的JSX @param {object} tools 工具链 @returns {{schema:object,paths:string[],functions:number}} 限定范围候选 */
|
|
|
|
|
+function buildCandidate(baseline, actions, render, tools = toolchain()) {
|
|
|
|
|
+ const schema = structuredClone(baseline);
|
|
|
|
|
+ const target = jsxNode(schema);
|
|
|
|
|
+ const module = { source: actions, compiled: compile(actions, false, tools) };
|
|
|
|
|
+ const names = [...actions.matchAll(/export\s+(?:async\s+)?function\s+(\w+)\s*\(/g)].map(match => match[1]);
|
|
|
|
|
+ assert.ok(names.length > 0, '动作模块没有导出函数');
|
|
|
|
|
+ assert.equal(names.length, new Set(names).size, '动作函数不能重名');
|
|
|
|
|
+ for (const item of schema.actions.list) assert.ok(names.includes(item.id), '不能移除原动作:' + item.id);
|
|
|
|
|
+ schema.actions.module = { ...schema.actions.module, ...module };
|
|
|
|
|
+ const oldNames = new Set(schema.actions.list.map(item => item.id));
|
|
|
|
|
+ schema.actions.list.push(...names.filter(id => !oldNames.has(id)).map(id => ({ id, title: id })));
|
|
|
|
|
+ target.node.props.render = { ...target.node.props.render, source: render, compiled: compile(render, true, tools) };
|
|
|
|
|
+ const paths = ['/actions/module/source', '/actions/module/compiled', '/actions/list',
|
|
|
|
|
+ target.path + '/props/render/source', target.path + '/props/render/compiled'];
|
|
|
|
|
+ // prd 只清理当前页面内的连接器描述装饰,不触及URL、参数、鉴权或连接器主资源。
|
|
|
|
|
+ const root = schema.pages[0].componentsTree[0];
|
|
|
|
|
+ (root.dataSource.online || []).forEach((dataSource, index) => {
|
|
|
|
|
+ const connector = dataSource.options && dataSource.options.connector;
|
|
|
|
|
+ if (!connector || typeof connector.description !== 'string') return;
|
|
|
|
|
+ const cleaned = connector.description.replace(tools.guard.EMOJI_PATTERN, '');
|
|
|
|
|
+ if (cleaned === connector.description) return;
|
|
|
|
|
+ connector.description = cleaned;
|
|
|
|
|
+ paths.push('/pages/0/componentsTree/0/dataSource/online/' + index + '/options/connector/description');
|
|
|
|
|
+ });
|
|
|
|
|
+ tools.guard.assertNoEmojiInValue(schema, { artifact: 'TimeCard双文件候选', code: 'OPENYIDA_FORM_SCHEMA_EMOJI_FORBIDDEN' });
|
|
|
|
|
+ verifyUnchanged(baseline, schema, paths);
|
|
|
|
|
+ return { schema, paths, functions: names.length };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** @param {object} target 待规范化副本 @param {string} pointer 已验证JSON路径 @returns {void} */
|
|
|
|
|
+function omit(target, pointer) {
|
|
|
|
|
+ const parts = pointer.slice(1).split('/');
|
|
|
|
|
+ const parent = parts.slice(0, -1).reduce((node, key) => node[key], target);
|
|
|
|
|
+ delete parent[parts.at(-1)];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** @param {object} before 基线 @param {object} after 候选或回读 @param {string[]} paths 允许变化路径 @returns {void} */
|
|
|
|
|
+function verifyUnchanged(before, after, paths) {
|
|
|
|
|
+ const a = structuredClone(before), b = structuredClone(after);
|
|
|
|
|
+ for (const pointer of [...paths, '/gmtModified']) { omit(a, pointer); omit(b, pointer); }
|
|
|
|
|
+ assert.deepEqual(b, a, '检测到发布范围外的Schema变化');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** @param {object} baseline 候选绑定基线 @param {object} current 保存前线上基线 @returns {void} */
|
|
|
|
|
+function assertCurrentBaseline(baseline, current) {
|
|
|
|
|
+ assert.ok(Number.isFinite(baseline.gmtModified), '缺少服务端版本标识');
|
|
|
|
|
+ assert.ok(isDeepStrictEqual(baseline, current), '线上页面已变化,必须重新拉取并合并;停止发布');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** @param {object} authRef 平台登录引用 @param {object} tools 工具链 @returns {Promise<object>} 只读最新Schema */
|
|
|
|
|
+async function fetchSchema(authRef, tools) {
|
|
|
|
|
+ const result = await tools.utils.requestWithAutoLogin(auth => tools.utils.httpGet(auth.baseUrl,
|
|
|
|
|
+ '/alibaba/web/' + APP + '/_view/query/formdesign/getFormSchema.json',
|
|
|
|
|
+ { formUuid: FORM, schemaVersion: 'V5' }), authRef);
|
|
|
|
|
+ assert.ok(result && result.success !== false && !result.__needLogin && !result.__csrfExpired, '读取线上Schema失败');
|
|
|
|
|
+ return schemaContent(result);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** @param {object} baseline 已备份最新Schema @param {object} candidate 已验证候选 @param {string} output 证据目录 @param {object} tools 工具链 @returns {Promise<void>} 绑定原版本保存并独立回读 */
|
|
|
|
|
+async function publish(baseline, candidate, output, tools) {
|
|
|
|
|
+ const auth = tools.client.createAuthRef();
|
|
|
|
|
+ const current = await fetchSchema(auth, tools);
|
|
|
|
|
+ assertCurrentBaseline(baseline, current);
|
|
|
|
|
+ // fixme 保存始终传候选绑定的原revision,写前及写入时任意并发变化均不能被覆盖。
|
|
|
|
|
+ fs.writeFileSync(path.join(output, '发布尝试.json'), JSON.stringify({ formUuid: FORM, revision: baseline.gmtModified, attemptedAt: new Date().toISOString() }, null, 2));
|
|
|
|
|
+ const result = await tools.transport(auth, JSON.stringify(candidate.schema), APP, FORM, baseline.gmtModified);
|
|
|
|
|
+ fs.writeFileSync(path.join(output, '发布回执.json'), JSON.stringify(result, null, 2));
|
|
|
|
|
+ assert.ok(result && result.success, '发布未成功;先只读核对回执及线上状态,不自动重试');
|
|
|
|
|
+ const readback = await fetchSchema(auth, tools);
|
|
|
|
|
+ fs.writeFileSync(path.join(output, '发布后Schema.json'), JSON.stringify(readback, null, 2));
|
|
|
|
|
+ verifyUnchanged(candidate.schema, readback, []);
|
|
|
|
|
+ const receipt = { success: true, formUuid: FORM, version: result.content && result.content.version,
|
|
|
|
|
+ gmtModified: readback.gmtModified, originalRevision: baseline.gmtModified,
|
|
|
|
|
+ codeAndConfigurationVerified: true, retainedDataSources: readback.pages[0].componentsTree[0].dataSource.online.length,
|
|
|
|
|
+ runtimeSmokeVerified: false, url: auth.baseUrl + '/' + APP + '/workbench/' + FORM };
|
|
|
|
|
+ fs.writeFileSync(path.join(output, '独立回读验收.json'), JSON.stringify(receipt, null, 2));
|
|
|
|
|
+ console.log(JSON.stringify(receipt));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** @returns {Promise<void>} 默认仅生成候选,显式--apply才实际发布指定正式页 */
|
|
|
|
|
+async function main() {
|
|
|
|
|
+ const [baselineFile, outputDir, ...flags] = process.argv.slice(2);
|
|
|
|
|
+ assert.ok(baselineFile && outputDir && flags.every(flag => flag === '--apply'),
|
|
|
|
|
+ '用法:node pages/scripts/publish-timecard-main.cjs <最新Schema文件> <证据目录> [--apply]');
|
|
|
|
|
+ const baseline = schemaContent(JSON.parse(fs.readFileSync(baselineFile, 'utf8')));
|
|
|
|
|
+ const tools = toolchain();
|
|
|
|
|
+ const actions = fs.readFileSync(path.join(__dirname, '../src/timecard-main.actions.js'), 'utf8');
|
|
|
|
|
+ const render = fs.readFileSync(path.join(__dirname, '../src/timecard-main.render.jsx'), 'utf8');
|
|
|
|
|
+ const candidate = buildCandidate(baseline, actions, render, tools);
|
|
|
|
|
+ fs.mkdirSync(outputDir, { recursive: true });
|
|
|
|
|
+ assert.ok(!fs.existsSync(path.join(outputDir, '发布尝试.json')), '证据目录已存在发布尝试,请先核对,不能盲目重试');
|
|
|
|
|
+ fs.writeFileSync(path.join(outputDir, '候选Schema.json'), JSON.stringify(candidate.schema, null, 2));
|
|
|
|
|
+ fs.writeFileSync(path.join(outputDir, '候选校验.json'), JSON.stringify({ functions: candidate.functions, allowedPaths: candidate.paths, baselineRevision: baseline.gmtModified }, null, 2));
|
|
|
|
|
+ if (flags.includes('--apply')) await publish(baseline, candidate, outputDir, tools);
|
|
|
|
|
+ else console.log(JSON.stringify({ prepared: true, functions: candidate.functions, changedPaths: candidate.paths.length, published: false }));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+module.exports = { buildCandidate, assertCurrentBaseline, verifyUnchanged, schemaContent };
|
|
|
|
|
+if (require.main === module) main().catch(error => { console.error(error.message); process.exitCode = 1; });
|